Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/mcpl-relist-keeps-tools.fixed.md
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.
23 changes: 21 additions & 2 deletions src/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +13134 to +13135

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Disconnected tools can return If a refresh starts before server B is disconnected, it can retain B in its server snapshot. Once B closes, the failed tools/list call 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
This is a comment left during a code review.
Path: src/framework.ts
Line: 13134-13135

Comment:
**Disconnected tools can return** If a refresh starts before server B is disconnected, it can retain B in its server snapshot. Once B closes, the failed `tools/list` call 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.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.


for (const server of this.mcplServerRegistry.getAllServers()) {
const config = this.mcplServerConfigs.get(server.id);
Expand Down Expand Up @@ -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}--`));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Prompt To Fix With AI
This 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Prompt To Fix With AI
This 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)`,
);
}
}
}

Expand Down
57 changes: 57 additions & 0 deletions test/mcpl-tools-keep-on-failed-relist.test.ts
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 });
}
});
Loading