Repository navigation
fix(mcpl): a server that fails a tools re-list keeps its known tools #183
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| - A server that fails `tools/list` during a re-list keeps its known tools instead of vanishing from the tool list. Every reconnect re-lists all servers, so one connector reconnecting while another was briefly down removed the second one's tools: a full prompt-cache rewrite (the tool list tops every request), the tools missing unannounced, and a second rewrite when they returned. A call to a down server fails on its own, as before. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13131,6 +13131,8 @@ export class AgentFramework { | |
|
|
||
| const tools: import('./types/index.js').ToolDefinition[] = []; | ||
| const toolFeatureSets = new Map<string, string>(); | ||
| const previousTools = this.mcplTools; | ||
| const previousFeatureSets = this.mcplToolFeatureSets; | ||
|
|
||
| for (const server of this.mcplServerRegistry.getAllServers()) { | ||
| const config = this.mcplServerConfigs.get(server.id); | ||
|
|
@@ -13160,8 +13162,25 @@ export class AgentFramework { | |
| toolFeatureSets.set(namespacedName, featureSet); | ||
| } | ||
| } | ||
| } catch { | ||
| // Server may not support tools/list — skip silently | ||
| } catch (error) { | ||
| // A server that can't answer right now (mid-reconnect, hung, briefly | ||
| // down) keeps the tools it had: dropping them would change the tool | ||
| // list, which is the top of every request, forcing a full prompt-cache | ||
| // rewrite now and another when they come back, and hiding the tools | ||
| // unannounced in between. A call to a down server fails on its own. | ||
| // A server that never listed tools (none known) contributes nothing. | ||
| const kept = previousTools.filter((t) => t.name.startsWith(`${prefix}--`)); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt To Fix With AIThis is a comment left during a code review.
Path: src/framework.ts
Line: 13172
Comment:
**Nested prefixes copy foreign tools** Two allowed server prefixes can be nested, such as `mcpl--foo` and `mcpl--foo--bar`. If the shorter-prefix server fails its re-list, this filter also copies the longer-prefix server’s old tools. A tool that the longer-prefix server just removed can remain advertised, or an updated tool can appear twice.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly. |
||
| for (const tool of kept) { | ||
| tools.push(tool); | ||
|
Comment on lines
+13173
to
+13174
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt To Fix With AIThis is a comment left during a code review.
Path: src/framework.ts
Line: 13173-13174
Comment:
**Partial lists produce duplicates** The catch also runs when processing a returned list fails after some tools have already been appended. For example, a valid known tool followed by a malformed `null` entry appends the first tool, throws on the second, then appends the old copy of the first tool again. The request list consequently contains duplicate tool names.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly. |
||
| const fs = previousFeatureSets.get(tool.name); | ||
| if (fs !== undefined) toolFeatureSets.set(tool.name, fs); | ||
| } | ||
| if (kept.length > 0) { | ||
| console.error( | ||
| `[mcpl-tools] server "${server.id}" did not answer tools/list ` + | ||
| `(${error instanceof Error ? error.message : String(error)}); keeping its ${kept.length} known tool(s)`, | ||
| ); | ||
| } | ||
| } | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| /** | ||
| * A server that fails tools/list during a re-list (every reconnect re-lists | ||
| * ALL servers) keeps its known tools instead of vanishing from the list: a | ||
| * vanishing tool changes the top of every request (full prompt-cache | ||
| * rewrite, twice) and hides the tools unannounced. | ||
| */ | ||
| import { test } from 'node:test'; | ||
| import assert from 'node:assert/strict'; | ||
| import { mkdtempSync, rmSync } from 'node:fs'; | ||
| import { tmpdir } from 'node:os'; | ||
| import { join } from 'node:path'; | ||
| import { AgentFramework } from '../src/framework.js'; | ||
| import { MockMembrane } from './helpers/mock-membrane.js'; | ||
|
|
||
| type Internals = { | ||
| mcplServerRegistry: unknown; | ||
| mcplTools: Array<{ name: string }>; | ||
| refreshMcplTools(): Promise<void>; | ||
| }; | ||
|
|
||
| test('a failed re-list keeps that server\'s known tools; others refresh normally', async () => { | ||
| const dir = mkdtempSync(join(tmpdir(), 'mcpl-keep-')); | ||
| const fw = await AgentFramework.create({ | ||
| storePath: join(dir, 'store'), membrane: new MockMembrane().asMembrane(), | ||
| agents: [{ name: 'assistant', model: 'test-model', systemPrompt: 'test' }], modules: [], | ||
| }); | ||
| try { | ||
| const state = { aTools: ['send'], bFails: false }; | ||
| const server = (id: string, list: () => string[]) => ({ | ||
| id, | ||
| sendToolsList: async () => { | ||
| if (id === 'b' && state.bFails) throw new Error('connection to "b" is closed'); | ||
| return { tools: list().map((name) => ({ name, description: name, inputSchema: { type: 'object' } })) }; | ||
| }, | ||
| }); | ||
| const servers = [server('a', () => state.aTools), server('b', () => ['read', 'write']), server('never', () => { throw new Error('no'); })]; | ||
| const internals = fw as unknown as Internals; | ||
| internals.mcplServerRegistry = { getAllServers: () => servers, closeAll: async () => {} }; | ||
| const names = () => internals.mcplTools.map((t) => t.name).sort(); | ||
|
|
||
| await internals.refreshMcplTools(); | ||
| assert.deepEqual(names(), ['mcpl--a--send', 'mcpl--b--read', 'mcpl--b--write']); | ||
|
|
||
| state.bFails = true; | ||
| state.aTools = ['send', 'react']; | ||
| await internals.refreshMcplTools(); | ||
| assert.deepEqual(names(), ['mcpl--a--react', 'mcpl--a--send', 'mcpl--b--read', 'mcpl--b--write'], | ||
| 'b keeps its tools; a picks up its new one'); | ||
|
|
||
| state.bFails = false; | ||
| await internals.refreshMcplTools(); | ||
| assert.equal(internals.mcplTools.filter((t) => t.name.startsWith('mcpl--b--')).length, 2); | ||
| } finally { | ||
| await fw.stop(); | ||
| rmSync(dir, { recursive: true, force: true }); | ||
| } | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
tools/listcall restores B’s tools from the earlier snapshot. If that refresh finishes after the disconnect’s own refresh, it overwrites the list that removed B, leaving disconnected tools advertised until another refresh.Prompt To Fix With AI