From 37a543561a7040e7c65dc33a4e92b0b8891aebc3 Mon Sep 17 00:00:00 2001 From: Jose Antonio Alatorre Date: Wed, 21 Oct 2026 22:19:21 +0200 Subject: [PATCH] Store MCP names with the saved connection (0.4.18) A saved MCP connection held only its release UID and endpoint, so every listing asked the backend twice to label it and a backend restart during a refresh showed "Unnamed MCP"; 0.4.17 only remembered the last name. - Bundle devtools 0.3.1, which resolves the release's name and its repository's name when an MCP is connected, stores them with the connection as name, display_name and code_repository_branch_uid, lists them without a backend request, and completes a record saved by an earlier version on the first listing that can read it - The worker's name adapter labels discovery rows only, from the code_repository_branch_uid each row now carries, and leaves listings alone - The packaged-worker MCP test covers stored names, listing without a backend request, the one-time completion of an old record and denied directory or branch reads - Release notes, README, guide and verification record the live check: the saved CRM connection was completed by the first listing (5.6 s) and listed from the file afterwards (46 ms, no backend request) Co-Authored-By: Claude Fable 5.1 --- README.md | 9 +++ docs/claude-desktop-verification.md | 34 ++++++++++ docs/claude-desktop.md | 11 ++- docs/releases/0.4.18.md | 21 ++++++ manifests/claude/.claude-plugin/plugin.json | 2 +- package-lock.json | 12 ++-- package.json | 4 +- src/hosts/claudeMcpNames.ts | 74 +++++++-------------- tests/claude-mcp.test.cjs | 59 ++++++++-------- 9 files changed, 138 insertions(+), 88 deletions(-) create mode 100644 docs/releases/0.4.18.md diff --git a/README.md b/README.md index 45e68de..1c28622 100644 --- a/README.md +++ b/README.md @@ -172,3 +172,12 @@ relabels the remaining MCPs **Unnamed MCP** or drops their tools: the pane keeps resolved name and the previously registered tools, names the unread MCP in a warning, and the worker falls back to the release's own name. See the [release notes](docs/releases/0.4.17.md). + +### 0.4.18 — MCP names stored with the connection + +A connected MCP's names live with its saved connection: devtools 0.3.1 reads the +release's name and its repository's name when the MCP is connected and stores them, so a +listing labels the connection without asking the backend. A connection saved by an earlier +version is completed by the first listing that can read its names. Only the Add MCP search +still labels its rows live, from the repository branch each row names. See the +[release notes](docs/releases/0.4.18.md). diff --git a/docs/claude-desktop-verification.md b/docs/claude-desktop-verification.md index 49a1854..a066b83 100644 --- a/docs/claude-desktop-verification.md +++ b/docs/claude-desktop-verification.md @@ -1,5 +1,39 @@ # Claude Desktop plugin verification +## 0.4.18 — MCP names stored with the saved connection (2026-10-21) + +Cause of the recurring **Unnamed MCP**: a saved connection held only its release UID and +endpoint, because the release detail that `mcp-add` reads carries neither a name nor a +repository. Every listing therefore asked the backend twice (the release directory, then +the repository branch) to label the row, and any failure of those requests showed the +UID. 0.4.17 only kept the last resolved name. Fix (devtools 0.3.1, PR +mainsequence-sdk/mainsequence-devtools#15): `mcp-add` resolves the release's name and its +repository's name once, when the MCP is connected, and stores them with the connection as +`name`, `display_name` and `code_repository_branch_uid`; `mcp-list` returns them without +a backend request and completes a record saved by an earlier version the first time the +backend can be read, under the connection lock. The worker's name adapter +(`src/hosts/claudeMcpNames.ts`) now labels only discovery rows, from the +`code_repository_branch_uid` each row carries; it no longer touches listings. + +Live check on 2026-10-21 against the local backend `http://127.0.0.1:8000` (environment +`18595d12-…`, Production) with the rebuilt worker and the saved MainSequenceCRMAPI +connection as 0.4.17 had left it, with no name in the file: + +| Worker action | Result | Duration | +| --- | --- | --- | +| `mcp-list`, first run | `crm` · `MainSequenceCRMAPI`, written to `mcp-connections.json` | 5.6 s | +| `mcp-list`, second run | the same labels from the file, no backend request | 46 ms | +| `mcp-tools` | the connection labelled, 70 tools | 16.7 s | +| `mcp-discover` "crm" | the row labelled `MainSequenceCRMAPI` from its branch | 7.8 s | + +Automated verification (`npm run release:check`): 97 Node tests (the packaged-worker MCP +test now covers stored names, listing without a backend request, the one-time completion +of an old record, and denied directory or branch reads), ten native simulator tests and +loader validation on Desktop's bundled Code 2.1.304, documentation, packaging and +distribution checks. devtools 0.3.1 adds its own test for storage, local listing, +completion, denied and mismatched lookups, malformed stored labels, a held lock and +discovery rows (112 tests, CI on Ubuntu and macOS). + ## 0.4.17 — MCP refresh tolerates a briefly unreachable backend (2026-10-21) Reported: after disconnecting one MCP, the remaining CRM MCP showed as **Unnamed MCP** diff --git a/docs/claude-desktop.md b/docs/claude-desktop.md index d007bbd..f40229c 100644 --- a/docs/claude-desktop.md +++ b/docs/claude-desktop.md @@ -227,9 +227,14 @@ are available from the next prompt. Exact release identities remain pinned. Connecting or disconnecting an MCP re-reads the environment's saved connections and each one's tool catalog (since 0.4.17, a refresh tolerates a backend that is briefly -unreachable, such as a local development server restarting). An MCP's row keeps the -last name the backend resolved for it while the name lookup is unavailable, and the -release's own name labels a connection whose repository cannot be read. A connection +unreachable, such as a local development server restarting). Since 0.4.18 a connection's +names are stored with it: connecting an MCP reads the release's name and its repository's +name and saves them in `mcp-connections.json` as `name`, `display_name` and +`code_repository_branch_uid`, so a listing labels the connection without a backend +request, and a connection saved by an earlier version is completed by the first listing +that can read its names. A name that cannot be read is left out and never makes a working +MCP unavailable; the row then keeps the last name the pane resolved for it, or the +release's own name when only its repository could not be read. A connection whose catalog cannot be read right now keeps the tools registered by the previous refresh and is named in a warning under the list; **Refresh** or **Re-register tools** reads it again. Only a disconnected MCP loses its tools, and a failed listing keeps the diff --git a/docs/releases/0.4.18.md b/docs/releases/0.4.18.md new file mode 100644 index 0000000..a4718ed --- /dev/null +++ b/docs/releases/0.4.18.md @@ -0,0 +1,21 @@ +A connected MCP's names now live with its saved connection. Until 0.4.17 the saved record +held only the release UID and endpoint, because the release detail read when connecting +carries neither a name nor a repository; every listing then asked the backend twice (the +release directory, then the repository branch) to label the row, and a backend that was +restarting during a refresh showed **Unnamed MCP**. devtools 0.3.1 reads the release's +name and its repository's name once, when the MCP is connected, and stores them in +`mcp-connections.json` as `name`, `display_name` and `code_repository_branch_uid`. A +listing shows them without a backend request, and a connection saved by an earlier +version is completed by the first listing that can read its names. A name that cannot be +read is left out and never makes a working MCP unavailable. The Add MCP search still +labels its rows live, from the repository branch each row names. + +Validation: 97 Node tests (the packaged-worker MCP test covers stored names, listing +without a backend request, the one-time completion of an old record, and denied directory +or branch reads), ten native Desktop simulator tests, loader validation and distribution +checks on Desktop's bundled Code 2.1.304. Against the local backend, the saved +MainSequenceCRMAPI connection that 0.4.17 had left without a name was completed by the +first listing (5.6 s) and listed from the file afterwards (46 ms, no backend request); +its tool catalog and the Add MCP search carried the same labels. Bundles devtools 0.3.1. +Update from the MainSequence marketplace and reload the plugin in the Code chat. Node 22+ +required. diff --git a/manifests/claude/.claude-plugin/plugin.json b/manifests/claude/.claude-plugin/plugin.json index fd13e18..87e2c52 100644 --- a/manifests/claude/.claude-plugin/plugin.json +++ b/manifests/claude/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mainsequence", - "version": "0.4.17", + "version": "0.4.18", "description": "MainSequence login, repositories, projects, MCP, agents and activity inside Claude Desktop Code.", "author": { "name": "MainSequence" diff --git a/package-lock.json b/package-lock.json index 4bbb3e3..635fb22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "@dev-mainsequence/claude-desktop", - "version": "0.4.17", + "version": "0.4.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@dev-mainsequence/claude-desktop", - "version": "0.4.17", + "version": "0.4.18", "license": "MIT", "dependencies": { - "@dev-mainsequence/devtools": "0.2.8" + "@dev-mainsequence/devtools": "0.3.1" }, "devDependencies": { "@types/node": "18.19.130", @@ -20,9 +20,9 @@ } }, "node_modules/@dev-mainsequence/devtools": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/@dev-mainsequence/devtools/-/devtools-0.2.8.tgz", - "integrity": "sha512-utKQW1KXN/alf+ze7zg29lqhb6juqfyialzlEFpg9oAosaB9n5xCpeIL/0vMAFYJvqlu33ymqvtgiODVKzIIkA==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@dev-mainsequence/devtools/-/devtools-0.3.1.tgz", + "integrity": "sha512-Gb8Gva1PZ1/kLCkO7NfEgELKUhakonck2DKQsgyf5N1pEVgHwyFD0zCsjTHOkNEGaOEPVIvSAL7h1BbNexHIOw==", "license": "MIT", "bin": { "ms-devtools": "dist/runner.js" diff --git a/package.json b/package.json index b99798f..e936542 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dev-mainsequence/claude-desktop", - "version": "0.4.17", + "version": "0.4.18", "private": true, "description": "MainSequence integration for Claude Desktop Code", "license": "MIT", @@ -24,7 +24,7 @@ "verify:published": "node scripts/verify-public-release.cjs" }, "dependencies": { - "@dev-mainsequence/devtools": "0.2.8" + "@dev-mainsequence/devtools": "0.3.1" }, "devDependencies": { "@types/node": "18.19.130", diff --git a/src/hosts/claudeMcpNames.ts b/src/hosts/claudeMcpNames.ts index cfb5928..5878736 100644 --- a/src/hosts/claudeMcpNames.ts +++ b/src/hosts/claudeMcpNames.ts @@ -1,58 +1,32 @@ import { ActionResult, HostServices, RepositoryClient, createSessionService } from '@dev-mainsequence/devtools'; -/** Presentation metadata only. Discovery, saved connections and tool routing stay in devtools. */ +/** Presentation metadata for discovery rows only. A saved connection carries its names + * (devtools stores them when an MCP is connected), so listings need no lookup here. */ export async function withClaudeMcpNames(result: ActionResult, options: { host: HostServices; signal?: AbortSignal }): Promise { - if (result.status !== 'success' || !['mcp-discover', 'mcp-list'].includes(result.action)) return result; + if (result.status !== 'success' || result.action !== 'mcp-discover') return result; const data = result.data as any; - const connections: any[] = Array.isArray(data) ? data : data?.results; - if (!Array.isArray(connections) || !connections.length) return result; - const backend = connections[0].backend; + const rows: any[] = Array.isArray(data?.results) ? data.results : []; + const pending = [...new Set(rows.map(row => row.code_repository_branch_uid).filter(uid => typeof uid === 'string' && uid.trim()))] as string[]; + if (!pending.length) return result; const session = createSessionService(options.host, { signal: options.signal, requestTimeoutMs: 15_000 }); - const repositories = new RepositoryClient(session, backend); - const wanted = new Map(connections.map(row => [row.resource_release_uid, row])); - const branches = new Map(); - const releaseNames = new Map(); - try { - // The pinned devtools result omits repository identity. Read the existing - // collection projection, matching exact releases and environments, to label it. - for (let offset = 0; offset < 10000; offset += 100) { - const page = await session.request(backend, 'GET', `/api/v1/resource-releases/?mcp_available=true&limit=100&offset=${offset}`) as any; - if (!Array.isArray(page?.results) || !Number.isInteger(page.count) || page.count < 0) break; - for (const row of page.results) { - const connection = wanted.get(row.uid); - if (connection?.backend === backend - && connection.mcp_connection?.organization_environment_uid === row.mcp_connection?.organization_environment_uid - && typeof row.code_repository_branch_uid === 'string' && row.code_repository_branch_uid.trim()) { - branches.set(row.uid, row.code_repository_branch_uid); - // The release's own name labels it when its repository cannot be read. - if (typeof row.name === 'string' && row.name.trim()) releaseNames.set(row.uid, row.name.trim()); - } + const repositories = new RepositoryClient(session, rows[0].backend); + const names = new Map(); + // Keep a large directory page from opening an unbounded number of requests. + await Promise.all(Array.from({ length: Math.min(4, pending.length) }, async () => { + while (pending.length) { + const uid = pending.shift()!; + try { + const branch = await repositories.getCodeRepositoryBranch(uid); + if (branch.uid === uid) names.set(uid, branch.code_repository_name); + } catch { + options.signal?.throwIfAborted(); + // Optional display metadata must not make a working MCP unavailable. } - if (branches.size === wanted.size || offset + 100 >= page.count || !page.results.length) break; } - const names = new Map(); - const pending = [...new Set(branches.values())]; - // Keep large saved catalogs from opening an unbounded number of requests. - await Promise.all(Array.from({ length: Math.min(4, pending.length) }, async () => { - while (pending.length) { - const uid = pending.shift()!; - try { - const branch = await repositories.getCodeRepositoryBranch(uid); - if (branch.uid === uid) names.set(uid, branch.code_repository_name); - } catch { - options.signal?.throwIfAborted(); - // Optional display metadata must not make a working MCP unavailable. - } - } - })); - const labelled = connections.map(row => { - const name = names.get(branches.get(row.resource_release_uid) || ''); - const release = typeof row.name === 'string' && row.name.trim() ? undefined : releaseNames.get(row.resource_release_uid); - return { ...row, ...(release ? { name: release } : {}), ...(name ? { display_name: name } : {}) }; - }); - return { ...result, data: Array.isArray(data) ? labelled : { ...data, results: labelled } }; - } catch { - options.signal?.throwIfAborted(); - return result; - } + })); + const labelled = rows.map(row => { + const name = names.get(row.code_repository_branch_uid); + return name ? { ...row, display_name: name } : row; + }); + return { ...result, data: { ...data, results: labelled } }; } diff --git a/tests/claude-mcp.test.cjs b/tests/claude-mcp.test.cjs index 48a79ee..6aec57e 100644 --- a/tests/claude-mcp.test.cjs +++ b/tests/claude-mcp.test.cjs @@ -13,7 +13,8 @@ test('packaged Claude worker discovers, connects and calls MCPs with nameless re const otherUid = 'b0a5ee29-f649-46ab-a040-38504838a132'; const environmentUid = 'ba034541-73b4-4c5b-81ad-16516d8a8f94'; const platformToken = 'fixture-platform-token', releaseToken = 'fixture-release-token'; - const state = { denied: false, noMcp: false, namesDenied: false, namesEnvironment: environmentUid, + const branches = ['c1d2e3f4-0000-4000-8000-000000000000', 'c1d2e3f4-0000-4000-8000-000000000001']; + const state = { denied: false, noMcp: false, namesDenied: false, branchesDenied: false, endpoint: undefined, detailUid: releaseUid, exchanged: 0, invoked: 0 }; const calls = []; async function listen(handler) { @@ -60,27 +61,25 @@ test('packaged Claude worker discovers, connects and calls MCPs with nameless re if (initialized(res, message)) return; assert.equal(message.params.name, 'resource_release.mcp_endpoints'); return reply(res, message.id, { content: [], structuredContent: { count: 2, results: [ - { resource_release_uid: releaseUid, name: 'app', mcp_connection: metadata(releaseUid) }, - { resource_release_uid: otherUid, name: 'app', mcp_connection: metadata(otherUid) }, + { resource_release_uid: releaseUid, name: 'app', code_repository_branch_uid: branches[0], mcp_connection: metadata(releaseUid) }, + { resource_release_uid: otherUid, name: 'app', code_repository_branch_uid: branches[1], mcp_connection: metadata(otherUid) }, ] } }); } res.setHeader('Content-Type', 'application/json'); 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.namesDenied) return res.writeHead(403).end('{}'); - // Exact matching must find entries after the first collection page and - // ignore metadata for the same release in another environment. - const offset = Number(new URL(req.url, backend).searchParams.get('offset')); - return res.end(JSON.stringify({ count: 102, results: offset === 0 - ? [{ uid: releaseUid, code_repository_branch_uid: 'wrong', mcp_connection: { organization_environment_uid: 'different' } }] - : [releaseUid, otherUid].map((uid, i) => ({ uid, code_repository_branch_uid: `branch-${i}`, name: i === 0 ? 'crm' : 'app', - mcp_connection: { organization_environment_uid: state.namesEnvironment } })) })); + const search = new URL(req.url, backend).searchParams.get('search') || ''; + assert.ok(search, 'the worker looks a release up by its UID'); + return res.end(JSON.stringify({ count: 1, results: [releaseUid, otherUid].filter(uid => uid.includes(search)).map(uid => ({ uid, + code_repository_branch_uid: branches[uid === releaseUid ? 0 : 1], name: uid === releaseUid ? 'crm' : 'app', mcp_connection: metadata(uid) })) })); } if (req.url.startsWith('/api/v1/code-repository-branches/')) { if (state.branchesDenied) return res.writeHead(503).end('{}'); const uid = req.url.split('/').at(-2); - assert.ok(['branch-0', 'branch-1'].includes(uid), 'only exact environment matches are resolved'); + assert.ok(branches.includes(uid), 'only branches the directory named are resolved'); return res.end(JSON.stringify({ uid, code_repository_uid: `repo-${uid}`, - code_repository_name: uid === 'branch-0' ? 'BackofficeBackend' : 'alpaca-connectors', + code_repository_name: uid === branches[0] ? 'BackofficeBackend' : 'alpaca-connectors', repository_branch: 'main', is_initialized: true })); } if (req.url === '/api/v1/resource-releases/' + releaseUid + '/') return res.end(JSON.stringify({ @@ -125,40 +124,48 @@ require(${JSON.stringify(path.join(packageRoot, 'dist/claudeRunner.js'))}).main( }); } + const lookups = () => calls.filter(call => call.server === 'platform' && call.path !== '/mcp').length; + const saved = () => fs.readFile(store, 'utf8').then(JSON.parse); const discovered = await run('mcp-discover'); assert.equal(discovered.status, 'success'); assert.deepEqual(discovered.data.results.map(row => row.name), ['app', 'app']); - assert.deepEqual(discovered.data.results.map(row => row.display_name), ['BackofficeBackend', 'alpaca-connectors']); + assert.deepEqual(discovered.data.results.map(row => row.display_name), ['BackofficeBackend', 'alpaca-connectors'], 'discovery rows are labelled from their branch'); const added = await run('mcp-add'); assert.equal(added.status, 'success', added.summary); assert.equal(added.data.tools[0].name, 'lookup'); - assert.equal(Object.hasOwn(added.data.connection, 'name'), false); - const saved = await fs.readFile(store, 'utf8'); - assert.equal(Object.hasOwn(JSON.parse(saved)[0], 'name'), false); + assert.deepEqual([added.data.connection.name, added.data.connection.display_name, added.data.connection.code_repository_branch_uid], + ['crm', 'BackofficeBackend', branches[0]]); + assert.deepEqual([(await saved())[0].name, (await saved())[0].display_name], ['crm', 'BackofficeBackend'], 'the names are stored with the connection'); + let before = lookups(); const listed = await run('mcp-list'); assert.equal(listed.data.length, 1); - assert.equal(listed.data[0].display_name, 'BackofficeBackend', 'old nameless saved connections get the same name'); - assert.equal(listed.data[0].name, 'crm', 'the release name from the projection labels a nameless saved connection'); - assert.deepEqual((await run('mcp-discover')).data.results.map(row => row.name), ['app', 'app'], 'a name the directory gave is kept'); - assert.equal(Object.hasOwn(JSON.parse(saved)[0], 'display_name'), false, 'presentation names do not rewrite connection storage'); - assert.equal(Object.hasOwn(JSON.parse(saved)[0], 'name'), false, 'release names do not rewrite connection storage either'); + assert.equal(listed.data[0].display_name, 'BackofficeBackend'); + assert.equal(lookups(), before, 'a saved connection is listed without a backend request'); + // A connection saved by an earlier plugin version has no names: the first listing that can read them stores them. + const legacy = { backend, resource_release_uid: releaseUid, mcp_connection: added.data.connection.mcp_connection }; + await fs.writeFile(store, JSON.stringify([legacy])); state.branchesDenied = true; const unresolved = await run('mcp-list'); assert.equal(unresolved.data[0].display_name, undefined); assert.equal(unresolved.data[0].name, 'crm', 'the release name remains when its repository cannot be read'); state.branchesDenied = false; + assert.equal((await run('mcp-list')).data[0].display_name, 'BackofficeBackend', 'old nameless saved connections get the same name'); + assert.equal((await saved())[0].display_name, 'BackofficeBackend', 'and keep it'); + before = lookups(); + await run('mcp-list'); + assert.equal(lookups(), before, 'a completed connection is not looked up again'); + await fs.writeFile(store, JSON.stringify([legacy])); state.namesDenied = true; const unavailableNames = await run('mcp-list'); assert.equal(unavailableNames.status, 'success'); assert.equal(unavailableNames.data[0].display_name, undefined, 'optional name lookup cannot break a saved connection'); - state.namesDenied = false; state.namesEnvironment = 'different'; - assert.equal((await run('mcp-list')).data[0].display_name, undefined, 'names from another environment cannot leak'); - state.namesEnvironment = environmentUid; + state.namesDenied = false; + assert.equal((await run('mcp-list')).data[0].display_name, 'BackofficeBackend'); assert.equal((await run('mcp-tools')).data.tools[0].name, 'lookup'); const called = await run('mcp-call', { tool: 'lookup', arguments: { value: 42 } }); assert.equal(called.status, 'success'); assert.equal(called.data.structuredContent.value, 42); - const exchanged = state.exchanged, executed = calls.filter(call => call.server === 'project').length; + const exchanged = state.exchanged, executed = calls.filter(call => call.server === 'project').length, stored = await fs.readFile(store, 'utf8'); state.denied = true; assert.equal((await run('mcp-tools')).error.code, 'AUTH_REJECTED'); state.denied = false; state.endpoint = 'https://changed.example/mcp'; @@ -169,7 +176,7 @@ require(${JSON.stringify(path.join(packageRoot, 'dist/claudeRunner.js'))}).main( assert.equal((await run('mcp-tools')).error.code, 'API_ERROR'); assert.equal(state.exchanged, exchanged); assert.equal(state.invoked, 1); assert.equal(calls.filter(call => call.server === 'project').length, executed); - assert.equal(await fs.readFile(store, 'utf8'), saved, 'failures preserve saved metadata'); + assert.equal(await fs.readFile(store, 'utf8'), stored, 'failures preserve saved metadata'); assert.ok(calls.filter(call => call.server === 'project').every(call => call.authorization === 'Bearer ' + releaseToken)); assert.ok(calls.filter(call => call.server === 'platform').every(call => call.authorization === 'Bearer ' + platformToken)); });