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:
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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}」工具。`,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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' } {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user