diff --git a/src/from_mcp.ts b/src/from_mcp.ts index 5117070..04c06c0 100644 --- a/src/from_mcp.ts +++ b/src/from_mcp.ts @@ -1,7 +1,10 @@ -import type { OperationSpec, OperationHandler, OperationContext } from "./types.js"; +import type { OperationSpec, OperationHandler } from "./types.js"; import { OperationType } from "./types.js"; -import { Type, type TSchema } from "@alkdev/typebox"; +import { Kind, Type, type TSchema } from "@alkdev/typebox"; +import { Value } from "@alkdev/typebox/value"; import { FromSchema } from "./from_schema.js"; +import { mcpEnvelope, type MCPContentBlock, type MCPAnnotations, type MCPResourceContent, type MCPResponseMeta } from "./response-envelope.js"; +import { CallError, InfrastructureErrorCode } from "./error.js"; import { getLogger } from "@logtape/logtape"; const logger = getLogger("operations:mcp"); @@ -21,6 +24,61 @@ export interface MCPClientWrapper { tools: Array; } +export function mapMCPContentBlocks(sdkBlocks: unknown[]): MCPContentBlock[] { + return sdkBlocks.map((block: unknown): MCPContentBlock => { + if (typeof block !== "object" || block === null) { + return { type: "text", text: JSON.stringify(block) }; + } + const b = block as Record; + switch (b.type) { + case "text": + return { + type: "text", + text: typeof b.text === "string" ? b.text : String(b.text ?? ""), + ...(b.annotations != null ? { annotations: b.annotations as MCPAnnotations } : {}), + }; + case "image": + return { + type: "image", + data: typeof b.data === "string" ? b.data : String(b.data ?? ""), + mimeType: typeof b.mimeType === "string" ? b.mimeType : "application/octet-stream", + ...(b.annotations != null ? { annotations: b.annotations as MCPAnnotations } : {}), + }; + case "audio": + return { + type: "audio", + data: typeof b.data === "string" ? b.data : String(b.data ?? ""), + mimeType: typeof b.mimeType === "string" ? b.mimeType : "audio/octet-stream", + ...(b.annotations != null ? { annotations: b.annotations as MCPAnnotations } : {}), + }; + case "resource": { + const resource = b.resource as Record | undefined; + const mappedResource: MCPResourceContent = { + uri: typeof resource?.uri === "string" ? resource.uri : "", + ...(resource?.mimeType != null ? { mimeType: String(resource.mimeType) } : {}), + ...(resource?.text != null ? { text: String(resource.text) } : {}), + ...(resource?.blob != null ? { blob: String(resource.blob) } : {}), + }; + return { + type: "resource", + resource: mappedResource, + ...(b.annotations != null ? { annotations: b.annotations as MCPAnnotations } : {}), + }; + } + case "resource_link": + return { + type: "resource_link", + uri: typeof b.uri === "string" ? b.uri : String(b.uri ?? ""), + name: typeof b.name === "string" ? b.name : String(b.name ?? ""), + ...(b.description != null ? { description: String(b.description) } : {}), + ...(b.mimeType != null ? { mimeType: String(b.mimeType) } : {}), + }; + default: + return { type: "text", text: JSON.stringify(block) }; + } + }); +} + export async function createMCPClient( name: string, config: MCPClientConfig, @@ -47,14 +105,18 @@ export async function createMCPClient( cwd: config.cwd, }); } else { - throw new Error(`Invalid MCP server config for ${name}: must have either 'url' or 'command'`); + throw new CallError(InfrastructureErrorCode.EXECUTION_ERROR, `Invalid MCP server config for ${name}: must have either 'url' or 'command'`); } await client.connect(transport); logger.info(`Connected to MCP server: ${name}`); const toolsResult = await client.listTools(); - const operations: Array = toolsResult.tools.map((tool: { name: string; description?: string; inputSchema: unknown }) => { + const operations: Array = toolsResult.tools.map((tool: { name: string; description?: string; inputSchema: unknown; outputSchema?: unknown }) => { + const outputSchema: TSchema = tool.outputSchema + ? FromSchema(tool.outputSchema) as TSchema + : Type.Unknown(); + return { name: tool.name, namespace: name, @@ -63,7 +125,7 @@ export async function createMCPClient( description: tool.description || "", tags: [], inputSchema: FromSchema(tool.inputSchema) as TSchema, - outputSchema: Type.Unknown(), + outputSchema, accessControl: { requiredScopes: [] }, handler: async (input: unknown) => { logger.debug(`Calling MCP tool: ${name}.${tool.name}`); @@ -72,11 +134,29 @@ export async function createMCPClient( arguments: input as Record, }); - if (result.isError) { - throw new Error(`MCP tool error: ${JSON.stringify(result.content)}`); + const structuredContent = (result as any).structuredContent as Record | undefined; + const contentBlocks = Array.isArray(result.content) ? result.content : []; + + const isUnknownOutputSchema = outputSchema[Kind] === "Unknown" || (typeof outputSchema === "object" && Object.keys(outputSchema).filter(k => typeof k === "string").length === 0); + + const data = structuredContent + ? (!isUnknownOutputSchema + ? Value.Cast(outputSchema, structuredContent) + : structuredContent) + : mapMCPContentBlocks(contentBlocks); + + const meta: Omit = { + isError: Boolean(result.isError), + content: mapMCPContentBlocks(contentBlocks), + }; + if (structuredContent != null) { + meta.structuredContent = structuredContent; + } + if ((result as any)._meta != null) { + meta._meta = (result as any)._meta as Record; } - return result.content; + return mcpEnvelope(data, meta); }, } satisfies OperationSpec & { handler: OperationHandler }; }); diff --git a/test/from_mcp.test.ts b/test/from_mcp.test.ts new file mode 100644 index 0000000..1ec4a12 --- /dev/null +++ b/test/from_mcp.test.ts @@ -0,0 +1,281 @@ +import { describe, it, expect } from "vitest"; +import { Type } from "@alkdev/typebox"; +import { Value } from "@alkdev/typebox/value"; +import { mapMCPContentBlocks } from "../src/from_mcp.js"; +import { isResponseEnvelope } from "../src/response-envelope.js"; +import type { MCPContentBlock } from "../src/response-envelope.js"; + +describe("mapMCPContentBlocks", () => { + it("maps text content blocks", () => { + const sdkBlocks = [ + { type: "text", text: "hello world" }, + ]; + const result = mapMCPContentBlocks(sdkBlocks); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ type: "text", text: "hello world" }); + }); + + it("maps text content blocks with annotations", () => { + const sdkBlocks = [ + { type: "text", text: "hello", annotations: { audience: ["user"], priority: 1 } }, + ]; + const result = mapMCPContentBlocks(sdkBlocks); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: "text", + text: "hello", + annotations: { audience: ["user"], priority: 1 }, + }); + }); + + it("maps image content blocks", () => { + const sdkBlocks = [ + { type: "image", data: "base64data", mimeType: "image/png" }, + ]; + const result = mapMCPContentBlocks(sdkBlocks); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: "image", + data: "base64data", + mimeType: "image/png", + }); + }); + + it("maps audio content blocks", () => { + const sdkBlocks = [ + { type: "audio", data: "base64audio", mimeType: "audio/wav" }, + ]; + const result = mapMCPContentBlocks(sdkBlocks); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: "audio", + data: "base64audio", + mimeType: "audio/wav", + }); + }); + + it("maps resource content blocks", () => { + const sdkBlocks = [ + { + type: "resource", + resource: { + uri: "file:///test.txt", + mimeType: "text/plain", + text: "file content", + }, + }, + ]; + const result = mapMCPContentBlocks(sdkBlocks); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: "resource", + resource: { + uri: "file:///test.txt", + mimeType: "text/plain", + text: "file content", + }, + }); + }); + + it("maps resource_link content blocks", () => { + const sdkBlocks = [ + { + type: "resource_link", + uri: "file:///data.json", + name: "data", + description: "A data file", + mimeType: "application/json", + }, + ]; + const result = mapMCPContentBlocks(sdkBlocks); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: "resource_link", + uri: "file:///data.json", + name: "data", + description: "A data file", + mimeType: "application/json", + }); + }); + + it("maps resource_link content blocks without optional fields", () => { + const sdkBlocks = [ + { + type: "resource_link", + uri: "file:///data.json", + name: "data", + }, + ]; + const result = mapMCPContentBlocks(sdkBlocks); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: "resource_link", + uri: "file:///data.json", + name: "data", + }); + expect(result[0]).not.toHaveProperty("description"); + expect(result[0]).not.toHaveProperty("mimeType"); + }); + + it("falls back to text JSON.stringify for unknown block types", () => { + const sdkBlocks = [ + { type: "custom_type", foo: "bar", baz: 42 }, + ]; + const result = mapMCPContentBlocks(sdkBlocks); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: "text", + text: JSON.stringify({ type: "custom_type", foo: "bar", baz: 42 }), + }); + }); + + it("falls back to text JSON.stringify for non-object blocks", () => { + const result = mapMCPContentBlocks([null, 42, "hello", true]); + expect(result).toHaveLength(4); + expect(result[0]).toEqual({ type: "text", text: "null" }); + expect(result[1]).toEqual({ type: "text", text: "42" }); + expect(result[2]).toEqual({ type: "text", text: "\"hello\"" }); + expect(result[3]).toEqual({ type: "text", text: "true" }); + }); + + it("handles multiple mixed content blocks", () => { + const sdkBlocks = [ + { type: "text", text: "result" }, + { type: "image", data: "abc", mimeType: "image/png" }, + { type: "resource", resource: { uri: "f.txt", text: "hi" } }, + ]; + const result = mapMCPContentBlocks(sdkBlocks); + expect(result).toHaveLength(3); + expect(result[0].type).toBe("text"); + expect(result[1].type).toBe("image"); + expect(result[2].type).toBe("resource"); + }); + + it("handles empty array", () => { + const result = mapMCPContentBlocks([]); + expect(result).toEqual([]); + }); + + it("maps resource blocks with blob instead of text", () => { + const sdkBlocks = [ + { + type: "resource", + resource: { + uri: "file:///binary.bin", + mimeType: "application/octet-stream", + blob: "base64blob", + }, + }, + ]; + const result = mapMCPContentBlocks(sdkBlocks); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: "resource", + resource: { + uri: "file:///binary.bin", + mimeType: "application/octet-stream", + blob: "base64blob", + }, + }); + }); + + it("maps image content blocks with annotations", () => { + const sdkBlocks = [ + { type: "image", data: "base64data", mimeType: "image/png", annotations: { priority: 5 } }, + ]; + const result = mapMCPContentBlocks(sdkBlocks); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: "image", + data: "base64data", + mimeType: "image/png", + annotations: { priority: 5 }, + }); + }); + + it("maps audio content blocks with annotations", () => { + const sdkBlocks = [ + { type: "audio", data: "base64audio", mimeType: "audio/wav", annotations: { audience: ["assistant"] } }, + ]; + const result = mapMCPContentBlocks(sdkBlocks); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: "audio", + data: "base64audio", + mimeType: "audio/wav", + annotations: { audience: ["assistant"] }, + }); + }); + + it("maps resource content blocks with annotations", () => { + const sdkBlocks = [ + { + type: "resource", + resource: { uri: "file:///test.txt", text: "hi" }, + annotations: { priority: 1 }, + }, + ]; + const result = mapMCPContentBlocks(sdkBlocks); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: "resource", + resource: { uri: "file:///test.txt", text: "hi" }, + annotations: { priority: 1 }, + }); + }); +}); + +describe("MCP handler envelope wrapping (unit)", () => { + it("isResponseEnvelope detects mcpEnvelope results", () => { + const envelope = { + data: [{ type: "text" as const, text: "result" }], + meta: { + source: "mcp" as const, + isError: false, + content: [{ type: "text" as const, text: "result" }], + }, + }; + expect(isResponseEnvelope(envelope)).toBe(true); + }); + + it("isResponseEnvelope detects mcpEnvelope with isError: true", () => { + const envelope = { + data: [{ type: "text" as const, text: "error details" }], + meta: { + source: "mcp" as const, + isError: true, + content: [{ type: "text" as const, text: "error details" }], + }, + }; + expect(isResponseEnvelope(envelope)).toBe(true); + }); + + it("Value.Cast normalizes structuredContent against outputSchema", () => { + const outputSchema = Type.Object({ + name: Type.String(), + count: Type.Number(), + }); + + const structuredContent = { name: "test", count: 42, extra: "removed" }; + const cast = Value.Cast(outputSchema, structuredContent); + + expect(cast.name).toBe("test"); + expect(cast.count).toBe(42); + }); + + it("Value.Cast with Type.Unknown passes through any value", () => { + const unknownSchema = Type.Unknown(); + const data = { anything: "goes", nested: [1, 2, 3] }; + const cast = Value.Cast(unknownSchema, data); + expect(cast).toEqual(data); + }); + + it("isResponseEnvelope rejects non-envelope values", () => { + expect(isResponseEnvelope(null)).toBe(false); + expect(isResponseEnvelope(undefined)).toBe(false); + expect(isResponseEnvelope("string")).toBe(false); + expect(isResponseEnvelope({})).toBe(false); + expect(isResponseEnvelope({ data: "test" })).toBe(false); + expect(isResponseEnvelope({ meta: { source: "mcp" } })).toBe(false); + }); +}); \ No newline at end of file