From b72ddae85e8e0813acbc7df63349ca080af77f41 Mon Sep 17 00:00:00 2001 From: Jose Antonio Alatorre Date: Wed, 21 Oct 2026 22:12:21 +0200 Subject: [PATCH] Release 0.3.1: store MCP names in saved connections A saved MCP connection carried only the release UID and endpoint, because the release detail that mcp-add reads has no name or repository. Hosts labelled listings with two live backend requests per listing, and a backend restart during a refresh left a connection named "Unnamed MCP". - mcp-add reads the release directory projection (search by UID) for the release's name and repository branch, the branch for its repository name, and stores them with the connection as name, display_name and code_repository_branch_uid; a label that cannot be read is left out and never makes a working MCP unavailable - mcp-list returns the stored labels without a backend request; a record saved by an earlier version is completed by the first listing that can read its labels, under the connection lock, and a held lock or failed write still returns the labelled listing - mcp-tools and mcp-call results carry the saved labels on their connection; discovery rows pass the directory's name and code_repository_branch_uid through; malformed stored labels are dropped on read - tests/mcp.test.cjs covers storage, local listing, backfill, denied and mismatched lookups, malformed labels, the held lock and discovery rows Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- package-lock.json | 4 +-- package.json | 2 +- src/shared/mcp.ts | 74 ++++++++++++++++++++++++++++++++++++---- tests/mcp.test.cjs | 85 ++++++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 154 insertions(+), 13 deletions(-) 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');