feat: add frosted glass appearance and improve local MCP recovery

Add an opt-in persistent frosted glass setting for the top bar and runtime checklist, with chat scrolling behind the header while preserving scroll anchors and window controls.

Retry transient local MCP initialization failures once after cleanup, include redacted connection diagnostics in feedback, and index lifecycle events to reduce execution statistics scans.

Release note: Add optional frosted glass appearance with chat visible behind the title bar; improve local tool connection recovery and diagnostic feedback.
This commit is contained in:
mesalogo
2026-09-30 23:37:26 +08:00
parent e4f7b8d993
commit f7f501a8e3
39 changed files with 1666 additions and 251 deletions
+10
View File
@@ -73,6 +73,16 @@ function settings(
}
describe('createAgentRuntime model compatibility', () => {
it('passes the desktop failure observer to local OpenCode', async () => {
const observeFailure = vi.fn()
const runtime = createAgentRuntime(process.cwd(), settings({ provider: 'opencode' }), { observeFailure })
try {
expect(runtime).toMatchObject({ options: { observeFailure } })
} finally {
await runtime.dispose()
}
})
it('does not treat the default model profile as the platform DeepSeek source', () => {
const defaultProfile = {
id: '00000000-0000-4000-8000-000000000001',
+3
View File
@@ -44,6 +44,7 @@ import {
type ExecutionSpaceDescriptor
} from '../execution-space'
import type { LaunchEnvironmentProvider } from '../local-tool-environment/launch-environment-provider'
import type { DesktopDiagnosticFailureObserver } from '../desktop-diagnostics'
const noModelTaskTools: ModelToolProviderLike = {
listTools: async () => [],
@@ -58,6 +59,7 @@ const noModelTaskTools: ModelToolProviderLike = {
}
export type AgentCapabilityContext = {
observeFailure?: DesktopDiagnosticFailureObserver
localRuntimeRegistry?: LocalRuntimeRegistry
skillInstructions?: string
skillPackages?: RuntimeSkillPackage[]
@@ -306,6 +308,7 @@ export function createAgentRuntime(
)
}
const options: OpenCodeRuntimeOptions = {
observeFailure: capabilities.observeFailure,
baseUrl,
embedded,
binaryPath:
@@ -24,6 +24,8 @@ import {
} from './knowledge-mcp-gateway'
import { browserTabIdSchema } from '../../shared/contracts'
import { BrowserService } from '../browser/browser-service'
import { createHash } from 'node:crypto'
import { DesktopDiagnostics, type DesktopDiagnosticFailureObserver } from '../desktop-diagnostics'
const firstLibraryId = '11111111-1111-4111-8111-111111111111'
const secondLibraryId = '22222222-2222-4222-8222-222222222222'
@@ -178,6 +180,90 @@ function testTool(name: string): Tool {
const gateways: KnowledgeMcpGateway[] = []
it('records safe HTTP rejection phases and isolates throwing observers', async () => {
const observeFailure = vi.fn<DesktopDiagnosticFailureObserver>(() => { throw new Error('observer failed') })
const gateway = new KnowledgeMcpGateway(createService().service, { observeFailure })
gateways.push(gateway)
await gateway.start()
const token = gateway.grant('private-request', [firstLibraryId], new AbortController().signal)!
const correlationId = `sha256:${createHash('sha256').update(token).digest('hex')}`
const cases = [
{ headers: {}, body: '{}', status: 401, phase: 'authentication' },
{ headers: { authorization: `Bearer ${token}` }, body: 'private-invalid-json', status: 400, phase: 'request' },
{ headers: { authorization: `Bearer ${token}`, 'mcp-session-id': 'private-session' }, body: '{}', status: 404, phase: 'session' },
{ headers: { authorization: `Bearer ${token}` }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'test', version: '1' } } }), status: 406, phase: 'initialize' }
]
for (const entry of cases) {
const response = await fetch(gateway.getEndpoint()!, { method: 'POST', headers: { 'content-type': 'application/json', ...entry.headers }, body: entry.body })
await response.text()
expect(response.status).toBe(entry.status)
expect(observeFailure).toHaveBeenLastCalledWith(expect.objectContaining({
code: 'runtime.mcp.failed', mcp: expect.objectContaining({ phase: entry.phase, category: 'http-rejection', status: entry.status, elapsedMs: expect.any(Number) })
}))
}
expect(observeFailure.mock.calls.at(-1)?.[0]).toMatchObject({ mcp: { correlationId } })
expect(JSON.stringify(observeFailure.mock.calls)).not.toContain(token)
expect(JSON.stringify(observeFailure.mock.calls)).not.toContain('private-')
})
it('cleans up failed HTTP initialization and allows a subsequent connection', async () => {
const observeFailure = vi.fn()
const gateway = new KnowledgeMcpGateway(createService().service, { observeFailure })
gateways.push(gateway)
await gateway.start()
const token = gateway.grant('request', [firstLibraryId], new AbortController().signal)!
const connect = vi.spyOn(McpProtocolServer.prototype, 'connect').mockRejectedValue(new Error('private-connect-error'))
const close = vi.spyOn(McpProtocolServer.prototype, 'close')
for (let index = 0; index < 9; index += 1) {
const response = await fetch(gateway.getEndpoint()!, {
method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
body: JSON.stringify({ jsonrpc: '2.0', id: index, method: 'initialize', params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'test', version: '1' } } })
})
await response.text()
expect(response.status).toBe(500)
}
expect(close).toHaveBeenCalledTimes(9)
expect(observeFailure).toHaveBeenCalledTimes(9)
expect(observeFailure).toHaveBeenLastCalledWith(expect.objectContaining({ mcp: expect.objectContaining({ phase: 'initialize', category: 'handler-failure', status: 500 }) }))
connect.mockRestore()
const client = new Client({ name: 'test', version: '1' })
try {
await client.connect(new StreamableHTTPClientTransport(new URL(gateway.getEndpoint()!), { requestInit: { headers: { authorization: `Bearer ${token}` } } }))
await client.listTools()
await client.callTool({ name: 'knowledge_list', arguments: {} })
expect(observeFailure).toHaveBeenCalledTimes(9)
} finally { await client.close() }
})
it('persists asynchronous HTTP tool discovery failures without private error details', async () => {
const directory = await mkdtemp(join(tmpdir(), 'gateway-diagnostics-'))
temporaryDirectories.push(directory)
const diagnostics = new DesktopDiagnostics(directory)
const pending: Promise<void>[] = []
let clock = 100
vi.spyOn(performance, 'now').mockImplementation(() => clock)
const gateway = new KnowledgeMcpGateway(createService().service, {
observeFailure: failure => { pending.push(diagnostics.recordFailure(failure)) },
storyGraphService: { available: async () => { clock = 145; throw new Error('private-url-token-body') }, read: () => ({}) }
})
gateways.push(gateway)
await gateway.start()
const token = gateway.grant('private-request', [firstLibraryId], new AbortController().signal, 'none', undefined, undefined, undefined, undefined, undefined, { runtimeTarget: 'opencode' })!
const client = new Client({ name: 'test', version: '1' })
try {
await client.connect(new StreamableHTTPClientTransport(new URL(gateway.getEndpoint()!), { requestInit: { headers: { authorization: `Bearer ${token}` } } }))
await expect(client.listTools()).rejects.toThrow()
await Promise.all(pending)
const exported = (await diagnostics.exportRecent()).toString('utf8')
expect(JSON.parse(exported)).toMatchObject({ code: 'runtime.mcp.failed', mcp: { phase: 'tool-discovery', category: 'handler-failure', elapsedMs: 45 } })
expect(exported).not.toContain('private-')
expect(exported).not.toContain(token)
} finally {
await client.close()
await diagnostics.dispose()
}
})
it('honors a requested knowledge result count above eight', async () => {
const { service, retrieveMany } = createService()
const [sample] = await retrieveMany([firstLibraryId])
+126 -71
View File
@@ -1,4 +1,5 @@
import { randomBytes } from 'node:crypto'
import { createHash, randomBytes } from 'node:crypto'
import type { DesktopDiagnosticFailureObserver, DesktopMcpDiagnosticMetadata } from '../desktop-diagnostics'
import { isStoryGraphTool, storyGraphToolNames, type StoryGraphToolName } from '../../shared/story-graph-tools'
import type { RuntimeTarget } from '../../shared/capability-contracts'
@@ -256,6 +257,7 @@ type DownstreamMcpSession = {
}
export type KnowledgeMcpGatewayOptions = {
observeFailure?: DesktopDiagnosticFailureObserver
storyGraphService?: StoryGraphService
maximumBodyBytes?: number
magicNotesDatabase?: MagicNotesDatabase
@@ -401,6 +403,7 @@ export class KnowledgeMcpGateway {
): BrowserTabUsageLease
}
private readonly launchEnvironmentProvider?: LaunchEnvironmentProvider
private readonly observeFailure?: DesktopDiagnosticFailureObserver
private server?: Server
private endpoint?: string
@@ -416,6 +419,18 @@ export class KnowledgeMcpGateway {
this.obsidianService = options.obsidianService
this.browserService = options.browserService
this.launchEnvironmentProvider = options.launchEnvironmentProvider
this.observeFailure = options.observeFailure
}
private observeMcpFailure(mcp: DesktopMcpDiagnosticMetadata): void {
try {
this.observeFailure?.({
component: 'runtime', stage: 'connect', code: 'runtime.mcp.failed',
error: new Error('Local MCP request failed'), mcp
})
} catch {
// Diagnostics must not change the HTTP response or session lifecycle.
}
}
async start(): Promise<void> {
@@ -423,7 +438,23 @@ export class KnowledgeMcpGateway {
return
}
const server = createServer((request, response) => {
void this.handleRequest(request, response).catch(() => {
const context = { phase: 'request' as DesktopMcpDiagnosticMetadata['phase'], startedAt: performance.now(), correlationId: undefined as string | undefined }
let failed = false
response.once('finish', () => {
if (!failed && response.statusCode >= 400) {
this.observeMcpFailure({
phase: context.phase, category: 'http-rejection', status: response.statusCode,
elapsedMs: Math.round(performance.now() - context.startedAt), correlationId: context.correlationId
})
}
})
void this.handleRequest(request, response, context).catch(() => {
failed = true
this.observeMcpFailure({
phase: context.phase, category: 'handler-failure',
...(response.headersSent ? {} : { status: 500 }),
elapsedMs: Math.round(performance.now() - context.startedAt), correlationId: context.correlationId
})
sendJson(response, 500, {
jsonrpc: '2.0',
error: { code: -32603, message: 'Internal server error' },
@@ -1683,76 +1714,86 @@ export class KnowledgeMcpGateway {
mcp.setRequestHandler(
ListToolsRequestSchema,
async (_request, extra) => {
const customBindings = await this.getCustomMcpBindings(
token,
extra.signal
)
const storyGraphAvailable = await this.isStoryGraphAvailable(token)
const scopedTools = [...availableTools].filter(name => !name.startsWith('story_graph_') || storyGraphAvailable).flatMap(
(name): Tool[] => {
const definition = scopedDataToolByName.get(
name as ScopedDataToolName
)
if (!definition) {
return []
}
const inputSchema = z.toJSONSchema(
definition.inputSchema,
{ target: 'draft-7' }
) as Tool['inputSchema'] & { $schema?: string }
Reflect.deleteProperty(inputSchema, '$schema')
return [
{
name,
title: definition.title,
description: definition.description,
inputSchema,
annotations: {
readOnlyHint: definition.access === 'read',
destructiveHint:
name === 'goodbuddy_config_apply' ||
name === 'note_delete' ||
name === 'note_entry_delete'
}
const startedAt = performance.now()
try {
const customBindings = await this.getCustomMcpBindings(
token,
extra.signal
)
const storyGraphAvailable = await this.isStoryGraphAvailable(token)
const scopedTools = [...availableTools].filter(name => !name.startsWith('story_graph_') || storyGraphAvailable).flatMap(
(name): Tool[] => {
const definition = scopedDataToolByName.get(
name as ScopedDataToolName
)
if (!definition) {
return []
}
const inputSchema = z.toJSONSchema(
definition.inputSchema,
{ target: 'draft-7' }
) as Tool['inputSchema'] & { $schema?: string }
Reflect.deleteProperty(inputSchema, '$schema')
return [
{
name,
title: definition.title,
description: definition.description,
inputSchema,
annotations: {
readOnlyHint: definition.access === 'read',
destructiveHint:
name === 'goodbuddy_config_apply' ||
name === 'note_delete' ||
name === 'note_entry_delete'
}
}
]
}
)
const capability = this.getCapability(token)
const imageTool = await imageToolDefinition(capability.imageToolBinding)
const browserTools = capability.browserConversationId && capability.browserTabId
? new BrowserModelTools({
service: this.browserService!,
conversationId: capability.browserConversationId,
browserTabId: capability.browserTabId
})
: undefined
const browserDefinitions = browserTools
? browserTools.listTools().map(
(definition): Tool => ({
name: definition.name,
title: definition.displayName,
description: definition.description,
inputSchema: definition.inputSchema as Tool['inputSchema'],
annotations: {
readOnlyHint:
definition.name === 'browser_snapshot' ||
definition.name === 'browser_screenshot',
destructiveHint: false
}
})
)
: []
session.listedTools = true
return {
tools: [
...(imageTool ? [{ name: imageTool.name, description: imageTool.description, inputSchema: imageTool.inputSchema as Tool['inputSchema'], annotations: { readOnlyHint: false, destructiveHint: false } }] : []),
...scopedTools,
...browserDefinitions,
...[...customBindings.values()].map(
(binding) => binding.exposedTool
)
]
}
)
const capability = this.getCapability(token)
const imageTool = await imageToolDefinition(capability.imageToolBinding)
const browserTools = capability.browserConversationId && capability.browserTabId
? new BrowserModelTools({
service: this.browserService!,
conversationId: capability.browserConversationId,
browserTabId: capability.browserTabId
})
: undefined
const browserDefinitions = browserTools
? browserTools.listTools().map(
(definition): Tool => ({
name: definition.name,
title: definition.displayName,
description: definition.description,
inputSchema: definition.inputSchema as Tool['inputSchema'],
annotations: {
readOnlyHint:
definition.name === 'browser_snapshot' ||
definition.name === 'browser_screenshot',
destructiveHint: false
}
})
)
: []
session.listedTools = true
return {
tools: [
...(imageTool ? [{ name: imageTool.name, description: imageTool.description, inputSchema: imageTool.inputSchema as Tool['inputSchema'], annotations: { readOnlyHint: false, destructiveHint: false } }] : []),
...scopedTools,
...browserDefinitions,
...[...customBindings.values()].map(
(binding) => binding.exposedTool
)
]
} catch (error) {
this.observeMcpFailure({
phase: 'tool-discovery', category: 'handler-failure',
elapsedMs: Math.round(performance.now() - startedAt),
correlationId: `sha256:${createHash('sha256').update(token).digest('hex')}`
})
throw error
}
}
)
@@ -1921,7 +1962,8 @@ export class KnowledgeMcpGateway {
private async handleRequest(
request: IncomingMessage,
response: ServerResponse
response: ServerResponse,
context: { phase: DesktopMcpDiagnosticMetadata['phase']; startedAt: number; correlationId?: string }
): Promise<void> {
if (request.url !== '/mcp') {
sendJson(response, 404, { error: 'Not found' })
@@ -1940,6 +1982,8 @@ export class KnowledgeMcpGateway {
})
return
}
context.phase = 'authentication'
context.startedAt = performance.now()
const authorization = request.headers.authorization
if (
typeof authorization !== 'string' ||
@@ -1949,6 +1993,7 @@ export class KnowledgeMcpGateway {
return
}
const token = authorization.slice('Bearer '.length)
context.correlationId = `sha256:${createHash('sha256').update(token).digest('hex')}`
try {
this.getCapability(token)
} catch {
@@ -1956,6 +2001,8 @@ export class KnowledgeMcpGateway {
return
}
context.phase = 'request'
context.startedAt = performance.now()
let body: unknown
if (request.method === 'POST') {
try {
@@ -1971,6 +2018,8 @@ export class KnowledgeMcpGateway {
}
}
context.phase = 'session'
context.startedAt = performance.now()
const sessionId = request.headers['mcp-session-id']
let createdSession = false
let session =
@@ -2020,15 +2069,21 @@ export class KnowledgeMcpGateway {
const availableTools = new Set(
this.getAvailableToolNames(token)
)
context.phase = 'initialize'
context.startedAt = performance.now()
session = this.createDownstreamMcpSession(
token,
availableTools
)
createdSession = true
this.downstreamMcpSessions.set(session.registryKey, session)
await session.mcp.connect(session.transport)
}
try {
if (createdSession) await session.mcp.connect(session.transport)
if (body && typeof body === 'object' && 'method' in body && body.method === 'tools/list') {
context.phase = 'tool-discovery'
context.startedAt = performance.now()
}
await session.transport.handleRequest(request, response, body)
} finally {
if (createdSession && session.id === undefined) {
@@ -1,5 +1,6 @@
// @vitest-environment node
import { existsSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { mkdtemp, rm } from 'node:fs/promises'
import { createServer } from 'node:http'
import { tmpdir } from 'node:os'
@@ -8,12 +9,112 @@ import { createOpencodeClient } from '@opencode-ai/sdk/v2'
import { expect, it } from 'vitest'
import { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
import { OpenCodeRuntime } from './opencode-runtime'
import type { DesktopDiagnosticFailure } from '../desktop-diagnostics'
const binaryPath = join(
process.cwd(), '.runtime-resources', process.arch,
process.platform === 'win32' ? 'opencode.exe' : 'opencode'
)
it.skipIf(!existsSync(binaryPath))(
'recovers a lost MCP add response with the real OpenCode binary and gateway',
async () => {
const workspace = await mkdtemp(join(tmpdir(), 'goodbuddy-opencode-mcp-retry-'))
let modelCalls = 0
const model = createServer((_request, response) => {
modelCalls++
response.writeHead(200, { 'content-type': 'text/event-stream' })
response.end(`data: ${JSON.stringify({
id: 'retry', object: 'chat.completion.chunk', created: 1, model: 'retry',
choices: [{ index: 0, delta: { role: 'assistant', content: 'RETRY_OK' }, finish_reason: 'stop' }]
})}\n\ndata: [DONE]\n\n`)
})
await new Promise<void>(resolve => model.listen(0, '127.0.0.1', resolve))
const address = model.address()
if (!address || typeof address === 'string') throw new Error('No model port')
const gateway = new KnowledgeMcpGateway({} as never)
await gateway.start()
const controller = new AbortController()
const requestId = crypto.randomUUID()
const token = gateway.grant(requestId, [crypto.randomUUID()], controller.signal)!
const records: DesktopDiagnosticFailure[] = []
const operations: string[] = []
const registrations: Array<{ name: string; config: { headers: { Authorization: string } } }> = []
const runtime = new OpenCodeRuntime({
embedded: true, binaryPath: '', bundledBinaryPath: binaryPath,
configPath: '', defaultWorkspace: workspace, knowledgeGateway: gateway,
observeFailure: failure => records.push(failure),
modelProfile: {
id: crypto.randomUUID(), name: 'Retry fixture', modelName: 'retry',
baseUrl: `http://127.0.0.1:${address.port}/v1`,
protocol: 'openai-chat-completions', authentication: 'none'
}
}, {
createClient: options => createOpencodeClient({
...options,
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
const pathname = new URL(request.url).pathname
if (pathname === '/mcp' && request.method === 'POST') {
const registration = await request.clone().json() as typeof registrations[number]
registrations.push(registration)
const response = await fetch(request, init)
const status = await response.clone().json() as Record<string, { status: string }>
expect(status[registration.name]?.status).toBe('connected')
operations.push('add-connected')
if (registrations.length === 1) {
// The server and gateway are already connected when the response is lost.
await response.body?.cancel()
throw new TypeError('fetch failed', { cause: Object.assign(new Error('socket reset'), { code: 'ECONNRESET' }) })
}
return response
}
const response = await fetch(request, init)
if (pathname.endsWith('/disconnect')) {
expect(await response.clone().json()).toBe(true)
operations.push('disconnect-succeeded')
}
return response
}
})
})
const deadline = setTimeout(() => controller.abort(), 30_000)
try {
let text = ''
for await (const event of runtime.run({
requestId, conversationId: crypto.randomUUID(), workMode: 'ask',
prompt: 'Reply RETRY_OK without tools.', knowledgeCapabilityToken: token
}, controller.signal)) {
if (event.type === 'text') text += event.delta
}
expect(text).toBe('RETRY_OK')
expect(modelCalls).toBe(1)
expect(operations).toEqual(['add-connected', 'disconnect-succeeded', 'add-connected', 'disconnect-succeeded'])
expect(registrations).toHaveLength(2)
expect(registrations[1]).toEqual(registrations[0])
expect(registrations[0]?.config.headers.Authorization).toBe(`Bearer ${token}`)
expect(records).toHaveLength(1)
expect(records[0]?.mcp).toMatchObject({
phase: 'connect', category: 'transport-failure', attempt: 1, kind: 'builtin',
correlationId: `sha256:${createHash('sha256').update(token).digest('hex')}`
})
expect(JSON.stringify(records)).not.toContain(token)
const unauthorized = await fetch(gateway.getEndpoint()!, { method: 'POST' })
expect(unauthorized.status).toBe(401)
await unauthorized.body?.cancel()
} finally {
clearTimeout(deadline)
await runtime.dispose()
gateway.revoke(token)
await gateway.dispose()
model.closeAllConnections()
await new Promise<void>(resolve => model.close(() => resolve()))
await rm(workspace, { recursive: true, force: true })
}
},
60_000
)
it.skipIf(!existsSync(binaryPath))(
'closes real event streams after parallel runs and compaction without cancelling a peer',
async () => {
+376 -6
View File
@@ -1,4 +1,5 @@
import { EventEmitter } from "node:events";
import { createHash } from "node:crypto";
import { createServer } from "node:http";
import {
mkdir,
@@ -11,11 +12,12 @@ import {
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { PassThrough } from "node:stream";
import type { createOpencodeClient } from "@opencode-ai/sdk/v2";
import { createOpencodeClient } from "@opencode-ai/sdk/v2";
import type spawn from "cross-spawn";
import { describe, expect, it, vi } from "vitest";
import type { KnowledgeMcpGateway } from "./knowledge-mcp-gateway";
import type { RuntimeEvent } from "./runtime";
import { DesktopDiagnostics, type DesktopDiagnosticFailureObserver } from "../desktop-diagnostics";
import {
createTemporaryMcpName,
OpenCodeRuntime,
@@ -3220,6 +3222,370 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
await runtime.dispose();
});
it.each(["knowledge", "custom"].flatMap<{
kind: string; scenario: string; detail: string; attempts: number;
succeeds?: boolean; thrown?: boolean; status?: string; apiError?: boolean;
}>((kind) => [
{ kind, scenario: "retry success", detail: "MCP error -32001: Request timed out", attempts: 2, succeeds: true },
{ kind, scenario: "retry exhausted", detail: "connect ECONNREFUSED", attempts: 2 },
{ kind, scenario: "thrown connect error", detail: "connect ECONNRESET", attempts: 2, succeeds: true, thrown: true },
{ kind, scenario: "auth", detail: "HTTP 401 Unauthorized; ECONNRESET", attempts: 1 },
{ kind, scenario: "OAuth", detail: "OAuth error: request timed out", attempts: 1 },
{ kind, scenario: "protocol", detail: "Invalid protocol response; connection closed", attempts: 1 },
{ kind, scenario: "unknown", detail: "Unexpected initialization failure", attempts: 1 },
{ kind, scenario: "plain failed", detail: "failed", attempts: 1 },
{ kind, scenario: "needs auth", detail: "needs_auth", attempts: 1, status: "needs_auth" },
{ kind, scenario: "missing status", detail: "unknown", attempts: 1, status: "missing" },
{ kind, scenario: "API error", detail: "HTTP 403 Forbidden", attempts: 1, apiError: true },
]))("handles $kind MCP initialization: $scenario", async ({ kind, detail, attempts, succeeds, thrown, status, apiError }) => {
const setup = runClient([
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
]);
const add = vi.mocked(setup.client.mcp.add);
const disconnect = vi.mocked(setup.client.mcp.disconnect);
const secret = "request-private-capability";
const failure = `${detail}\nAuthorization: Bearer ${secret}\npassword=private-password\nhttps://user:private-password@example.com/ECONNRESET?token=private-query\n${"x".repeat(3_000)}`;
const fail = async (input?: { name?: string }) => {
if (thrown) throw new Error(failure);
if (apiError) return { error: { name: "UnknownError", data: { message: failure } }, response: { status: 403 } };
return { data: status === "missing" ? {} : {
[input!.name!]: { status: status ?? "failed", error: failure },
} };
};
// The SDK returns a map keyed by the registered name, not a top-level status.
if (succeeds) add.mockImplementationOnce(fail as never);
else add.mockImplementation(fail as never);
const gateway = {
getEndpoint: () => "http://127.0.0.1:4567/mcp",
getAvailableToolNames: () => ["knowledge_search"],
grantCustomMcp: vi.fn(() => secret),
prepareCustomMcpTools: vi.fn(async () => [{ name: "custom_tool" }]),
revoke: vi.fn(),
};
const observeFailure = vi.fn<DesktopDiagnosticFailureObserver>();
const runtime = embeddedRuntime(setup.client, {
observeFailure,
knowledgeGateway: gateway as unknown as KnowledgeMcpGateway,
mcpServers: kind === "custom" ? [{
id: "custom", name: "Custom", description: "", transport: "http",
url: "https://example.com/mcp", enabled: true, allowDynamicTools: false,
assignments: ["opencode"], secretConfigured: false,
}] : [],
});
const collect = async () => {
const events: RuntimeEvent[] = [];
for await (const event of runtime.run({
requestId: "request-1", conversationId: "conversation-1", prompt: "test",
workMode: kind === "custom" ? "execute" : "ask",
...(kind === "knowledge" ? { knowledgeCapabilityToken: secret } : {}),
}, new AbortController().signal)) events.push(event);
return events;
};
try {
if (succeeds) {
await expect(collect()).resolves.toContainEqual(expect.objectContaining({ type: "done" }));
expect(setup.session.promptAsync).toHaveBeenCalledOnce();
} else {
const error = await collect().catch((error: unknown) => error) as Error;
expect(error).toBeInstanceOf(Error);
expect(error.message).toContain(detail);
expect(error.message).not.toMatch(/request-private-capability|private-password|private-query/);
expect(error.message.length).toBeLessThan(2_100);
expect(setup.session.promptAsync).not.toHaveBeenCalled();
}
expect(add).toHaveBeenCalledTimes(attempts);
expect(disconnect).toHaveBeenCalledTimes(attempts);
expect(observeFailure).toHaveBeenCalledTimes(succeeds ? 1 : attempts);
for (const [index, [record]] of observeFailure.mock.calls.entries()) {
expect(record).toMatchObject({
component: "runtime", stage: "connect", code: "runtime.mcp.failed",
mcp: {
phase: "connect", attempt: index + 1, kind: kind === "knowledge" ? "builtin" : "custom",
correlationId: `sha256:${createHash("sha256").update(secret).digest("hex")}`,
elapsedMs: expect.any(Number),
category: apiError ? "http-rejection" : thrown ? "transport-failure" : "initialization-failure",
...(apiError ? { status: 403 } : {}),
},
});
expect(record.mcp!.elapsedMs).toBeGreaterThanOrEqual(0);
expect(JSON.stringify(record)).not.toMatch(/request-private-capability|private-password|private-query|example.com|ECONNRESET/);
expect((record.error as Error).message).toBe("Local MCP operation failed");
}
if (attempts === 2) {
expect(add.mock.calls[1]![0]).toEqual(add.mock.calls[0]![0]);
expect(disconnect.mock.invocationCallOrder[0]).toBeGreaterThan(add.mock.invocationCallOrder[0]!);
expect(disconnect.mock.invocationCallOrder[0]).toBeLessThan(add.mock.invocationCallOrder[1]!);
}
expect(add.mock.calls[0]![0]?.config).toMatchObject({
headers: { Authorization: `Bearer ${secret}` }, oauth: false,
});
if (kind === "custom") {
expect(gateway.grantCustomMcp).toHaveBeenCalledOnce();
expect(gateway.prepareCustomMcpTools).toHaveBeenCalledOnce();
expect(gateway.revoke).toHaveBeenCalledExactlyOnceWith(secret);
}
} finally {
await runtime.dispose();
}
});
it.each(["recovered", "exhausted", "cleanup-false", "cleanup-transport", "cleanup-timeout", "http-401", "http-503", "unknown"])(
"handles installed SDK fetch results: %s", async (scenario) => {
const setup = runClient([
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
]);
const token = "private-sdk-capability";
const cause = Object.assign(new Error(`socket reset\nAuthorization: Bearer ${token}\npassword=private-password\nhttps://user:private-password@example.com/private-path`), { code: "ECONNRESET" });
// A circular cause must not cause unbounded traversal or expose a stack.
Object.assign(cause, { cause });
const failure = new TypeError("fetch failed", { cause });
const order: string[] = [];
const signals: AbortSignal[] = [];
let adds = 0;
const sdk = createOpencodeClient({
baseUrl: "http://127.0.0.1:4096", throwOnError: false,
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init);
if (new URL(request.url).pathname.endsWith("/disconnect")) {
order.push("disconnect");
signals.push(request.signal);
if (scenario === "cleanup-transport") throw failure;
if (scenario === "cleanup-timeout") return new Promise<Response>(() => {});
return Response.json(scenario !== "cleanup-false");
}
order.push("add");
adds++;
if (scenario.startsWith("http-")) {
return Response.json({ name: "UnknownError", data: { message: `ECONNRESET password=private-password` } }, { status: Number(scenario.slice(5)) });
}
if (scenario === "unknown") throw new TypeError("fetch failed");
if (adds === 1 || scenario === "exhausted") throw failure;
const { name } = await request.json() as { name: string };
return Response.json({ [name]: { status: "connected" } });
},
});
Object.assign(setup.client, { mcp: sdk.mcp });
const add = vi.spyOn(sdk.mcp, "add");
const observeFailure = vi.fn<DesktopDiagnosticFailureObserver>();
const runtime = embeddedRuntime(setup.client, {
observeFailure,
knowledgeGateway: {
getEndpoint: () => "http://127.0.0.1:4567/mcp",
getAvailableToolNames: () => ["knowledge_search"],
} as unknown as KnowledgeMcpGateway,
});
const collect = async () => {
for await (const event of runtime.run({
requestId: "sdk-request", conversationId: "sdk-conversation", prompt: "test", workMode: "ask",
knowledgeCapabilityToken: token,
}, new AbortController().signal)) void event;
};
try {
if (scenario === "recovered") {
await collect();
expect(setup.session.promptAsync).toHaveBeenCalledOnce();
} else {
const error = await collect().catch((error: unknown) => error) as Error;
expect(error).toBeInstanceOf(Error);
expect(error.message).toContain(scenario === "unknown" ? "fetch failed" : "ECONNRESET");
expect(error.message).not.toMatch(/private-|example\.com|socket reset.*at /);
expect(error.message.length).toBeLessThan(2_100);
expect(setup.session.promptAsync).not.toHaveBeenCalled();
}
const first = await add.mock.results[0]!.value;
if (scenario.startsWith("http-")) {
expect(first.response.status).toBe(Number(scenario.slice(5)));
expect(observeFailure.mock.calls[0]![0].mcp).toMatchObject({ category: "http-rejection", status: Number(scenario.slice(5)) });
} else {
expect(first.response).toBeUndefined();
expect(first.error).toBeInstanceOf(TypeError);
if (scenario !== "unknown") expect(first.error).toBe(failure);
expect(observeFailure.mock.calls[0]![0].mcp).toMatchObject({ category: "transport-failure" });
}
expect(add).toHaveBeenCalledTimes(scenario === "recovered" || scenario === "exhausted" ? 2 : 1);
expect(order).toEqual(scenario === "recovered" || scenario === "exhausted"
? ["add", "disconnect", "add", "disconnect"]
: scenario.startsWith("cleanup-") ? ["add", "disconnect", "disconnect"] : ["add", "disconnect"]);
if (scenario === "cleanup-timeout") expect(signals.every(signal => signal.aborted)).toBe(true);
const records = observeFailure.mock.calls.map(([record]) => record);
expect(JSON.stringify(records)).not.toMatch(/private-|ECONNRESET|socket reset|example\.com/);
expect(records.every(record => record.mcp?.correlationId === `sha256:${createHash("sha256").update(token).digest("hex")}`)).toBe(true);
} finally {
await runtime.dispose();
}
},
);
it("persists a recovered MCP attempt even when the observer throws", async () => {
const directory = await mkdtemp(join(tmpdir(), "goodbuddy-mcp-diagnostics-"));
const diagnostics = new DesktopDiagnostics(directory);
const setup = runClient([
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
]);
const token = "private-correlation-token";
vi.mocked(setup.client.mcp.add).mockImplementationOnce((async (input: { name: string }) => ({
data: { [input.name]: { status: "failed", error: `connect ECONNRESET Authorization: Bearer ${token}` } },
})) as never);
const writes: Promise<void>[] = [];
const observer = vi.fn<DesktopDiagnosticFailureObserver>((failure) => {
writes.push(diagnostics.recordFailure(failure));
throw new Error("observer failed");
});
const runtime = embeddedRuntime(setup.client, {
observeFailure: observer,
knowledgeGateway: {
getEndpoint: () => "http://127.0.0.1:4567/mcp",
getAvailableToolNames: () => ["knowledge_search"],
} as unknown as KnowledgeMcpGateway,
});
try {
for await (const event of runtime.run({
requestId: "request-1", conversationId: "conversation-1", prompt: "test", workMode: "ask",
knowledgeCapabilityToken: token,
}, new AbortController().signal)) void event;
expect(setup.client.mcp.add).toHaveBeenCalledTimes(2);
expect(setup.session.promptAsync).toHaveBeenCalledOnce();
expect(observer).toHaveBeenCalledOnce();
await Promise.all(writes);
await diagnostics.dispose();
const restarted = new DesktopDiagnostics(directory);
try {
const records = await restarted.readRecent();
expect(records).toHaveLength(1);
expect(records[0]?.mcp).toMatchObject({
phase: "connect", attempt: 1, kind: "builtin",
correlationId: `sha256:${createHash("sha256").update(token).digest("hex")}`,
});
expect((await restarted.exportRecent()).toString()).not.toMatch(/private-|ECONNRESET|Authorization|127\.0\.0\.1/);
} finally { await restarted.dispose(); }
} finally {
await runtime.dispose();
await diagnostics.dispose();
await rm(directory, { recursive: true, force: true });
}
});
it.each(["reject", "error", "false", "timeout"])("does not retry or reuse the slot after MCP cleanup %s", async (failure) => {
const setup = runClient([
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
]);
const add = vi.mocked(setup.client.mcp.add);
add.mockImplementationOnce((async (input: { name: string }) => ({
data: { [input.name]: { status: "failed", error: "connect ECONNREFUSED" } },
})) as never);
const disconnect = vi.mocked(setup.client.mcp.disconnect);
if (failure === "reject") disconnect.mockRejectedValue(new Error("cleanup failed"));
if (failure === "error") disconnect.mockResolvedValue({ error: { message: "cleanup failed" } } as never);
if (failure === "false") disconnect.mockResolvedValue({ data: false } as never);
if (failure === "timeout") disconnect.mockImplementation((() => new Promise(() => {})) as never);
const observeFailure = vi.fn<DesktopDiagnosticFailureObserver>(() => { throw new Error("observer failed"); });
const runtime = embeddedRuntime(setup.client, {
observeFailure,
knowledgeGateway: {
getEndpoint: () => "http://127.0.0.1:4567/mcp",
getAvailableToolNames: () => ["knowledge_search"],
} as unknown as KnowledgeMcpGateway,
});
const collect = async (requestId: string) => {
for await (const event of runtime.run({
requestId, conversationId: "conversation-1", prompt: "test", workMode: "ask",
knowledgeCapabilityToken: requestId,
}, new AbortController().signal)) void event;
};
try {
await expect(collect("first")).rejects.toThrow("ECONNREFUSED");
expect(add).toHaveBeenCalledOnce();
expect(setup.session.promptAsync).not.toHaveBeenCalled();
expect(observeFailure).toHaveBeenCalledTimes(3);
expect(observeFailure.mock.calls.slice(1).map(([record]) => record.mcp)).toEqual([
expect.objectContaining({ phase: "disconnect", attempt: 1, kind: "builtin", category: failure === "false" ? "cleanup-failure" : "transport-failure" }),
expect.objectContaining({ phase: "disconnect", attempt: 1, kind: "builtin" }),
]);
expect(new Set(observeFailure.mock.calls.map(([record]) => record.mcp?.correlationId)).size).toBe(1);
disconnect.mockResolvedValue({ data: true } as never);
await collect("second");
expect(add.mock.calls[1]![0]?.name).toBe(`${add.mock.calls[0]![0]?.name}-2`);
} finally {
await runtime.dispose();
}
});
it.each(["add", "disconnect", "retry"])("respects cancellation during MCP %s", async (phase) => {
const setup = runClient([]);
const controller = new AbortController();
const add = vi.mocked(setup.client.mcp.add);
add.mockImplementation((async (input: { name: string }) => {
if (phase === "add" || (phase === "retry" && add.mock.calls.length === 2)) {
controller.abort(new Error("cancel initialization"));
}
return { data: { [input.name]: { status: "failed", error: "connect ECONNRESET" } } };
}) as never);
const disconnect = vi.mocked(setup.client.mcp.disconnect);
disconnect.mockImplementation((async () => {
if (phase === "disconnect") controller.abort(new Error("cancel initialization"));
return { data: true };
}) as never);
const runtime = embeddedRuntime(setup.client, {
knowledgeGateway: {
getEndpoint: () => "http://127.0.0.1:4567/mcp",
getAvailableToolNames: () => ["knowledge_search"],
} as unknown as KnowledgeMcpGateway,
});
try {
await expect((async () => {
for await (const event of runtime.run({
requestId: "request-1", conversationId: "conversation-1", prompt: "test", workMode: "ask",
knowledgeCapabilityToken: "private-token",
}, controller.signal)) void event;
})()).rejects.toThrow("cancel initialization");
expect(add).toHaveBeenCalledTimes(phase === "retry" ? 2 : 1);
expect(disconnect).toHaveBeenCalledTimes(phase === "retry" ? 2 : 1);
expect(setup.session.promptAsync).not.toHaveBeenCalled();
} finally {
await runtime.dispose();
}
});
it("does not retry MCP initialization or the prompt after a transient tool execution error", async () => {
const setup = runClient([
{
id: "tool-error", type: "message.part.updated",
properties: {
sessionID: "session-1",
part: {
id: "part-1", callID: "call-1", type: "tool", tool: "knowledge_search",
state: {
status: "error", input: {}, error: "connect ECONNRESET",
time: { start: 1, end: 2 },
},
},
},
},
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
]);
const runtime = embeddedRuntime(setup.client, {
knowledgeGateway: {
getEndpoint: () => "http://127.0.0.1:4567/mcp",
getAvailableToolNames: () => ["knowledge_search"],
} as unknown as KnowledgeMcpGateway,
});
try {
const events: RuntimeEvent[] = [];
await expect((async () => {
for await (const event of runtime.run({
requestId: "request-1", conversationId: "conversation-1", prompt: "test", workMode: "ask",
knowledgeCapabilityToken: "private-token",
}, new AbortController().signal)) events.push(event);
})()).rejects.toThrow("connect ECONNRESET");
expect(events).toContainEqual(expect.objectContaining({ type: "tool", state: "failed" }));
expect(setup.client.mcp.add).toHaveBeenCalledOnce();
expect(setup.client.mcp.disconnect).toHaveBeenCalledOnce();
expect(setup.session.promptAsync).toHaveBeenCalledOnce();
} finally {
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" } },
@@ -3400,7 +3766,7 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
await runtime.dispose();
});
it("runs embedded conversations concurrently while serializing MCP registrations", async () => {
it.each([false, true])("runs embedded conversations concurrently while serializing MCP registrations (retry=%s)", async (retry) => {
const setup = runClient([
{
id: "idle",
@@ -3437,7 +3803,9 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
await firstAdd;
return {
data: {
[input.name]: { status: "connected" },
[input.name]: retry
? { status: "failed", error: "connect ECONNRESET" }
: { status: "connected" },
},
error: undefined,
};
@@ -3500,7 +3868,7 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
} finally {
resolveFirstAdd();
}
await vi.waitFor(() => expect(mcpAdd).toHaveBeenCalledTimes(2));
await vi.waitFor(() => expect(mcpAdd).toHaveBeenCalledTimes(retry ? 3 : 2));
await Promise.all([first, second]);
expect(
mcpAdd.mock.calls.map(
@@ -3511,8 +3879,10 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
}
).config.headers.Authorization,
),
).toEqual(["Bearer first-token", "Bearer second-token"]);
expect(setup.client.mcp.disconnect).toHaveBeenCalledTimes(2);
).toEqual(retry
? ["Bearer first-token", "Bearer first-token", "Bearer second-token"]
: ["Bearer first-token", "Bearer second-token"]);
expect(setup.client.mcp.disconnect).toHaveBeenCalledTimes(retry ? 3 : 2);
await runtime.dispose();
});
+99 -89
View File
@@ -56,7 +56,8 @@ import {
runtimePrivacyEnvironment,
} from "./process-environment";
import type { LaunchEnvironmentProvider } from "../local-tool-environment/launch-environment-provider";
import { boundedToolDetail, safeToolErrorDetail } from "./approval-summary";
import type { DesktopDiagnosticFailureObserver, DesktopMcpDiagnosticMetadata } from "../desktop-diagnostics";
import { boundedToolDetail, redactSensitiveText, safeToolErrorDetail } from "./approval-summary";
import type {
ResolvedMcpServer,
RuntimeSkillPackage,
@@ -503,6 +504,7 @@ export type OpenCodeRuntimeDependencies = {
};
export type OpenCodeRuntimeOptions = {
observeFailure?: DesktopDiagnosticFailureObserver;
baseUrl?: string;
embedded: boolean;
binaryPath: string;
@@ -2239,7 +2241,8 @@ export class OpenCodeRuntime implements AgentRuntime {
let knowledgeToolIds: string[] = [];
let customMcpName: string | undefined;
let customMcpToken: string | undefined;
const attemptedMcpNames: string[] = [];
type McpAttempt = Pick<DesktopMcpDiagnosticMetadata, "correlationId" | "kind" | "attempt">;
const attemptedMcpNames = new Map<string, McpAttempt>();
const mcpConversationKey = temporaryMcpConversationKey(
request.conversationId,
);
@@ -2255,6 +2258,96 @@ export class OpenCodeRuntime implements AgentRuntime {
? this.options.knowledgeGateway?.bindImageTool(request.imageToolBinding, signal, request.knowledgeCapabilityToken)
: undefined;
const scopedCapabilityToken = imageCapabilityToken ?? request.knowledgeCapabilityToken;
const observeMcpFailure = (mcp: DesktopMcpDiagnosticMetadata): void => {
try {
this.options.observeFailure?.({
component: "runtime", stage: mcp.phase, code: "runtime.mcp.failed",
error: new Error("Local MCP operation failed"), mcp,
});
} catch {
// Diagnostics must not affect retry, cancellation, or cleanup.
}
};
const disconnectMcp = async (name: string, metadata: McpAttempt): Promise<boolean> => {
// Called under the mutation lock; queue wait does not consume this budget.
const cleanupSignal = AbortSignal.timeout(1_000);
const startedAt = performance.now();
let category: DesktopMcpDiagnosticMetadata["category"] = "transport-failure";
let status: number | undefined;
try {
const result = await awaitWithAbort(
client.mcp.disconnect({ name, directory }, { signal: cleanupSignal }),
cleanupSignal,
);
if (result.data === true && !result.error) return true;
status = result.response?.status;
category = result.error && !result.response ? "transport-failure"
: status && status >= 400 ? "http-rejection" : "cleanup-failure";
} catch {
// The uncertain connection keeps its slot until cleanup succeeds.
}
observeMcpFailure({ ...metadata, phase: "disconnect", category, status,
elapsedMs: Math.round(performance.now() - startedAt) });
return false;
};
const addMcp = (name: string, url: string, token: string, label: string) =>
this.mutateMcp(signal, async () => {
for (let attempt = 0; attempt < 2; attempt++) {
signal.throwIfAborted();
const startedAt = performance.now();
const metadata: McpAttempt = {
correlationId: `sha256:${createHash("sha256").update(token).digest("hex")}`,
kind: name.startsWith(KNOWLEDGE_MCP_PREFIX) ? "builtin" : "custom",
attempt: attempt === 0 ? 1 : 2,
};
let failure: unknown;
let retryableStatus = true;
let category: DesktopMcpDiagnosticMetadata["category"] = "transport-failure";
let httpStatus: number | undefined;
try {
const added = await this.controlRequest(label, (controlSignal) => {
attemptedMcpNames.set(name, metadata);
return client.mcp.add({
directory, name,
config: {
type: "remote", url, enabled: true,
headers: { Authorization: `Bearer ${token}` },
oauth: false,
},
}, { signal: controlSignal });
}, signal);
const status = added.data?.[name];
httpStatus = added.response?.status;
const httpRejected = httpStatus !== undefined && httpStatus >= 400;
if (!httpRejected && !added.error && status?.status === "connected") return;
// With throwOnError:false the SDK resolves fetch failures without a response.
const transportFailure = !!added.error && !added.response;
category = transportFailure ? "transport-failure"
: httpRejected ? "http-rejection" : "initialization-failure";
retryableStatus = transportFailure || (!httpRejected && !added.error && status?.status === "failed");
failure = added.error ?? (status && "error" in status
? `${status.status}: ${status.error}`
: status?.status ?? "unknown");
} catch (error) {
failure = error;
}
observeMcpFailure({ ...metadata, phase: "connect",
category: signal.aborted ? "cancelled" : category, status: httpStatus,
elapsedMs: Math.round(performance.now() - startedAt) });
signal.throwIfAborted();
const failureDetail = safeToolErrorDetail(failure) ?? "unknown";
const detail = redactSensitiveText(
failureDetail.split(token).join("[REDACTED]"),
).replace(/https?:\/\/[^\s<>"']+/giu, "[URL]");
const error = new Error(`OpenCode ${label}失败(${httpStatus && httpStatus >= 400 ? `HTTP ${httpStatus}: ` : ""}${detail})`);
// Only explicit transport failures during initialization may be retried.
const transient = /\b(?:ECONNREFUSED|ECONNRESET|ETIMEDOUT|EPIPE)\b|\b(?:connection closed|socket hang up|request timed out)\b/iu.test(detail)
&& !/\b(?:auth(?:entication|orization)?\s+(?:failed|required|error|denied)|oauth|credentials|unauthorized|forbidden|401|403|protocol|invalid|parse|json|method)\b/iu.test(failureDetail);
if (attempt > 0 || !retryableStatus || !transient) throw error;
if (!await disconnectMcp(name, metadata)) throw error;
attemptedMcpNames.delete(name);
}
});
try {
if (
scopedCapabilityToken &&
@@ -2263,42 +2356,7 @@ export class OpenCodeRuntime implements AgentRuntime {
) {
const knowledgeEndpoint = this.options.knowledgeGateway.getEndpoint()!;
knowledgeMcpName = temporaryMcpName(KNOWLEDGE_MCP_PREFIX);
const added = await this.mutateMcp(signal, () =>
this.controlRequest(
"连接内置只读工具",
(controlSignal) => {
attemptedMcpNames.push(knowledgeMcpName!);
return client.mcp.add(
{
directory,
name: knowledgeMcpName,
config: {
type: "remote",
url: knowledgeEndpoint,
enabled: true,
headers: {
Authorization: `Bearer ${scopedCapabilityToken}`,
},
oauth: false,
},
},
{ signal: controlSignal },
);
},
signal,
),
);
if (added.error || !added.data) {
throw new Error(
opencodeErrorMessage(added.error, "OpenCode 内置只读工具连接失败"),
);
}
const addedStatus = added.data[knowledgeMcpName];
if (!addedStatus || addedStatus.status !== "connected") {
throw new Error(
`OpenCode 内置只读工具连接失败(${addedStatus?.status ?? "unknown"})`,
);
}
await addMcp(knowledgeMcpName, knowledgeEndpoint, scopedCapabilityToken, "连接内置只读工具");
// OpenCode 1.18.x does not include dynamically added MCP tools in
// experimental/tool/ids. Its model tool namespace is deterministic:
// "<MCP server name>_<declared tool name>".
@@ -2325,42 +2383,7 @@ export class OpenCodeRuntime implements AgentRuntime {
signal,
);
customMcpName = temporaryMcpName(CUSTOM_MCP_PREFIX);
const added = await this.mutateMcp(signal, () =>
this.controlRequest(
"连接自定义 MCP 工具",
(controlSignal) => {
attemptedMcpNames.push(customMcpName!);
return client.mcp.add(
{
directory,
name: customMcpName,
config: {
type: "remote",
url: customMcpEndpoint,
enabled: true,
headers: {
Authorization: `Bearer ${customMcpToken}`,
},
oauth: false,
},
},
{ signal: controlSignal },
);
},
signal,
),
);
const addedStatus = added.data?.[customMcpName];
if (
added.error ||
!added.data ||
!addedStatus ||
addedStatus.status !== "connected"
) {
throw new Error(
`OpenCode 自定义 MCP 连接失败(${addedStatus?.status ?? "unknown"})`,
);
}
await addMcp(customMcpName, customMcpEndpoint, customMcpToken, "连接自定义 MCP 工具");
knowledgeToolIds.push(
...tools.map((tool) => `${customMcpName}_${tool.name}`),
);
@@ -3193,23 +3216,10 @@ export class OpenCodeRuntime implements AgentRuntime {
subscriptionController.abort();
if (imageCapabilityToken && imageCapabilityToken !== request.knowledgeCapabilityToken) this.options.knowledgeGateway?.revoke(imageCapabilityToken);
let allMcpDisconnected = true;
for (const name of attemptedMcpNames) {
for (const [name, metadata] of attemptedMcpNames) {
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,
() => disconnectMcp(name, metadata),
);
allMcpDisconnected &&= disconnected;
}