@octane-xplat/mcp 0.1.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +338 -1
- package/package.json +37 -4
- package/src/actions/bridge.mjs +2 -0
- package/src/contracts/actions.d.ts +209 -0
- package/src/contracts/actions.mjs +3 -0
- package/src/contracts/boundaries.d.ts +6 -0
- package/src/contracts/boundaries.mjs +3 -0
- package/src/contracts/capabilities.d.ts +106 -0
- package/src/contracts/capabilities.mjs +3 -0
- package/src/contracts/code.d.ts +134 -0
- package/src/contracts/code.mjs +3 -0
- package/src/contracts/errors.d.ts +75 -0
- package/src/contracts/errors.mjs +3 -0
- package/src/contracts/evidence.d.ts +115 -0
- package/src/contracts/evidence.mjs +3 -0
- package/src/contracts/identity.d.ts +127 -0
- package/src/contracts/identity.mjs +3 -0
- package/src/contracts/index.d.ts +11 -0
- package/src/contracts/index.mjs +3 -0
- package/src/contracts/inspection.d.ts +45 -0
- package/src/contracts/inspection.mjs +3 -0
- package/src/contracts/policy.d.ts +86 -0
- package/src/contracts/policy.mjs +3 -0
- package/src/contracts/sessions.d.ts +160 -0
- package/src/contracts/sessions.mjs +3 -0
- package/src/contracts/status.d.ts +40 -0
- package/src/contracts/status.mjs +3 -0
- package/src/discovery.mjs +2 -0
- package/src/docs.mjs +84 -0
- package/src/inspection/service.mjs +5 -0
- package/src/inspection/tools.mjs +53 -0
- package/src/runtime/logs.mjs +2 -0
- package/src/runtime/process.mjs +2 -0
- package/src/runtime/service.mjs +2 -0
- package/src/runtime/targets.mjs +2 -0
- package/src/runtime/tools.mjs +126 -0
- package/src/server.mjs +565 -0
- package/src/status.mjs +2 -0
- package/src/xplat-mcp.mjs +85 -0
package/src/server.mjs
ADDED
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
// MCP server assembly: registers the package-A contract tools against the
|
|
2
|
+
// workspace context. Later packages add their own tools/providers through the
|
|
3
|
+
// same context — one coherent entry point.
|
|
4
|
+
|
|
5
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
6
|
+
import { readFileSync } from 'node:fs'
|
|
7
|
+
import { z } from 'zod'
|
|
8
|
+
import { contractError, toContractError } from './contracts/errors.mjs'
|
|
9
|
+
import { EVIDENCE_KINDS } from './contracts/evidence.mjs'
|
|
10
|
+
import { PLATFORMS } from './contracts/identity.mjs'
|
|
11
|
+
import { PERMISSION_SCOPES } from './contracts/policy.mjs'
|
|
12
|
+
import { discoverWorkspace } from './discovery.mjs'
|
|
13
|
+
import { registerRuntimeTools } from './runtime/tools.mjs'
|
|
14
|
+
import { registerDocsTools } from './docs.mjs'
|
|
15
|
+
import { registerInspectionTools } from './inspection/service.mjs'
|
|
16
|
+
import { bindApp, buildProjectStatus, resolveSelection, SERVER_NAME } from './status.mjs'
|
|
17
|
+
|
|
18
|
+
// Lockstep releases bump the manifest; the handshake must follow it.
|
|
19
|
+
const SERVER_VERSION = JSON.parse(
|
|
20
|
+
readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
|
|
21
|
+
).version
|
|
22
|
+
|
|
23
|
+
const INSTRUCTIONS =
|
|
24
|
+
'MCP server for Octane Xplat apps. Call xplat_project_status first — it reports detected apps, framework versions, guidance, prerequisites, and capabilities. ' +
|
|
25
|
+
'If several apps are detected, ask the developer which to use and bind it with xplat_select_app; never guess. ' +
|
|
26
|
+
'With a dev-mode app running, xplat_dev_actions lists the app-declared action registry and xplat_dev_action_invoke runs one against an exact instance and registry revision. ' +
|
|
27
|
+
'For runtime work, check xplat_capability_matrix then xplat_targets; xplat_launch starts an owned session as a job and xplat_attach binds a session you already run. ' +
|
|
28
|
+
'Results always say what was actually checked; a successful build is not proof of behavior.'
|
|
29
|
+
|
|
30
|
+
const respond = (outcome) => ({
|
|
31
|
+
structuredContent: outcome,
|
|
32
|
+
isError: outcome.ok === false,
|
|
33
|
+
content: [{ type: 'text', text: JSON.stringify(outcome, null, 2) }],
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const ok = (data) => respond({ ok: true, data })
|
|
37
|
+
const fail = (error) => respond({ ok: false, error })
|
|
38
|
+
|
|
39
|
+
/** The bound app for runtime tools — explicit, configured, or sole-app. */
|
|
40
|
+
const boundApp = (context) => {
|
|
41
|
+
const selection = resolveSelection(context)
|
|
42
|
+
return selection.state === 'bound' ? selection.app : null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const NO_APP = () =>
|
|
46
|
+
contractError('invalid_selection', 'No app is bound.', {
|
|
47
|
+
recovery: { action: 'Bind an app with xplat_select_app first.' },
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
export function createXplatMcpServer(context) {
|
|
51
|
+
const server = new McpServer(
|
|
52
|
+
{ name: SERVER_NAME, version: SERVER_VERSION },
|
|
53
|
+
{ instructions: INSTRUCTIONS },
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
// Wire the policy elicitation channel now that the protocol exists. The
|
|
57
|
+
// client must advertise elicitation support; without it, grants fall back
|
|
58
|
+
// to developer-side configuration with an honest recovery message.
|
|
59
|
+
context.policy.setElicitor(async (scope, reason) => {
|
|
60
|
+
try {
|
|
61
|
+
if (!server.server.getClientCapabilities()?.elicitation) {
|
|
62
|
+
return 'denied'
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const answer = await server.server.elicitInput({
|
|
66
|
+
message: `Octane Xplat MCP requests permission "${scope}": ${reason}`,
|
|
67
|
+
requestedSchema: {
|
|
68
|
+
type: 'object',
|
|
69
|
+
properties: {
|
|
70
|
+
grant: { type: 'boolean', description: 'Grant this permission for the current task' },
|
|
71
|
+
},
|
|
72
|
+
required: ['grant'],
|
|
73
|
+
},
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
return answer.action === 'accept' && answer.content?.grant === true ? 'granted' : 'denied'
|
|
77
|
+
} catch {
|
|
78
|
+
return 'denied'
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
// Work item C: a code provider wired into the context makes code
|
|
83
|
+
// search/read a server-side capability — supported on every platform,
|
|
84
|
+
// independent of runtime adapters.
|
|
85
|
+
if (context.code) {
|
|
86
|
+
for (const platform of PLATFORMS) {
|
|
87
|
+
context.capabilities.register({
|
|
88
|
+
platform,
|
|
89
|
+
describe: () => [
|
|
90
|
+
{
|
|
91
|
+
capability: 'code_search',
|
|
92
|
+
status: 'supported',
|
|
93
|
+
detail:
|
|
94
|
+
'Exact search reads the bound app and its installed framework sources directly. An optional local semantic index accelerates intent-style queries and never blocks exact retrieval.',
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
server.registerTool(
|
|
102
|
+
'xplat_project_status',
|
|
103
|
+
{
|
|
104
|
+
description:
|
|
105
|
+
'Report detected project identity, framework versions, guidance, docs/code-index status, declared platforms, runtime prerequisites, and the capability summary. Call first; call again after changing workspaces or installing dependencies.',
|
|
106
|
+
inputSchema: {
|
|
107
|
+
refresh: z
|
|
108
|
+
.boolean()
|
|
109
|
+
.optional()
|
|
110
|
+
.describe('Re-scan the workspace instead of using the startup snapshot'),
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
async ({ refresh }) => {
|
|
114
|
+
if (refresh) {
|
|
115
|
+
context.discovery = discoverWorkspace(context.workspaceRoot)
|
|
116
|
+
// A previously bound app may have moved or vanished; re-resolve.
|
|
117
|
+
if (context.boundApp) {
|
|
118
|
+
const still = context.discovery.apps.find((a) => a.id === context.boundApp.id)
|
|
119
|
+
context.boundApp = still ?? null
|
|
120
|
+
context.selectionReason = still ? 'explicit' : null
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return ok(await buildProjectStatus(context))
|
|
125
|
+
},
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
server.registerTool(
|
|
129
|
+
'xplat_select_app',
|
|
130
|
+
{
|
|
131
|
+
description:
|
|
132
|
+
'Bind an explicit app for subsequent work. Required when several apps are detected — the server never picks silently. Pass the app id, relative path, or package name from the status report.',
|
|
133
|
+
inputSchema: {
|
|
134
|
+
app: z.string().describe('App id, workspace-relative path, or package name'),
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
async ({ app }) => {
|
|
138
|
+
const result = bindApp(context, app)
|
|
139
|
+
if (result.error) {
|
|
140
|
+
if (result.error === 'ambiguous_selection') {
|
|
141
|
+
return fail(
|
|
142
|
+
contractError('ambiguous_selection', `"${app}" matches several apps.`, {
|
|
143
|
+
recovery: { action: 'Ask the developer which app to use, then pass its id.' },
|
|
144
|
+
context: { candidates: result.candidates },
|
|
145
|
+
}),
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return fail(
|
|
150
|
+
contractError('invalid_selection', `No app matches "${app}".`, {
|
|
151
|
+
recovery: { action: 'Pick an id from candidates, or check the workspace root.' },
|
|
152
|
+
context: { candidates: result.candidates },
|
|
153
|
+
}),
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return ok({ bound: result.app, reason: 'explicit' })
|
|
158
|
+
},
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
server.registerTool(
|
|
162
|
+
'xplat_capability_matrix',
|
|
163
|
+
{
|
|
164
|
+
description:
|
|
165
|
+
'Per-platform capability matrix: build, launch, logs, UI inspection, screenshot, and interaction are separate capabilities. Unsupported or missing capabilities come with an explanation and a usable alternative when one exists.',
|
|
166
|
+
inputSchema: {
|
|
167
|
+
platform: z.enum(PLATFORMS).optional().describe('Limit to one platform'),
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
async ({ platform }) => {
|
|
171
|
+
const status = await buildProjectStatus(context)
|
|
172
|
+
const matrices = platform
|
|
173
|
+
? status.capabilities.filter((m) => m.platform === platform)
|
|
174
|
+
: status.capabilities
|
|
175
|
+
|
|
176
|
+
return ok({ capabilities: matrices })
|
|
177
|
+
},
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
server.registerTool(
|
|
181
|
+
'xplat_permissions',
|
|
182
|
+
{
|
|
183
|
+
description:
|
|
184
|
+
'Report permission grants and their states. Workspace scope and ordinary runtime actions reuse the task authorization; screenshots require an explicit per-task developer grant checked at action time.',
|
|
185
|
+
inputSchema: {
|
|
186
|
+
scope: z.enum(PERMISSION_SCOPES).optional(),
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
async ({ scope }) => {
|
|
190
|
+
const grants = context.policy.list()
|
|
191
|
+
if (!scope) {
|
|
192
|
+
return ok({ grants })
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const grant = grants.find((g) => g.scope === scope)
|
|
196
|
+
const check = context.policy.require(scope)
|
|
197
|
+
return ok({
|
|
198
|
+
grant,
|
|
199
|
+
check: check.granted ? { granted: true } : { granted: false, error: check.error },
|
|
200
|
+
})
|
|
201
|
+
},
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
server.registerTool(
|
|
205
|
+
'xplat_sessions',
|
|
206
|
+
{
|
|
207
|
+
description:
|
|
208
|
+
'List bound sessions: owned sessions the server started and attached user-owned sessions. Runtime tools bind one app to one target; attached sessions are preserved on end.',
|
|
209
|
+
inputSchema: {},
|
|
210
|
+
},
|
|
211
|
+
async () => ok({ sessions: context.sessions.list() }),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
server.registerTool(
|
|
215
|
+
'xplat_job_status',
|
|
216
|
+
{
|
|
217
|
+
description: 'Bounded status query for a job started by a runtime tool.',
|
|
218
|
+
inputSchema: { jobId: z.string() },
|
|
219
|
+
},
|
|
220
|
+
async ({ jobId }) => {
|
|
221
|
+
const job = context.jobs.status(jobId)
|
|
222
|
+
return job
|
|
223
|
+
? ok({ job })
|
|
224
|
+
: fail(
|
|
225
|
+
contractError('not_found', `No job "${jobId}".`, {
|
|
226
|
+
recovery: { action: 'Start a job first; finished history is bounded.' },
|
|
227
|
+
}),
|
|
228
|
+
)
|
|
229
|
+
},
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
server.registerTool(
|
|
233
|
+
'xplat_job_cancel',
|
|
234
|
+
{
|
|
235
|
+
description:
|
|
236
|
+
'Cooperatively cancel a job. Cancelling signals only that job — it never kills an unrelated app or another user\u2019s process.',
|
|
237
|
+
inputSchema: { jobId: z.string() },
|
|
238
|
+
},
|
|
239
|
+
async ({ jobId }) => {
|
|
240
|
+
const job = context.jobs.cancel(jobId)
|
|
241
|
+
return job ? ok({ job }) : fail(contractError('not_found', `No job "${jobId}".`))
|
|
242
|
+
},
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
server.registerTool(
|
|
246
|
+
'xplat_session_end',
|
|
247
|
+
{
|
|
248
|
+
description:
|
|
249
|
+
'End a session with ownership-safe cleanup: owned sessions release their resources; attached user-owned sessions are detached and preserved.',
|
|
250
|
+
inputSchema: { sessionId: z.string() },
|
|
251
|
+
},
|
|
252
|
+
async ({ sessionId }) => {
|
|
253
|
+
const usable = context.sessions.requireUsable(sessionId)
|
|
254
|
+
if (!usable.usable && usable.error.code === 'not_found') {
|
|
255
|
+
return fail(usable.error)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return ok({ result: await context.runtime.endSession(sessionId) })
|
|
259
|
+
},
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
server.registerTool(
|
|
263
|
+
'xplat_dev_actions',
|
|
264
|
+
{
|
|
265
|
+
description:
|
|
266
|
+
"List the bound app's declared development actions — the live registry announced by a running dev app: stable ids, titles, input/result contracts, effects, availability with reasons, and the registry revision. Never executes an action. An empty registry is reported distinctly from a disconnected app.",
|
|
267
|
+
inputSchema: {},
|
|
268
|
+
},
|
|
269
|
+
async () => {
|
|
270
|
+
const app = boundApp(context)
|
|
271
|
+
return app ? ok(await context.devActions.list(app)) : fail(NO_APP())
|
|
272
|
+
},
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
server.registerTool(
|
|
276
|
+
'xplat_dev_action_invoke',
|
|
277
|
+
{
|
|
278
|
+
description:
|
|
279
|
+
'Invoke an app-declared development action against a specific app instance and registry revision. Input is validated against the declared contract before anything runs; stale revisions, unavailable actions and saturated queues are rejected up front. Returns a job — poll xplat_job_status, cancel with xplat_job_cancel. Successful setup does not prove app behavior; verify separately.',
|
|
280
|
+
inputSchema: {
|
|
281
|
+
appInstanceId: z
|
|
282
|
+
.string()
|
|
283
|
+
.optional()
|
|
284
|
+
.describe(
|
|
285
|
+
'Target app instance from xplat_dev_actions; required when several instances are live',
|
|
286
|
+
),
|
|
287
|
+
actionId: z.string().describe('Stable action id from the registry'),
|
|
288
|
+
input: z
|
|
289
|
+
.record(z.string(), z.unknown())
|
|
290
|
+
.optional()
|
|
291
|
+
.describe('Named inputs matching the declared input contract'),
|
|
292
|
+
registryRevision: z
|
|
293
|
+
.number()
|
|
294
|
+
.optional()
|
|
295
|
+
.describe('The revision returned by xplat_dev_actions — stale revisions are rejected'),
|
|
296
|
+
requestKey: z
|
|
297
|
+
.string()
|
|
298
|
+
.optional()
|
|
299
|
+
.describe(
|
|
300
|
+
'Idempotence key: a retry with the same key returns the live job instead of re-running',
|
|
301
|
+
),
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
async ({ appInstanceId, actionId, input, registryRevision, requestKey }) => {
|
|
305
|
+
const app = boundApp(context)
|
|
306
|
+
if (!app) {
|
|
307
|
+
return fail(NO_APP())
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const result = await context.devActions.invoke(app, {
|
|
311
|
+
appInstanceId,
|
|
312
|
+
actionId,
|
|
313
|
+
input: input ?? {},
|
|
314
|
+
registryRevision,
|
|
315
|
+
requestKey,
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
return result.error ? fail(result.error) : ok(result)
|
|
319
|
+
},
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
server.registerTool(
|
|
323
|
+
'xplat_dev_action_status',
|
|
324
|
+
{
|
|
325
|
+
description:
|
|
326
|
+
'Status of a development-action invocation: job state, structured result or failure, and disclosed partial effects. Same data as xplat_job_status, restricted to dev-action jobs.',
|
|
327
|
+
inputSchema: { jobId: z.string() },
|
|
328
|
+
},
|
|
329
|
+
async ({ jobId }) => {
|
|
330
|
+
const described = context.devActions.describeJob(jobId)
|
|
331
|
+
if (!described) {
|
|
332
|
+
return fail(
|
|
333
|
+
contractError('not_found', `No job "${jobId}".`, {
|
|
334
|
+
recovery: { action: 'Invoke an action first; finished history is bounded.' },
|
|
335
|
+
}),
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (described.job.kind !== 'dev-action') {
|
|
340
|
+
return fail(
|
|
341
|
+
contractError(
|
|
342
|
+
'invalid_selection',
|
|
343
|
+
`Job "${jobId}" is a ${described.job.kind} job, not a dev-action invocation.`,
|
|
344
|
+
{
|
|
345
|
+
recovery: { action: 'Use xplat_job_status for other job kinds.' },
|
|
346
|
+
},
|
|
347
|
+
),
|
|
348
|
+
)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return ok(described)
|
|
352
|
+
},
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
server.registerTool(
|
|
356
|
+
'xplat_evidence',
|
|
357
|
+
{
|
|
358
|
+
description:
|
|
359
|
+
'List, retain, or delete evidence artifacts. Evidence is session-scoped and deleted when the MCP session ends; retain needs an explicit expiration or manual deletion. Deletion cannot erase an external client transcript.',
|
|
360
|
+
inputSchema: {
|
|
361
|
+
op: z.enum(['list', 'retain', 'delete']),
|
|
362
|
+
id: z.string().optional().describe('Evidence record id (required for retain/delete)'),
|
|
363
|
+
expiresAt: z
|
|
364
|
+
.string()
|
|
365
|
+
.optional()
|
|
366
|
+
.describe('ISO expiration for retain; omit for manual deletion only'),
|
|
367
|
+
kind: z.enum(EVIDENCE_KINDS).optional().describe('Filter for list'),
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
async ({ op, id, expiresAt, kind }) => {
|
|
371
|
+
if (op === 'list') {
|
|
372
|
+
return ok({ evidence: context.evidence.list({ kind }) })
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (!id) {
|
|
376
|
+
return fail(contractError('invalid_selection', `op "${op}" requires an evidence id.`))
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (op === 'retain') {
|
|
380
|
+
const result = context.evidence.retain(id, { expiresAt: expiresAt ?? null })
|
|
381
|
+
return result.error ? fail(result.error) : ok(result)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const result = context.evidence.delete(id)
|
|
385
|
+
return result.error ? fail(result.error) : ok(result)
|
|
386
|
+
},
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
// Code retrieval (work item C). Results are leads with provenance and
|
|
390
|
+
// freshness — exact search reads live files, semantic hits are
|
|
391
|
+
// revalidated against current source before serving.
|
|
392
|
+
const boundAppForCode = () => {
|
|
393
|
+
const selection = resolveSelection(context)
|
|
394
|
+
if (selection.state === 'ambiguous') {
|
|
395
|
+
return {
|
|
396
|
+
error: contractError(
|
|
397
|
+
'ambiguous_selection',
|
|
398
|
+
'Several apps are detected — code retrieval targets the bound app and its installed packages, never a guessed one.',
|
|
399
|
+
{
|
|
400
|
+
recovery: {
|
|
401
|
+
action: 'Ask the developer which app to use, then call xplat_select_app.',
|
|
402
|
+
},
|
|
403
|
+
context: { candidates: selection.candidates.map((a) => a.id) },
|
|
404
|
+
},
|
|
405
|
+
),
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// 'none' stays usable for framework-repo workspaces (framework corpus
|
|
410
|
+
// needs no bound app); the provider reports when an app is required.
|
|
411
|
+
return { app: selection.state === 'bound' ? selection.app : null }
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const noCodeProvider = () =>
|
|
415
|
+
fail(
|
|
416
|
+
contractError(
|
|
417
|
+
'unavailable',
|
|
418
|
+
'No code-search provider is wired into this server (work item C integration absent).',
|
|
419
|
+
{
|
|
420
|
+
recovery: {
|
|
421
|
+
action: 'Use your normal file tools; report the missing provider to the developer.',
|
|
422
|
+
},
|
|
423
|
+
},
|
|
424
|
+
),
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
const codeResult = (result) => {
|
|
428
|
+
if (!result?.status) {
|
|
429
|
+
return ok(result)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const code = result.status === 'unavailable' ? 'unavailable' : 'invalid_selection'
|
|
433
|
+
|
|
434
|
+
return fail(
|
|
435
|
+
contractError(code, result.detail ?? result.reason ?? result.status, {
|
|
436
|
+
context: {
|
|
437
|
+
status: result.status,
|
|
438
|
+
reason: result.reason,
|
|
439
|
+
corpus: result.corpus,
|
|
440
|
+
path: result.path,
|
|
441
|
+
},
|
|
442
|
+
}),
|
|
443
|
+
)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
server.registerTool(
|
|
447
|
+
'xplat_code_search',
|
|
448
|
+
{
|
|
449
|
+
description:
|
|
450
|
+
'Search current source. mode=exact (default auto includes it) finds identifiers/tokens by exact match over live files; mode=semantic uses an optional local index that never blocks exact retrieval. Corpora stay labeled: app = the bound app, framework = its installed @octane-xplat packages. Results are leads — open refs with xplat_code_read before citing an API.',
|
|
451
|
+
inputSchema: {
|
|
452
|
+
query: z.string().describe('Identifier, phrase, or intent to search for'),
|
|
453
|
+
mode: z
|
|
454
|
+
.enum(['auto', 'exact', 'semantic'])
|
|
455
|
+
.optional()
|
|
456
|
+
.describe('exact = token search over current files; semantic = local index; auto = both'),
|
|
457
|
+
corpus: z.enum(['app', 'framework', 'all']).optional().describe('Default: all'),
|
|
458
|
+
platform: z
|
|
459
|
+
.enum(PLATFORMS)
|
|
460
|
+
.optional()
|
|
461
|
+
.describe('Include shared files plus files marked for this platform'),
|
|
462
|
+
package: z.string().optional().describe('Limit the framework corpus to one package'),
|
|
463
|
+
limit: z.number().int().min(1).max(50).optional(),
|
|
464
|
+
},
|
|
465
|
+
},
|
|
466
|
+
async ({ query, mode, corpus, platform, package: pkg, limit }) => {
|
|
467
|
+
if (!context.code) {
|
|
468
|
+
return noCodeProvider()
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const check = context.policy.require('workspace')
|
|
472
|
+
if (!check.granted) {
|
|
473
|
+
return fail(check.error)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const { app, error } = boundAppForCode()
|
|
477
|
+
if (error) {
|
|
478
|
+
return fail(error)
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
try {
|
|
482
|
+
return codeResult(
|
|
483
|
+
await context.code.search({
|
|
484
|
+
app,
|
|
485
|
+
workspaceKind: context.discovery.workspace.kind,
|
|
486
|
+
query,
|
|
487
|
+
mode,
|
|
488
|
+
corpus,
|
|
489
|
+
platform,
|
|
490
|
+
package: pkg,
|
|
491
|
+
limit,
|
|
492
|
+
}),
|
|
493
|
+
)
|
|
494
|
+
} catch (value) {
|
|
495
|
+
return fail(toContractError(value))
|
|
496
|
+
}
|
|
497
|
+
},
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
server.registerTool(
|
|
501
|
+
'xplat_code_read',
|
|
502
|
+
{
|
|
503
|
+
description:
|
|
504
|
+
'Read current source by a search ref (xplat-code://…) or explicit corpus + path. Bounded slices return a continuation offset; a file that changed or vanished is reported, never served stale.',
|
|
505
|
+
inputSchema: {
|
|
506
|
+
ref: z.string().optional().describe('xplat-code://<corpus>/<key> from a search hit'),
|
|
507
|
+
corpus: z.enum(['app', 'framework']).optional(),
|
|
508
|
+
package: z.string().optional().describe('Framework package name for corpus=framework'),
|
|
509
|
+
path: z
|
|
510
|
+
.string()
|
|
511
|
+
.optional()
|
|
512
|
+
.describe('Workspace-relative path (app) or path inside the package (framework)'),
|
|
513
|
+
offset: z.number().int().min(0).optional(),
|
|
514
|
+
limit: z.number().int().min(1).max(64_000).optional(),
|
|
515
|
+
},
|
|
516
|
+
},
|
|
517
|
+
async ({ ref, corpus, package: pkg, path, offset, limit }) => {
|
|
518
|
+
if (!context.code) {
|
|
519
|
+
return noCodeProvider()
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const check = context.policy.require('workspace')
|
|
523
|
+
if (!check.granted) {
|
|
524
|
+
return fail(check.error)
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const { app, error } = boundAppForCode()
|
|
528
|
+
if (error) {
|
|
529
|
+
return fail(error)
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
try {
|
|
533
|
+
return codeResult(
|
|
534
|
+
await context.code.read({
|
|
535
|
+
app,
|
|
536
|
+
workspaceKind: context.discovery.workspace.kind,
|
|
537
|
+
ref,
|
|
538
|
+
corpus,
|
|
539
|
+
package: pkg,
|
|
540
|
+
path,
|
|
541
|
+
offset,
|
|
542
|
+
limit,
|
|
543
|
+
}),
|
|
544
|
+
)
|
|
545
|
+
} catch (value) {
|
|
546
|
+
return fail(toContractError(value))
|
|
547
|
+
}
|
|
548
|
+
},
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
registerRuntimeTools(server, context, respond)
|
|
552
|
+
registerDocsTools(server, context, respond)
|
|
553
|
+
registerInspectionTools(server, context, respond)
|
|
554
|
+
|
|
555
|
+
return {
|
|
556
|
+
server,
|
|
557
|
+
context,
|
|
558
|
+
/** Ownership-safe teardown: end owned sessions, delete session-scoped evidence. */
|
|
559
|
+
async shutdown() {
|
|
560
|
+
const sessions = await context.runtime.endAll()
|
|
561
|
+
const evidence = context.evidence.closeSession()
|
|
562
|
+
return { sessions, evidence }
|
|
563
|
+
},
|
|
564
|
+
}
|
|
565
|
+
}
|
package/src/status.mjs
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Octane Xplat MCP server — stdio transport. stdout is the protocol channel;
|
|
3
|
+
// diagnostics go to stderr only.
|
|
4
|
+
|
|
5
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
6
|
+
import { createCodeService } from '@octane-xplat/cli/mcp/code-service'
|
|
7
|
+
import { readFileSync } from 'node:fs'
|
|
8
|
+
import { PERMISSION_SCOPES } from './contracts/policy.mjs'
|
|
9
|
+
import { createXplatMcpServer } from './server.mjs'
|
|
10
|
+
import { createWorkspaceContext } from './status.mjs'
|
|
11
|
+
|
|
12
|
+
const usage = `xplat-mcp — Octane Xplat MCP server (stdio)
|
|
13
|
+
|
|
14
|
+
Usage: xplat-mcp [--workspace <path>] [--app <id|path|name>]
|
|
15
|
+
[--permissions <scope,scope>] [--evidence-dir <path>]
|
|
16
|
+
|
|
17
|
+
Options may also come from the environment:
|
|
18
|
+
XPLAT_MCP_WORKSPACE, XPLAT_MCP_APP, XPLAT_MCP_PERMISSIONS, XPLAT_MCP_EVIDENCE_DIR
|
|
19
|
+
|
|
20
|
+
Scopes: ${PERMISSION_SCOPES.join(', ')}
|
|
21
|
+
Workspace reads and ordinary runtime actions reuse the developer's task
|
|
22
|
+
authorization; visual-capture always needs an explicit per-task grant.
|
|
23
|
+
`
|
|
24
|
+
|
|
25
|
+
const arg = (name) => {
|
|
26
|
+
const index = process.argv.indexOf(`--${name}`)
|
|
27
|
+
return index >= 0 ? process.argv[index + 1] : undefined
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
31
|
+
process.stderr.write(usage)
|
|
32
|
+
process.exit(0)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const permissionScopes = (arg('permissions') ?? process.env.XPLAT_MCP_PERMISSIONS ?? '')
|
|
36
|
+
.split(',')
|
|
37
|
+
.map((s) => s.trim())
|
|
38
|
+
.filter(Boolean)
|
|
39
|
+
|
|
40
|
+
const unknown = permissionScopes.filter((s) => !PERMISSION_SCOPES.includes(s))
|
|
41
|
+
if (unknown.length) {
|
|
42
|
+
process.stderr.write(`xplat-mcp: ignoring unknown permission scopes: ${unknown.join(', ')}\n`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const workspaceRoot = arg('workspace') ?? process.env.XPLAT_MCP_WORKSPACE ?? process.cwd()
|
|
46
|
+
|
|
47
|
+
// The status report's `server` field identifies this entry point — the
|
|
48
|
+
// manifest version follows lockstep bumps automatically.
|
|
49
|
+
const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
|
|
50
|
+
|
|
51
|
+
const context = createWorkspaceContext({
|
|
52
|
+
workspaceRoot,
|
|
53
|
+
serverIdentity: { name: '@octane-xplat/mcp', version: manifest.version },
|
|
54
|
+
appSelector: arg('app') ?? process.env.XPLAT_MCP_APP,
|
|
55
|
+
permissionScopes: permissionScopes.filter((s) => PERMISSION_SCOPES.includes(s)),
|
|
56
|
+
evidenceRoot: arg('evidence-dir') ?? process.env.XPLAT_MCP_EVIDENCE_DIR,
|
|
57
|
+
code: createCodeService({
|
|
58
|
+
workspaceRoot,
|
|
59
|
+
workspaceKind: () => context.discovery.workspace.kind,
|
|
60
|
+
}),
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const { server, shutdown } = createXplatMcpServer(context)
|
|
64
|
+
|
|
65
|
+
let closing = false
|
|
66
|
+
const close = async () => {
|
|
67
|
+
if (closing) {
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
closing = true
|
|
72
|
+
try {
|
|
73
|
+
await shutdown()
|
|
74
|
+
} catch (error) {
|
|
75
|
+
process.stderr.write(`xplat-mcp: shutdown cleanup failed: ${error?.message ?? error}\n`)
|
|
76
|
+
} finally {
|
|
77
|
+
await server.close()
|
|
78
|
+
process.exit(0)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
process.on('SIGINT', close)
|
|
83
|
+
process.on('SIGTERM', close)
|
|
84
|
+
|
|
85
|
+
await server.connect(new StdioServerTransport())
|