diff --git a/kun/src/adapters/tool/mcp-facade-provider.ts b/kun/src/adapters/tool/mcp-facade-provider.ts new file mode 100644 index 000000000..2fe328896 --- /dev/null +++ b/kun/src/adapters/tool/mcp-facade-provider.ts @@ -0,0 +1,228 @@ +import type { McpServerConfig } from '../../contracts/capabilities.js' +import type { ToolHostContext } from '../../ports/tool-host.js' +import type { CapabilityToolProvider } from './capability-registry.js' +import { LocalToolHost, type LocalTool } from './local-tool-host.js' +import { + canUseMcpServer, + isMcpServerTrusted, + isMcpServerVisible +} from './mcp-naming.js' +import type { McpClientLike } from './mcp-types.js' + +export type McpFacadeConnectionState = { + serverId: string + server: McpServerConfig + client: McpClientLike + status: 'connected' | 'reconnecting' | 'error' +} + +type FacadeCapability = + | 'listResources' + | 'readResource' + | 'listResourceTemplates' + | 'listPrompts' + | 'getPrompt' + +const MCP_FACADE_PROVIDER_ID = 'mcp:facade' + +export function createMcpFacadeProvider(connected: McpFacadeConnectionState[]): CapabilityToolProvider { + return { + id: MCP_FACADE_PROVIDER_ID, + kind: 'mcp', + enabled: true, + available: true, + tools: [ + createListResourcesTool(connected), + createReadResourceTool(connected), + createListResourceTemplatesTool(connected), + createListPromptsTool(connected), + createGetPromptTool(connected) + ] + } +} + +function createListResourcesTool(connected: McpFacadeConnectionState[]): LocalTool { + return LocalToolHost.defineTool({ + name: 'mcp_list_resources', + description: 'List MCP resources exposed by currently connected MCP servers.', + policy: 'auto', + inputSchema: { + type: 'object', + properties: { + serverId: { type: 'string' } + } + }, + shouldAdvertise: (context) => hasUsableServer(connected, context, 'listResources'), + execute: async (args, context) => { + const states = selectUsableServers(connected, context, 'listResources', stringArg(args.serverId)) + if (!states.length) return unavailableOutput('listResources') + const results = [] + for (const state of states) { + const listed = await state.client.listResources?.({ signal: context.abortSignal, timeout: state.server.timeoutMs }) + results.push({ serverId: state.serverId, resources: listed?.resources ?? [], nextCursor: listed?.nextCursor }) + } + return { output: { servers: results } } + } + }) +} + +function createReadResourceTool(connected: McpFacadeConnectionState[]): LocalTool { + return LocalToolHost.defineTool({ + name: 'mcp_read_resource', + description: 'Read one MCP resource from a connected MCP server.', + policy: 'auto', + inputSchema: { + type: 'object', + properties: { + serverId: { type: 'string' }, + uri: { type: 'string' } + }, + required: ['uri'] + }, + shouldAdvertise: (context) => hasUsableServer(connected, context, 'readResource'), + execute: async (args, context) => { + const uri = stringArg(args.uri) + if (!uri) return { output: { error: 'uri is required' }, isError: true } + const state = selectSingleUsableServer(connected, context, 'readResource', stringArg(args.serverId)) + if (!state) return unavailableOutput('readResource') + const result = await state.client.readResource?.({ uri }, { signal: context.abortSignal, timeout: state.server.timeoutMs }) + return { output: { serverId: state.serverId, uri, result } } + } + }) +} + +function createListResourceTemplatesTool(connected: McpFacadeConnectionState[]): LocalTool { + return LocalToolHost.defineTool({ + name: 'mcp_list_resource_templates', + description: 'List MCP resource templates exposed by currently connected MCP servers.', + policy: 'auto', + inputSchema: { + type: 'object', + properties: { + serverId: { type: 'string' } + } + }, + shouldAdvertise: (context) => hasUsableServer(connected, context, 'listResourceTemplates'), + execute: async (args, context) => { + const states = selectUsableServers(connected, context, 'listResourceTemplates', stringArg(args.serverId)) + if (!states.length) return unavailableOutput('listResourceTemplates') + const results = [] + for (const state of states) { + const listed = await state.client.listResourceTemplates?.({ signal: context.abortSignal, timeout: state.server.timeoutMs }) + results.push({ serverId: state.serverId, resourceTemplates: listed?.resourceTemplates ?? [], nextCursor: listed?.nextCursor }) + } + return { output: { servers: results } } + } + }) +} + +function createListPromptsTool(connected: McpFacadeConnectionState[]): LocalTool { + return LocalToolHost.defineTool({ + name: 'mcp_list_prompts', + description: 'List MCP prompts exposed by currently connected MCP servers.', + policy: 'auto', + inputSchema: { + type: 'object', + properties: { + serverId: { type: 'string' } + } + }, + shouldAdvertise: (context) => hasUsableServer(connected, context, 'listPrompts'), + execute: async (args, context) => { + const states = selectUsableServers(connected, context, 'listPrompts', stringArg(args.serverId)) + if (!states.length) return unavailableOutput('listPrompts') + const results = [] + for (const state of states) { + const listed = await state.client.listPrompts?.({ signal: context.abortSignal, timeout: state.server.timeoutMs }) + results.push({ serverId: state.serverId, prompts: listed?.prompts ?? [], nextCursor: listed?.nextCursor }) + } + return { output: { servers: results } } + } + }) +} + +function createGetPromptTool(connected: McpFacadeConnectionState[]): LocalTool { + return LocalToolHost.defineTool({ + name: 'mcp_get_prompt', + description: 'Get one MCP prompt from a connected MCP server.', + policy: 'auto', + inputSchema: { + type: 'object', + properties: { + serverId: { type: 'string' }, + name: { type: 'string' }, + arguments: { type: 'object', additionalProperties: true } + }, + required: ['name'] + }, + shouldAdvertise: (context) => hasUsableServer(connected, context, 'getPrompt'), + execute: async (args, context) => { + const name = stringArg(args.name) + if (!name) return { output: { error: 'name is required' }, isError: true } + const state = selectSingleUsableServer(connected, context, 'getPrompt', stringArg(args.serverId)) + if (!state) return unavailableOutput('getPrompt') + const result = await state.client.getPrompt?.( + { name, arguments: objectArg(args.arguments) }, + { signal: context.abortSignal, timeout: state.server.timeoutMs } + ) + return { output: { serverId: state.serverId, name, result } } + } + }) +} + +function hasUsableServer( + connected: McpFacadeConnectionState[], + context: ToolHostContext, + capability: FacadeCapability +): boolean { + return connected.some((state) => isUsableServer(state, context, capability)) +} + +function selectUsableServers( + connected: McpFacadeConnectionState[], + context: ToolHostContext, + capability: FacadeCapability, + serverId?: string +): McpFacadeConnectionState[] { + return connected.filter((state) => { + if (serverId && state.serverId !== serverId) return false + return isUsableServer(state, context, capability) + }) +} + +function selectSingleUsableServer( + connected: McpFacadeConnectionState[], + context: ToolHostContext, + capability: FacadeCapability, + serverId?: string +): McpFacadeConnectionState | null { + const states = selectUsableServers(connected, context, capability, serverId) + return states[0] ?? null +} + +function isUsableServer( + state: McpFacadeConnectionState, + context: ToolHostContext, + capability: FacadeCapability +): boolean { + return state.status === 'connected' && + typeof state.client[capability] === 'function' && + canUseMcpServer(state.server, context.workspace) && + isMcpServerVisible(state.server, context.workspace) && + isMcpServerTrusted(state.server, context.workspace) +} + +function unavailableOutput(capability: FacadeCapability): { output: { error: string }; isError: true } { + return { + output: { error: `No connected MCP server can use ${capability} in this workspace.` }, + isError: true + } +} + +function stringArg(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined +} + +function objectArg(value: unknown): Record | undefined { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : undefined +} diff --git a/kun/src/adapters/tool/mcp-transport.ts b/kun/src/adapters/tool/mcp-transport.ts index 628c22d88..1d55b12ae 100644 --- a/kun/src/adapters/tool/mcp-transport.ts +++ b/kun/src/adapters/tool/mcp-transport.ts @@ -24,6 +24,14 @@ type OAuthTransport = Transport & { finishAuth?: (authorizationCode: string) => Promise } +type SdkClientFacade = Client & { + listResources?: (params?: { cursor?: string }, options?: { signal?: AbortSignal; timeout?: number }) => Promise + readResource?: (input: { uri: string }, options?: { signal?: AbortSignal; timeout?: number }) => Promise + listResourceTemplates?: (params?: { cursor?: string }, options?: { signal?: AbortSignal; timeout?: number }) => Promise + listPrompts?: (params?: { cursor?: string }, options?: { signal?: AbortSignal; timeout?: number }) => Promise + getPrompt?: (input: { name: string; arguments?: Record }, options?: { signal?: AbortSignal; timeout?: number }) => Promise +} + export type SdkMcpClientOptions = { storageDir?: string openExternal?: (url: URL) => void | Promise @@ -48,6 +56,7 @@ export async function createSdkMcpClient( options: SdkMcpClientOptions = {} ): Promise { const client = new Client({ name: `kun-${serverId}`, version: '0.1.0' }) + const sdk = client as SdkClientFacade // Observe transport-level failures explicitly (#639). The SDK routes a // dropped SSE stream / exhausted reconnect to `onerror`; with no handler it // is silently swallowed and can escape as an unhandled rejection that @@ -106,6 +115,39 @@ export async function createSdkMcpClient( }) }, callTool: (input, callOptions) => client.callTool(input, undefined, callOptions), + ...(sdk.listResources ? { + listResources: async (listOptions) => { + const params = listOptions?.cursor ? { cursor: listOptions.cursor } : undefined + return await sdk.listResources?.(params, { + signal: listOptions?.signal, + timeout: listOptions?.timeout + }) as Awaited>> + } + } : {}), + ...(sdk.readResource ? { + readResource: async (input, callOptions) => await sdk.readResource?.(input, callOptions) as unknown + } : {}), + ...(sdk.listResourceTemplates ? { + listResourceTemplates: async (listOptions) => { + const params = listOptions?.cursor ? { cursor: listOptions.cursor } : undefined + return await sdk.listResourceTemplates?.(params, { + signal: listOptions?.signal, + timeout: listOptions?.timeout + }) as Awaited>> + } + } : {}), + ...(sdk.listPrompts ? { + listPrompts: async (listOptions) => { + const params = listOptions?.cursor ? { cursor: listOptions.cursor } : undefined + return await sdk.listPrompts?.(params, { + signal: listOptions?.signal, + timeout: listOptions?.timeout + }) as Awaited>> + } + } : {}), + ...(sdk.getPrompt ? { + getPrompt: async (input, callOptions) => await sdk.getPrompt?.(input, callOptions) as unknown + } : {}), close: () => client.close(), setLifecycleHandlers: (handlers) => { ;(client as { onerror?: (error: Error) => void }).onerror = handlers.onError diff --git a/kun/src/adapters/tool/mcp-types.ts b/kun/src/adapters/tool/mcp-types.ts index 013a81d74..a686d0cd5 100644 --- a/kun/src/adapters/tool/mcp-types.ts +++ b/kun/src/adapters/tool/mcp-types.ts @@ -18,6 +18,40 @@ export type McpToolDescriptor = { _meta?: Record } +export type McpResourceDescriptor = { + uri: string + name?: string + title?: string + description?: string + mimeType?: string + size?: number + annotations?: Record + _meta?: Record +} + +export type McpResourceTemplateDescriptor = { + uriTemplate: string + name?: string + title?: string + description?: string + mimeType?: string + annotations?: Record + _meta?: Record +} + +export type McpPromptDescriptor = { + name: string + title?: string + description?: string + arguments?: Array<{ + name: string + title?: string + description?: string + required?: boolean + }> + _meta?: Record +} + export type McpClientLifecycleHandlers = { onError?: (error: Error) => void onClose?: () => void @@ -33,6 +67,29 @@ export type McpClientLike = { input: { name: string; arguments: Record }, options?: { signal?: AbortSignal; timeout?: number } ): Promise + listResources?(options?: { + cursor?: string + signal?: AbortSignal + timeout?: number + }): Promise<{ resources: McpResourceDescriptor[]; nextCursor?: string }> + readResource?( + input: { uri: string }, + options?: { signal?: AbortSignal; timeout?: number } + ): Promise + listResourceTemplates?(options?: { + cursor?: string + signal?: AbortSignal + timeout?: number + }): Promise<{ resourceTemplates: McpResourceTemplateDescriptor[]; nextCursor?: string }> + listPrompts?(options?: { + cursor?: string + signal?: AbortSignal + timeout?: number + }): Promise<{ prompts: McpPromptDescriptor[]; nextCursor?: string }> + getPrompt?( + input: { name: string; arguments?: Record }, + options?: { signal?: AbortSignal; timeout?: number } + ): Promise close(): Promise setLifecycleHandlers?(handlers: McpClientLifecycleHandlers): void }