feat(mcp-envelope-integration): update from_mcp adapter to use mcpEnvelope, structuredContent, and outputSchema extraction

- Add mapMCPContentBlocks() helper mapping SDK ContentBlock[] to MCPContentBlock[]
- Extract tool.outputSchema via FromSchema() when present, fall back to Type.Unknown()
- Handler returns mcpEnvelope() with structured/legacy data path
- structuredContent preferred as data when present, Value.Cast() when outputSchema is known
- isError: true wrapped in envelope meta, NOT thrown
- Transport-level config errors throw CallError
- Unknown MCP content block types fall back to { type: 'text', text: JSON.stringify(block) }
- Add 20 tests for mapMCPContentBlocks and envelope detection
This commit is contained in:
2026-05-11 01:59:30 +00:00
parent 15a558bace
commit b0283aa662
2 changed files with 369 additions and 8 deletions

View File

@@ -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<OperationSpec & { handler: OperationHandler }>;
}
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<string, unknown>;
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<string, unknown> | 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<OperationSpec & { handler: OperationHandler }> = toolsResult.tools.map((tool: { name: string; description?: string; inputSchema: unknown }) => {
const operations: Array<OperationSpec & { handler: OperationHandler }> = 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<string, unknown>,
});
if (result.isError) {
throw new Error(`MCP tool error: ${JSON.stringify(result.content)}`);
const structuredContent = (result as any).structuredContent as Record<string, unknown> | 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<MCPResponseMeta, "source"> = {
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<string, unknown>;
}
return result.content;
return mcpEnvelope(data, meta);
},
} satisfies OperationSpec & { handler: OperationHandler };
});