diff --git a/README.md b/README.md index 399950b..7cdbd93 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ The machine record is the SDKs' existing `MainSequenceCLI.auth` service, backend ## Project MCP client -The `mcp-discover`, `mcp-add`, `mcp-list`, `mcp-remove`, `mcp-tools` and `mcp-call` actions discover the platform's MCP directory, remember a selected release and invoke its tools through a local MCP client, using the existing developer session and short-lived release credentials. They are available from `ms-devtools` and from `createDevTools().dispatch()`; hosts may inject `mcpConnectionsPath`. The conversational `mainsequence-mcp` skill that lets Codex use them without `/ms` ships with the [Codex plugin](https://github.com/mainsequence-sdk/mainsequence-codex-plugin), together with [its user guide](https://github.com/mainsequence-sdk/mainsequence-codex-plugin/blob/main/docs/conversational-mcp.md) and [ADR 0002](https://github.com/mainsequence-sdk/mainsequence-codex-plugin/blob/main/docs/adr/0002-conversational-project-mcp-client.md). +The `mcp-discover`, `mcp-add`, `mcp-list`, `mcp-remove`, `mcp-tools` and `mcp-call` actions discover the platform's MCP directory, remember a selected release and invoke its tools through a local MCP client, using the existing developer session and short-lived release credentials. They are available from `ms-devtools` and from `createDevTools().dispatch()`; hosts may inject `mcpConnectionsPath`. Since 0.3.1, `mcp-add` reads the release's name, its repository's name and its repository branch from the release directory and stores them with the connection as `name`, `display_name` and `code_repository_branch_uid`, so `mcp-list` labels a saved connection without a backend request; a record saved by an earlier version is completed by the first listing that can read them, and a label that cannot be read is left out rather than making the MCP unavailable. Discovery rows carry `name` and `code_repository_branch_uid` as the platform directory reports them. The conversational `mainsequence-mcp` skill that lets Codex use them without `/ms` ships with the [Codex plugin](https://github.com/mainsequence-sdk/mainsequence-codex-plugin), together with [its user guide](https://github.com/mainsequence-sdk/mainsequence-codex-plugin/blob/main/docs/conversational-mcp.md) and [ADR 0002](https://github.com/mainsequence-sdk/mainsequence-codex-plugin/blob/main/docs/adr/0002-conversational-project-mcp-client.md). ## Project actions diff --git a/package-lock.json b/package-lock.json index ff365cb..96711b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@dev-mainsequence/devtools", - "version": "0.3.0", + "version": "0.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@dev-mainsequence/devtools", - "version": "0.3.0", + "version": "0.3.1", "license": "MIT", "bin": { "ms-devtools": "dist/runner.js" diff --git a/package.json b/package.json index 94bfb05..e9e8482 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dev-mainsequence/devtools", - "version": "0.3.0", + "version": "0.3.1", "description": "Shared MainSequence developer actions, project operations, MCP client and command runner", "license": "MIT", "engines": { diff --git a/src/shared/mcp.ts b/src/shared/mcp.ts index 562903e..3492c6d 100644 --- a/src/shared/mcp.ts +++ b/src/shared/mcp.ts @@ -5,6 +5,7 @@ import { randomUUID } from 'node:crypto'; import { ActionResult, SessionService, ToolingError } from './models'; import { requireBackend } from './session'; import { McpTransport, sendProjectMcp } from './mcpTransport'; +import { codeRepositoryBranchFromResponse } from './repositoryModels'; export type McpRequest = { backend?: string; environmentUid?: string } & ( | { action: 'mcp-discover'; search?: string; limit?: number; offset?: number } @@ -13,18 +14,29 @@ export type McpRequest = { backend?: string; environmentUid?: string } & ( | { action: 'mcp-add' | 'mcp-remove' | 'mcp-tools'; releaseUid: string } | { action: 'mcp-call'; releaseUid: string; tool: string; arguments: Record } ); +/** `name` is the release's own name, `display_name` its repository's name; both are + * resolved when a connection is added and stored with it, so listing needs no lookup. */ type Connection = { - backend: string; resource_release_uid: string; name?: string; + backend: string; resource_release_uid: string; name?: string; display_name?: string; code_repository_branch_uid?: string; mcp_connection: { url: string; transport: 'streamable-http'; resource_metadata_url: string; organization_environment_uid: string; organization_environment_name?: string; active_revision_uid: string }; }; +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; function uuid(value: unknown): string { - if (typeof value !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)) { + if (typeof value !== 'string' || !UUID.test(value)) { throw new ToolingError('INVALID_REQUEST', 'Select an exact resource release UUID from MCP discovery or saved connections.'); } return value.toLowerCase(); } +type Labels = Pick; +/** Optional labels: an absent or malformed value is dropped, never an error. */ +function labels(row: any): Labels { + const text = (value: unknown) => typeof value === 'string' && value.trim() && !/[\r\n\0]/.test(value) ? value.trim() : undefined; + const name = text(row?.name), display = text(row?.display_name); + const branch = typeof row?.code_repository_branch_uid === 'string' && UUID.test(row.code_repository_branch_uid) ? row.code_repository_branch_uid.toLowerCase() : undefined; + return { ...(name ? { name } : {}), ...(display ? { display_name: display } : {}), ...(branch ? { code_repository_branch_uid: branch } : {}) }; +} function endpoint(value: unknown, metadata = false): string { if (typeof value !== 'string') throw new ToolingError('API_ERROR', 'The backend returned no MCP endpoint.'); let url: URL; @@ -46,8 +58,7 @@ function connection(backend: string, row: any): Connection { const metadataQuery = new URL(metadataUrl).searchParams; if ([...metadataQuery.keys()].some(key => key !== 'resource_release_uid') || metadataQuery.getAll('resource_release_uid').length !== 1 || metadataQuery.get('resource_release_uid') !== releaseUid) throw new ToolingError('API_ERROR', 'The MCP metadata targets another release.'); - return { backend: requireBackend(backend), resource_release_uid: releaseUid, - ...(typeof row.name === 'string' && row.name.trim() ? { name: row.name } : {}), + return { backend: requireBackend(backend), resource_release_uid: releaseUid, ...labels(row), mcp_connection: { url, transport: 'streamable-http', resource_metadata_url: metadataUrl, organization_environment_uid: uuid(c.organization_environment_uid), ...(typeof c.organization_environment_name === 'string' && c.organization_environment_name.trim() ? { organization_environment_name: c.organization_environment_name.trim() } : {}), active_revision_uid: uuid(c.active_revision_uid) } }; @@ -130,6 +141,55 @@ export class McpConnections { await client.initialize(); return client; } + /** The release's name and repository from the release directory and its branch. Whatever + * cannot be read is left out: labels never make a working MCP unavailable. */ + private async resolveLabels(value: Connection): Promise { + const found: Labels = {}; + const read = async (apiPath: string): Promise => { + try { return await this.session.request(value.backend, 'GET', apiPath, undefined, true); } + catch { this.options.signal?.throwIfAborted(); return undefined; } + }; + // The release detail carries no name or repository; the directory projection does. + const page = await read('/api/v1/resource-releases/?search=' + encodeURIComponent(value.resource_release_uid) + '&limit=100'); + const row = Array.isArray(page?.results) ? page.results.find((item: any) => typeof item?.uid === 'string' && item.uid.toLowerCase() === value.resource_release_uid) : undefined; + if (row) Object.assign(found, labels({ name: row.name, code_repository_branch_uid: row.code_repository_branch_uid })); + const branchUid = found.code_repository_branch_uid ?? value.code_repository_branch_uid; + if (branchUid) { + const branch = await read('/api/v1/code-repository-branches/' + encodeURIComponent(branchUid) + '/'); + try { + const parsed = codeRepositoryBranchFromResponse(branch, 'CodeRepositoryBranch response'); + if (parsed.uid.toLowerCase() === branchUid) Object.assign(found, labels({ display_name: parsed.code_repository_name, code_repository_branch_uid: branchUid })); + } catch { /* an unreadable branch leaves the repository name out */ } + } + return found; + } + private labelled(value: Connection, found: Labels): Connection { + return connection(value.backend, { ...value, ...found }); + } + /** Records saved before labels were stored are completed once, when the backend answers. */ + private async backfill(rows: Connection[]): Promise { + const pending = rows.filter(row => !row.display_name); + if (!pending.length) return rows; + const found = new Map(); + await Promise.all(Array.from({ length: Math.min(4, pending.length) }, async () => { + while (pending.length) { + const row = pending.shift()!; + const resolved = await this.resolveLabels(row); + const added = Object.entries(resolved).filter(([key, item]) => item !== row[key as keyof Labels]); + if (added.length) found.set(row.backend + ' ' + row.resource_release_uid, Object.fromEntries(added)); + } + })); + if (!found.size) return rows; + const key = (row: Connection) => row.backend + ' ' + row.resource_release_uid; + const merge = (row: Connection) => found.has(key(row)) ? this.labelled(row, { ...found.get(key(row)) }) : row; + try { await this.update(all => all.map(merge)); } + catch (error) { + // A held lock or a failed write keeps the listing itself available. + if (error instanceof ToolingError && error.code === 'CANCELLED') throw error; + this.options.signal?.throwIfAborted(); + } + return rows.map(merge); + } async dispatch(request: McpRequest, inputBackend: string): Promise { let callStarted = false, resultData: unknown; try { @@ -193,7 +253,7 @@ export class McpConnections { } summary = 'Available project MCP endpoints; discovery does not save a connection.'; } else if (request.action === 'mcp-list') { - data = (await this.read()).filter(row => row.backend === backend && belongs(row)); + data = await this.backfill((await this.read()).filter(row => row.backend === backend && belongs(row))); summary = 'Saved MCP connections for this backend. Availability is checked when used.'; } else { const releaseUid = uuid(request.releaseUid); @@ -203,16 +263,18 @@ export class McpConnections { await this.update(rows => rows.filter(row => !(row.backend === backend && row.resource_release_uid === releaseUid))); data = { resource_release_uid: releaseUid }; summary = 'The saved connection was removed.'; } else { - const value = await this.current(backend, releaseUid); + let value = await this.current(backend, releaseUid); requireEnvironment(value); if (request.action !== 'mcp-add') { const saved = (await this.read()).find(row => row.backend === backend && row.resource_release_uid === releaseUid); if (!saved) throw new ToolingError('INVALID_REQUEST', 'Add this MCP connection before using it.'); if (saved.mcp_connection.url !== value.mcp_connection.url) throw new ToolingError('INVALID_REQUEST', 'The MCP endpoint changed. Add the release again to confirm its new endpoint.'); + value = this.labelled(value, labels(saved)); } const client = await this.open(value); const tools = await client.tools(); if (request.action === 'mcp-add') { + value = this.labelled(value, await this.resolveLabels(value)); await this.update(rows => [...rows.filter(row => !(row.backend === backend && row.resource_release_uid === releaseUid)), value]); data = { connection: value, tools }; summary = 'MCP connection verified and saved. Its tools can now be used through the plugin.'; } else if (request.action === 'mcp-tools') { diff --git a/tests/mcp.test.cjs b/tests/mcp.test.cjs index 3386c81..209b8a1 100644 --- a/tests/mcp.test.cjs +++ b/tests/mcp.test.cjs @@ -13,9 +13,11 @@ const { saveSession, readSession } = require('../dist/shared/machineSession.js') async function fixture(t) { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ms-mcp-test-')); t.after(() => fs.rm(dir, { recursive: true, force: true })); - const calls = [], state = { denied: false, mode: 'json', token: 'private-release-credential', exchanged: 0, invoked: 0 }; + const calls = [], state = { denied: false, mode: 'json', token: 'private-release-credential', exchanged: 0, invoked: 0, + labels: true, labelsDenied: false, branchDenied: false, branchMismatch: false }; const uid = 'b0a5ee29-f649-46ab-a040-38504838a131'; const secondUid = 'b0a5ee29-f649-46ab-a040-38504838a132'; + const branchUid = 'c1d2e3f4-0000-4000-8000-000000000001'; const tool = { name: 'set_value', description: 'Write a fixture value', inputSchema: { type: 'object', properties: { value: { type: 'number' } }, required: ['value'] }, annotations: { readOnlyHint: false, destructiveHint: true } }; const listen = async handler => { @@ -77,10 +79,23 @@ async function fixture(t) { if (message.method === 'notifications/initialized') return res.writeHead(202).end(); assert.equal(message.params.name, 'resource_release.mcp_endpoints'); return reply(res, message.id, { content: [], structuredContent: { count: 2, results: [uid, secondUid].map(id => { - const value = row(id); value.resource_release_uid = value.uid; delete value.uid; return value; + const value = row(id); value.resource_release_uid = value.uid; delete value.uid; value.code_repository_branch_uid = branchUid; return value; }) } }); } if (state.denied) return res.writeHead(403).end(); + if (req.url.startsWith('/api/v1/resource-releases/?')) { + // The directory projection names a release and its repository branch; the detail below does not. + if (!state.labels) return res.writeHead(404).end('{}'); + if (state.labelsDenied) return res.writeHead(403).end(); + const search = new URL(req.url, backend).searchParams.get('search') || ''; + const results = [uid, secondUid].filter(id => id.includes(search)).map(id => ({ uid: id, name: 'metatable-mcp', release_kind: 'fastapi', code_repository_branch_uid: branchUid })); + res.setHeader('Content-Type', 'application/json'); return res.end(JSON.stringify({ count: results.length, results })); + } + if (req.url === '/api/v1/code-repository-branches/' + branchUid + '/') { + if (state.branchDenied) return res.writeHead(403).end(); + res.setHeader('Content-Type', 'application/json'); + return res.end(JSON.stringify({ uid: state.branchMismatch ? secondUid : branchUid, code_repository_uid: 'repo-1', code_repository_name: 'MetaTablesApi', repository_branch: 'main', is_initialized: true })); + } if (req.url === '/api/v1/resource-releases/' + uid + '/' || req.url === '/api/v1/resource-releases/' + secondUid + '/') { const value = row(req.url.includes(secondUid) ? secondUid : uid); // Release detail has capability metadata, but no discovery-only display name. @@ -100,7 +115,7 @@ async function fixture(t) { const create = extra => createDevTools({ host: createNodeHost(), env: { MAINSEQUENCE_ENDPOINT: backend, MAINSEQUENCE_ACCESS_TOKEN: 'private-platform-token' }, mcpConnectionsPath: file, requestTimeoutMs: 500, ...extra }); const dispatch = (action, extra = {}, instance = create()) => instance.dispatch({ action, backend, releaseUid: uid, ...extra }); - return { dir, file, uid, secondUid, backend, row, project, calls, state, create, dispatch }; + return { dir, file, uid, secondUid, branchUid, backend, row, project, calls, state, create, dispatch }; } test('conversation workflow discovers, saves, immediately calls and remembers MCP connections across client instances', async t => { @@ -130,6 +145,7 @@ test('conversation workflow discovers, saves, immediately calls and remembers MC test('release detail and saved connections work without a display name or another discovery request', async t => { const f = await fixture(t); + f.state.labels = false; const detail = await fetch(f.backend + '/api/v1/resource-releases/' + f.uid + '/', { headers: { Authorization: 'Bearer private-platform-token' }, }).then(response => response.json()); @@ -157,6 +173,69 @@ test('release detail and saved connections work without a display name or anothe 'UID-based operations do not fetch discovery to fill display metadata'); }); +test('connecting stores the release and repository names; listing reads them locally and completes older records once', async t => { + const f = await fixture(t); + const platform = () => f.calls.filter(call => call.server === 'platform').length; + const stored = () => fs.readFile(f.file, 'utf8').then(JSON.parse); + const added = await f.dispatch('mcp-add'); + assert.equal(added.status, 'success'); + assert.deepEqual([added.data.connection.name, added.data.connection.display_name, added.data.connection.code_repository_branch_uid], + ['metatable-mcp', 'MetaTablesApi', f.branchUid]); + assert.deepEqual(Object.keys((await stored())[0]), ['backend', 'resource_release_uid', 'name', 'display_name', 'code_repository_branch_uid', 'mcp_connection']); + let before = platform(); + const listed = await f.dispatch('mcp-list', {}, f.create()); + assert.equal(listed.data[0].display_name, 'MetaTablesApi'); + assert.equal(platform(), before, 'a labelled record lists without any backend request'); + assert.equal((await f.dispatch('mcp-tools', {}, f.create())).data.connection.display_name, 'MetaTablesApi', 'tool listings carry the saved labels'); + + // A record saved by an earlier version has no labels: the first listing that can read them stores them. + const legacy = { backend: f.backend, resource_release_uid: f.uid, mcp_connection: added.data.connection.mcp_connection }; + await fs.writeFile(f.file, JSON.stringify([legacy])); + f.state.branchDenied = true; + let result = await f.dispatch('mcp-list', {}, f.create()); + assert.equal(result.status, 'success'); + assert.equal(result.data[0].name, 'metatable-mcp'); assert.equal(result.data[0].display_name, undefined); + assert.equal((await stored())[0].name, 'metatable-mcp', 'the part that could be read is stored'); + f.state.branchDenied = false; + result = await f.dispatch('mcp-list', {}, f.create()); + assert.equal(result.data[0].display_name, 'MetaTablesApi'); + assert.equal((await stored())[0].display_name, 'MetaTablesApi'); + before = platform(); + await f.dispatch('mcp-list', {}, f.create()); + assert.equal(platform(), before, 'a completed record is not looked up again'); + + // An unreadable directory, a mismatched branch and malformed stored labels leave the record usable. + const cases = [[() => { f.state.labelsDenied = true; }, {}], [() => { f.state.labels = false; }, {}], + [() => { f.state.branchMismatch = true; }, { name: 'metatable-mcp', code_repository_branch_uid: f.branchUid }]]; + for (const [setup, expected] of cases) { + await fs.writeFile(f.file, JSON.stringify([{ ...legacy, display_name: 17, code_repository_branch_uid: 'not-a-uuid' }])); + Object.assign(f.state, { labelsDenied: false, labels: true, branchMismatch: false }); setup(); + result = await f.dispatch('mcp-list', {}, f.create()); + assert.equal(result.status, 'success'); + assert.deepEqual([result.data[0].name, result.data[0].display_name, result.data[0].code_repository_branch_uid], + [expected.name, undefined, expected.code_repository_branch_uid]); + assert.equal((await f.dispatch('mcp-call', { tool: 'set_value', arguments: { value: 5 } }, f.create())).data.structuredContent.value, 5); + } + Object.assign(f.state, { labelsDenied: false, labels: true, branchMismatch: false }); + + // A held lock defers storing the labels; the listing still shows them. + await fs.writeFile(f.file, JSON.stringify([legacy])); + await fs.writeFile(f.file + '.lock', 'fixture-owner'); + result = await f.dispatch('mcp-list', {}, f.create()); + assert.equal(result.status, 'success'); assert.equal(result.data[0].display_name, 'MetaTablesApi'); + assert.equal(Object.hasOwn((await stored())[0], 'display_name'), false); + await fs.unlink(f.file + '.lock'); + await f.dispatch('mcp-list', {}, f.create()); + assert.equal((await stored())[0].display_name, 'MetaTablesApi'); + + // Discovery rows carry the release name and branch without a repository lookup. + before = f.calls.length; + const discovered = await f.dispatch('mcp-discover'); + assert.deepEqual(discovered.data.results.map(row => [row.name, row.code_repository_branch_uid]), [['metatable-mcp', f.branchUid], ['metatable-mcp', f.branchUid]]); + assert.equal(f.calls.slice(before).filter(call => call.server === 'platform' && call.path !== '/mcp').length, 0); + assert.doesNotMatch(JSON.stringify([added, listed, result, discovered, await stored()]), /private-platform-token|private-release-credential/); +}); + test('nameless release details still validate identity and all required MCP metadata before exchanging credentials', async t => { const f = await fixture(t); assert.equal((await f.dispatch('mcp-add')).status, 'success');