fix: shorten OpenCode temporary MCP tool names and keep them stable per conversation

Use gbd-/gbc- prefixes with a 6-char conversation hash instead of 20-char per-request hashes, lease per-conversation slots so parallel requests never share a name, and expose gateway custom tools as <server hash>_<name> within the 64-char provider limit.
This commit is contained in:
mesalogo
2026-09-30 17:32:26 +08:00
parent 7fb23d9b6b
commit 80edd15438
6 changed files with 334 additions and 43 deletions
+1 -1
View File
@@ -1079,7 +1079,7 @@ describe('KnowledgeMcpGateway', () => {
expect(listed.tools).toEqual([
expect.objectContaining({
name: expect.stringMatching(
/^mcp_[a-f0-9]{8}_[a-f0-9]{8}_echo_private$/u
/^[a-f0-9]{4}_echo_private$/u
),
description: expect.stringContaining('Private MCP')
})
+2 -2
View File
@@ -77,7 +77,7 @@ import type {
ResolvedMcpServer
} from '../capabilities/capability-service'
import {
createMcpToolName,
createGatewayMcpToolName,
isValidMcpToolName,
listAllMcpTools,
normalizeMcpToolSchema
@@ -848,7 +848,7 @@ export class KnowledgeMcpGateway {
>(normalizeMcpToolSchema(tool.outputSchema))
: undefined,
exposedTool: {
name: createMcpToolName(server.id, tool.name),
name: createGatewayMcpToolName(server.id, tool.name),
title: `${server.name} / ${tool.name}`.slice(0, 200),
description: [
`GoodBuddy 代理的自定义 MCP Server「${server.name}」工具。`,
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import {
createGatewayMcpToolName,
MAXIMUM_GATEWAY_MCP_TOOL_NAME_LENGTH
} from './mcp-tool-utils'
describe('createGatewayMcpToolName', () => {
it('keeps provider-safe original names readable behind a short server hash', () => {
expect(
createGatewayMcpToolName('server-1', 'crmtools_search_tools')
).toMatch(/^[a-f0-9]{4}_crmtools_search_tools$/u)
})
it('separates identical tool names from different servers', () => {
expect(createGatewayMcpToolName('server-1', 'search')).not.toBe(
createGatewayMcpToolName('server-2', 'search')
)
})
it('sanitizes unsafe names and keeps them unique with a tool hash', () => {
const dotted = createGatewayMcpToolName('server-1', 'files.read')
const spaced = createGatewayMcpToolName('server-1', 'files read')
expect(dotted).toMatch(/^[a-f0-9]{4}_files_read_[a-f0-9]{4}$/u)
expect(spaced).toMatch(/^[a-f0-9]{4}_files_read_[a-f0-9]{4}$/u)
expect(dotted).not.toBe(spaced)
expect(createGatewayMcpToolName('server-1', '工具')).toMatch(
/^[a-f0-9]{4}_tool_[a-f0-9]{4}$/u
)
})
it('keeps the runtime-prefixed name within 64 characters for long tool names', () => {
const longA = `${'a'.repeat(120)}_one`
const longB = `${'a'.repeat(120)}_two`
const nameA = createGatewayMcpToolName('server-1', longA)
const nameB = createGatewayMcpToolName('server-1', longB)
expect(nameA.length).toBeLessThanOrEqual(
MAXIMUM_GATEWAY_MCP_TOOL_NAME_LENGTH
)
expect(nameA).not.toBe(nameB)
expect(`gbc-a1b2c3-9_${nameA}`.length).toBeLessThanOrEqual(64)
expect(nameA).toMatch(/^[a-zA-Z0-9_-]+$/u)
})
})
+42
View File
@@ -105,6 +105,48 @@ export function createMcpToolName(
return `mcp_${serverHash}_${toolHash}_${readable}`.slice(0, 64)
}
// Budget for tools re-exposed through a GoodBuddy gateway MCP. Runtimes such as
// OpenCode prepend "<MCP name>_" (up to 13 characters, e.g. "gbc-a1b2c3-9_")
// and common provider APIs reject tool names longer than 64 characters.
export const MAXIMUM_GATEWAY_MCP_TOOL_NAME_LENGTH = 51
const GATEWAY_SERVER_HASH_LENGTH = 4
const GATEWAY_TOOL_HASH_LENGTH = 4
/**
* Compact name for a custom MCP tool exposed through the GoodBuddy gateway:
* "<server hash>_<original name>". The original name is kept verbatim when it
* is already provider-safe and fits; otherwise the readable part is sanitized
* and truncated, and a short hash of the original name keeps it unique.
*/
export function createGatewayMcpToolName(
serverId: string,
originalName: string
): string {
const serverHash = createHash('sha256')
.update(serverId)
.digest('hex')
.slice(0, GATEWAY_SERVER_HASH_LENGTH)
const prefix = `${serverHash}_`
const available = MAXIMUM_GATEWAY_MCP_TOOL_NAME_LENGTH - prefix.length
if (
/^[a-zA-Z0-9_-]+$/u.test(originalName) &&
originalName.length <= available
) {
return `${prefix}${originalName}`
}
const toolHash = createHash('sha256')
.update(originalName)
.digest('hex')
.slice(0, GATEWAY_TOOL_HASH_LENGTH)
const readable =
originalName
.replace(/[^a-zA-Z0-9_-]+/gu, '_')
.replace(/^_+|_+$/gu, '')
.slice(0, available - GATEWAY_TOOL_HASH_LENGTH - 1)
.replace(/_+$/u, '') || 'tool'
return `${prefix}${readable}_${toolHash}`
}
export function normalizeMcpToolSchema(
value: unknown
): Record<string, unknown> & { type: 'object' } {
+137 -19
View File
@@ -17,6 +17,7 @@ import { describe, expect, it, vi } from "vitest";
import type { KnowledgeMcpGateway } from "./knowledge-mcp-gateway";
import type { RuntimeEvent } from "./runtime";
import {
createTemporaryMcpName,
OpenCodeRuntime,
type OpenCodeRuntimeDependencies,
} from "./opencode-runtime";
@@ -2265,7 +2266,7 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
expect(setup.client.mcp.add).toHaveBeenCalledWith(
{
directory: process.cwd(),
name: expect.stringMatching(/^goodbuddy-custom-[a-f0-9]{20}$/u),
name: expect.stringMatching(/^gbc-[a-f0-9]{6}$/u),
config: {
type: "remote",
url: "http://127.0.0.1:4567/mcp",
@@ -3164,7 +3165,7 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
expect(setup.client.mcp.add).toHaveBeenCalledWith(
{
directory: process.cwd(),
name: expect.stringMatching(/^goodbuddy-data-[a-f0-9]{20}$/u),
name: expect.stringMatching(/^gbd-[a-f0-9]{6}$/u),
config: {
type: "remote",
url: "http://127.0.0.1:4567/mcp",
@@ -3201,8 +3202,8 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
read: false,
write: false,
bash: false,
"goodbuddy-data-*": false,
"goodbuddy-custom-*": false,
"gbd-*": false,
"gbc-*": false,
[knowledgeToolId]: true,
},
}),
@@ -3210,7 +3211,7 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
);
expect(setup.client.mcp.disconnect).toHaveBeenCalledWith(
{
name: expect.stringMatching(/^goodbuddy-data-/u),
name: expect.stringMatching(/^gbd-/u),
directory: process.cwd(),
},
{ signal: expect.any(AbortSignal) },
@@ -3219,6 +3220,123 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
await runtime.dispose();
});
it("keeps temporary MCP names stable within a conversation and short enough for providers", async () => {
const setup = runClient([
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
]);
const runtime = embeddedRuntime(setup.client, {
knowledgeGateway: {
getEndpoint: () => "http://127.0.0.1:4567/mcp",
getAvailableToolNames: () => ["goodbuddy_config_capabilities"],
} as unknown as KnowledgeMcpGateway,
});
const runOnce = async (requestId: string, conversationId: string) => {
for await (const _event of runtime.run(
{
requestId,
conversationId,
prompt: "search",
workMode: "ask",
knowledgeCapabilityToken: requestId,
},
new AbortController().signal,
)) {
void _event;
}
};
try {
await runOnce("request-1", "conversation-1");
await runOnce("request-2", "conversation-1");
await runOnce("request-3", "conversation-2");
const add = setup.client.mcp.add as unknown as ReturnType<typeof vi.fn>;
const names = add.mock.calls.map(
(call) => (call[0] as { name: string }).name,
);
expect(names[0]).toMatch(/^gbd-[a-f0-9]{6}$/u);
expect(names[1]).toBe(names[0]);
expect(names[2]).toMatch(/^gbd-[a-f0-9]{6}$/u);
expect(names[2]).not.toBe(names[0]);
expect(`${names[0]}_goodbuddy_config_capabilities`.length).toBeLessThanOrEqual(64);
} finally {
await runtime.dispose();
}
});
it("does not reuse a temporary MCP name whose disconnect failed", async () => {
const setup = runClient([
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
]);
const disconnect = setup.client.mcp.disconnect as unknown as ReturnType<
typeof vi.fn
>;
disconnect
.mockRejectedValueOnce(new Error("disconnect timed out"))
.mockResolvedValueOnce({ data: undefined, error: { name: "BadRequest" } });
const runtime = embeddedRuntime(setup.client, {
knowledgeGateway: {
getEndpoint: () => "http://127.0.0.1:4567/mcp",
getAvailableToolNames: () => ["knowledge_search"],
} as unknown as KnowledgeMcpGateway,
});
const runOnce = async (requestId: string) => {
for await (const _event of runtime.run(
{
requestId,
conversationId: "conversation-1",
prompt: "search",
workMode: "ask",
knowledgeCapabilityToken: requestId,
},
new AbortController().signal,
)) {
void _event;
}
};
try {
await runOnce("request-1");
await runOnce("request-2");
await runOnce("request-3");
await runOnce("request-4");
const add = setup.client.mcp.add as unknown as ReturnType<typeof vi.fn>;
const names = add.mock.calls.map(
(call) => (call[0] as { name: string }).name,
);
const base = names[0]!;
expect(base).toMatch(/^gbd-[a-f0-9]{6}$/u);
// request-1 failed to disconnect (rejected) -> slot 1 stays leased.
expect(names[1]).toBe(`${base}-2`);
// request-2 disconnect returned an error -> slot 2 stays leased.
expect(names[2]).toBe(`${base}-3`);
// request-3 disconnected cleanly -> slot 3 is reusable.
expect(names[3]).toBe(`${base}-3`);
} finally {
await runtime.dispose();
}
});
it("leases distinct temporary MCP slots for concurrent holders and caps them", () => {
const setup = runClient([]);
const runtime = embeddedRuntime(setup.client) as unknown as {
acquireTemporaryMcpSlot: (client: unknown, key: string) => number;
releaseTemporaryMcpSlot: (client: unknown, key: string, slot: number) => void;
};
const otherClient = {};
const slots = Array.from({ length: 9 }, () =>
runtime.acquireTemporaryMcpSlot(setup.client, "abc123"),
);
expect(slots).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]);
expect(() => runtime.acquireTemporaryMcpSlot(setup.client, "abc123")).toThrow(
/并行的 OpenCode 请求过多/u,
);
// Other conversation keys and other OpenCode clients are independent.
expect(runtime.acquireTemporaryMcpSlot(setup.client, "def456")).toBe(1);
expect(runtime.acquireTemporaryMcpSlot(otherClient, "abc123")).toBe(1);
runtime.releaseTemporaryMcpSlot(setup.client, "abc123", 4);
expect(runtime.acquireTemporaryMcpSlot(setup.client, "abc123")).toBe(4);
expect(createTemporaryMcpName("gbd-", "abc123", 1)).toBe("gbd-abc123");
expect(createTemporaryMcpName("gbc-", "abc123", 4)).toBe("gbc-abc123-4");
});
it("enables the deterministic MCP tool name when tool ids omit dynamic tools", async () => {
const setup = runClient([
{
@@ -3694,8 +3812,8 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
write: false,
bash: false,
task: false,
"goodbuddy-data-*": false,
"goodbuddy-custom-*": false,
"gbd-*": false,
"gbc-*": false,
skill: true,
},
}),
@@ -3767,12 +3885,12 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
permission: [
{ permission: "*", pattern: "*", action: "allow" },
{
permission: "goodbuddy-data-*",
permission: "gbd-*",
pattern: "*",
action: "deny",
},
{
permission: "goodbuddy-custom-*",
permission: "gbc-*",
pattern: "*",
action: "deny",
},
@@ -3783,8 +3901,8 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
expect(session.promptAsync).toHaveBeenCalledWith(
expect.objectContaining({
tools: {
"goodbuddy-data-*": false,
"goodbuddy-custom-*": false,
"gbd-*": false,
"gbc-*": false,
},
}),
expect.anything(),
@@ -4334,8 +4452,8 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
write: false,
bash: false,
task: false,
"goodbuddy-data-*": false,
"goodbuddy-custom-*": false,
"gbd-*": false,
"gbc-*": false,
},
}),
expect.anything(),
@@ -4483,7 +4601,7 @@ describe("OpenCodeRuntime native customization", () => {
"webfetch",
"websearch",
"write",
"goodbuddy-data-123_search",
"gbd-123_search",
"extension_tool",
],
error: undefined,
@@ -4513,7 +4631,7 @@ describe("OpenCodeRuntime native customization", () => {
hints: [],
},
{
name: "goodbuddy-data-123",
name: "gbd-123",
source: "mcp",
template: "temporary prompt",
hints: [],
@@ -4548,7 +4666,7 @@ describe("OpenCodeRuntime native customization", () => {
status: vi.fn().mockResolvedValue({
data: {
public: { status: "failed", error: "private failure" },
"goodbuddy-custom-123": { status: "connected" },
"gbc-123": { status: "connected" },
},
}),
},
@@ -4566,7 +4684,7 @@ describe("OpenCodeRuntime native customization", () => {
temporary: {
name: "Temporary resource",
uri: "docs://temporary",
client: "goodbuddy-data-123",
client: "gbd-123",
},
},
}),
@@ -4697,8 +4815,8 @@ describe("OpenCodeRuntime native customization", () => {
expect(serialized).not.toContain("must not be exposed");
expect(serialized).not.toContain("C:\\private");
expect(serialized).not.toContain("assigned-skill");
expect(serialized).not.toContain("goodbuddy-data-");
expect(serialized).not.toContain("goodbuddy-custom-");
expect(serialized).not.toContain("gbd-");
expect(serialized).not.toContain("gbc-");
expect(serialized).not.toContain('"invalid"');
vi.mocked(client.tool.ids).mockRejectedValueOnce(
+109 -21
View File
@@ -95,10 +95,16 @@ const MAX_NATIVE_RESOURCES = runtimeNativeInventoryLimits.resources;
const COMPACTION_USAGE_EVENT_GRACE_MS = 1_000;
const EMBEDDED_SERVER_USERNAME = "goodbuddy";
const OPENCODE_SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
// Keep temporary MCP names short: OpenCode exposes tools as
// "<MCP name>_<tool name>" and common provider APIs cap tool names at 64.
const KNOWLEDGE_MCP_PREFIX = "gbd-";
const CUSTOM_MCP_PREFIX = "gbc-";
const TEMPORARY_MCP_PREFIXES = [
"goodbuddy-data-",
"goodbuddy-custom-",
KNOWLEDGE_MCP_PREFIX,
CUSTOM_MCP_PREFIX,
] as const;
const TEMPORARY_MCP_CONVERSATION_HASH_LENGTH = 6;
const MAX_TEMPORARY_MCP_SLOTS = 9;
const temporaryMcpToolOverrides = Object.fromEntries(
TEMPORARY_MCP_PREFIXES.map((prefix) => [`${prefix}*`, false]),
);
@@ -534,6 +540,21 @@ function isTemporaryMcpName(value: string): boolean {
return TEMPORARY_MCP_PREFIXES.some((prefix) => value.startsWith(prefix));
}
function temporaryMcpConversationKey(conversationId: string): string {
return createHash("sha256")
.update(conversationId)
.digest("hex")
.slice(0, TEMPORARY_MCP_CONVERSATION_HASH_LENGTH);
}
export function createTemporaryMcpName(
prefix: (typeof TEMPORARY_MCP_PREFIXES)[number],
conversationKey: string,
slot: number,
): string {
return `${prefix}${conversationKey}${slot === 1 ? "" : `-${slot}`}`;
}
function isTemporaryMcpInventoryItem(
...values: Array<string | undefined>
): boolean {
@@ -753,6 +774,15 @@ export class OpenCodeRuntime implements AgentRuntime {
>();
private readonly conversationRunTails = new Map<string, Promise<void>>();
private mcpMutationTail: Promise<void> = Promise.resolve();
// Temporary MCP name slots per OpenCode client and conversation. A slot is
// held while a request owns its MCP connections and is only freed after
// every disconnect succeeds, so a request never reuses a name that may still
// be bound to another request's capability token. A new client (restarted
// server) starts with a clean MCP registry and therefore fresh slots.
private readonly temporaryMcpSlots = new WeakMap<
OpencodeClient,
Map<string, Set<number>>
>();
private readonly dependencies: OpenCodeRuntimeDependencies;
constructor(
@@ -829,6 +859,47 @@ export class OpenCodeRuntime implements AgentRuntime {
}
}
private acquireTemporaryMcpSlot(
client: OpencodeClient,
conversationKey: string,
): number {
let byConversation = this.temporaryMcpSlots.get(client);
if (!byConversation) {
byConversation = new Map();
this.temporaryMcpSlots.set(client, byConversation);
}
let occupied = byConversation.get(conversationKey);
if (!occupied) {
occupied = new Set();
byConversation.set(conversationKey, occupied);
}
for (let slot = 1; slot <= MAX_TEMPORARY_MCP_SLOTS; slot += 1) {
if (!occupied.has(slot)) {
occupied.add(slot);
return slot;
}
}
throw new Error(
`同一会话中并行的 OpenCode 请求过多(最多 ${MAX_TEMPORARY_MCP_SLOTS} 个),请等待其他请求结束后重试`,
);
}
private releaseTemporaryMcpSlot(
client: OpencodeClient,
conversationKey: string,
slot: number,
): void {
const byConversation = this.temporaryMcpSlots.get(client);
const occupied = byConversation?.get(conversationKey);
if (!byConversation || !occupied) {
return;
}
occupied.delete(slot);
if (occupied.size === 0) {
byConversation.delete(conversationKey);
}
}
private terminate(child: SpawnedProcess): void {
requestProcessTreeTermination(child, {
platform: this.dependencies.platform,
@@ -2169,6 +2240,16 @@ export class OpenCodeRuntime implements AgentRuntime {
let customMcpName: string | undefined;
let customMcpToken: string | undefined;
const attemptedMcpNames: string[] = [];
const mcpConversationKey = temporaryMcpConversationKey(
request.conversationId,
);
let mcpSlot: number | undefined;
const temporaryMcpName = (
prefix: (typeof TEMPORARY_MCP_PREFIXES)[number],
): string => {
mcpSlot ??= this.acquireTemporaryMcpSlot(client, mcpConversationKey);
return createTemporaryMcpName(prefix, mcpConversationKey, mcpSlot);
};
const subscriptionController = new AbortController();
const imageCapabilityToken = request.imageToolBinding && request.workMode === 'execute'
? this.options.knowledgeGateway?.bindImageTool(request.imageToolBinding, signal, request.knowledgeCapabilityToken)
@@ -2181,10 +2262,7 @@ export class OpenCodeRuntime implements AgentRuntime {
this.options.knowledgeGateway?.getEndpoint()
) {
const knowledgeEndpoint = this.options.knowledgeGateway.getEndpoint()!;
knowledgeMcpName = `goodbuddy-data-${createHash("sha256")
.update(`${request.conversationId}\0${request.requestId}`)
.digest("hex")
.slice(0, 20)}`;
knowledgeMcpName = temporaryMcpName(KNOWLEDGE_MCP_PREFIX);
const added = await this.mutateMcp(signal, () =>
this.controlRequest(
"连接内置只读工具",
@@ -2246,10 +2324,7 @@ export class OpenCodeRuntime implements AgentRuntime {
customMcpToken,
signal,
);
customMcpName = `goodbuddy-custom-${createHash("sha256")
.update(`${request.conversationId}\0${request.requestId}`)
.digest("hex")
.slice(0, 20)}`;
customMcpName = temporaryMcpName(CUSTOM_MCP_PREFIX);
const added = await this.mutateMcp(signal, () =>
this.controlRequest(
"连接自定义 MCP 工具",
@@ -3117,18 +3192,31 @@ export class OpenCodeRuntime implements AgentRuntime {
} finally {
subscriptionController.abort();
if (imageCapabilityToken && imageCapabilityToken !== request.knowledgeCapabilityToken) this.options.knowledgeGateway?.revoke(imageCapabilityToken);
let allMcpDisconnected = true;
for (const name of attemptedMcpNames) {
await this.mutateMcp(new AbortController().signal, () => {
// Queue wait must not consume the disconnect timeout.
const cleanupSignal = AbortSignal.timeout(1_000);
return awaitWithAbort(
client.mcp.disconnect(
{ name, directory },
{ signal: cleanupSignal },
),
cleanupSignal,
);
}).catch(() => undefined);
const disconnected = await this.mutateMcp(
new AbortController().signal,
() => {
// Queue wait must not consume the disconnect timeout.
const cleanupSignal = AbortSignal.timeout(1_000);
return awaitWithAbort(
client.mcp.disconnect(
{ name, directory },
{ signal: cleanupSignal },
),
cleanupSignal,
);
},
).then(
(result) => !(result as { error?: unknown } | undefined)?.error,
() => false,
);
allMcpDisconnected &&= disconnected;
}
// A slot whose connection may still be alive stays occupied so a later
// request cannot reuse a name bound to this request's token.
if (mcpSlot !== undefined && allMcpDisconnected) {
this.releaseTemporaryMcpSlot(client, mcpConversationKey, mcpSlot);
}
if (customMcpToken) {
this.options.knowledgeGateway?.revoke(customMcpToken);