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:
@@ -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 };
|
||||
});
|
||||
|
||||
281
test/from_mcp.test.ts
Normal file
281
test/from_mcp.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user