From 4d326c3bb7c0abecaa25c52d6eb194a2596e29ad Mon Sep 17 00:00:00 2001 From: erKam Date: Wed, 14 Oct 2026 08:25:02 +0000 Subject: [PATCH 1/2] feat(mcp): let agent tools return MCP content blocks The MCP adapter sent every tool result as one JSON text block, so a tool could not send an image. withMcpContent (from devframe) now attaches MCP content blocks (text, image or audio) to a tool result under a non-enumerable symbol key, and tools/call sends them in place of the JSON text block. The value itself does not change, so the RPC transport, returns validation and structuredContent keep the plain result. Invalid blocks give an isError result with DF0081. --- docs/content/1.guide/15.agent-native.md | 23 +++++ docs/content/6.errors/DF0081.md | 33 +++++++ docs/content/6.errors/index.md | 1 + .../src/mcp/__tests__/mcp-http.test.ts | 35 +++++++ .../src/mcp/__tests__/mcp-server.test.ts | 92 +++++++++++++++++++ packages/agentic/src/mcp/build-server.ts | 37 ++++++-- .../src/agent/__tests__/mcp-content.test.ts | 27 ++++++ packages/devframe/src/agent/mcp-content.ts | 42 +++++++++ packages/devframe/src/index.ts | 1 + packages/devframe/src/internal/index.ts | 3 + packages/devframe/src/node/diagnostics.ts | 5 + packages/devframe/src/types/mcp.ts | 10 ++ .../tsnapi/devframe/index.snapshot.d.ts | 13 +++ .../tsnapi/devframe/index.snapshot.js | 1 + .../tsnapi/devframe/internal.snapshot.d.ts | 8 ++ .../tsnapi/devframe/internal.snapshot.js | 1 + .../tsnapi/devframe/types.snapshot.d.ts | 1 + 17 files changed, 325 insertions(+), 8 deletions(-) create mode 100644 docs/content/6.errors/DF0081.md create mode 100644 packages/devframe/src/agent/__tests__/mcp-content.test.ts create mode 100644 packages/devframe/src/agent/mcp-content.ts diff --git a/docs/content/1.guide/15.agent-native.md b/docs/content/1.guide/15.agent-native.md index 6953bfd14..168a7506d 100644 --- a/docs/content/1.guide/15.agent-native.md +++ b/docs/content/1.guide/15.agent-native.md @@ -189,6 +189,29 @@ ctx.agent.registerTool({ }) ``` +## Returning images and other content + +The MCP adapter sends a tool result as one text block that holds the result as JSON. To send other MCP content, such as a screenshot next to its description, attach content blocks to the result with `withMcpContent`: + +```ts +import { defineRpcFunction, withMcpContent } from 'devframe' + +export const captureComponent = defineRpcFunction({ + name: 'my-plugin:capture-component', + type: 'query', + agent: { description: 'Take a screenshot of one component. Call it when the layout of the component matters.' }, + handler: async () => { + const { markdown, png } = await capture() + return withMcpContent({ markdown }, [ + { type: 'text', text: markdown }, + { type: 'image', data: png, mimeType: 'image/png' }, + ]) + }, +}) +``` + +The adapter sends the blocks in place of the JSON text block. A block is `text`, `image` or `audio`, and `data` is base64. The blocks are stored under a hidden key, so the returned value does not change: the SPA gets `{ markdown }` over RPC, without the image data. If the tool has an output schema, `structuredContent` is also that plain value. Invalid blocks make the call fail with [`DF0081`](/errors/DF0081). + ## Structured errors A coded diagnostic thrown from a handler crosses the MCP boundary as JSON: diff --git a/docs/content/6.errors/DF0081.md b/docs/content/6.errors/DF0081.md new file mode 100644 index 000000000..8b27e6401 --- /dev/null +++ b/docs/content/6.errors/DF0081.md @@ -0,0 +1,33 @@ +--- +title: 'DF0081: Invalid MCP Content Blocks' +description: 'Tool "{name}" returned invalid MCP content blocks: {reason}' +--- + +## Message + +> Tool "`{name}`" returned invalid MCP content blocks: `{reason}` + +## Cause + +A tool result carries content blocks from `withMcpContent`, but the blocks are not a non-empty array of valid blocks. A valid block is `{ type: 'text', text }`, `{ type: 'image', data, mimeType }` or `{ type: 'audio', data, mimeType }`, and each field is a string. The MCP adapter does not send invalid blocks. It returns this error as an `isError` result of the `tools/call` request. + +## Example + +```ts +import { withMcpContent } from 'devframe' + +ctx.agent.registerTool({ + id: 'my-plugin:snapshot', + description: 'Take a snapshot.', + // The image block has no `mimeType`. + handler: () => withMcpContent({}, [{ type: 'image', data: png }]), +}) +``` + +## Fix + +Give `withMcpContent` at least one block, and give each block all of its fields: `text` for a text block, base64 `data` and a `mimeType` (for example `image/png`) for an image or audio block. + +## Source + +- [`packages/agentic/src/mcp/build-server.ts`](https://github.com/devframes/devframe/blob/main/packages/agentic/src/mcp/build-server.ts): `mcpContentOf()` throws this when the content blocks of a `tools/call` result are invalid. diff --git a/docs/content/6.errors/index.md b/docs/content/6.errors/index.md index eee53bd30..684cce839 100644 --- a/docs/content/6.errors/index.md +++ b/docs/content/6.errors/index.md @@ -86,6 +86,7 @@ Emitted by `devframe`: the framework-neutral host, RPC, streaming, assets, servi | [DF0077](/errors/DF0077) | error | In-Page Channel Function Not Registered | | [DF0078](/errors/DF0078) | warn | Agent Surface Without @devframes/agentic | | [DF0079](/errors/DF0079) | error | MCP Enabled Without @devframes/agentic | +| [DF0081](/errors/DF0081) | error | Invalid MCP Content Blocks | ## Hub: context & lifecycle (DF80xx) diff --git a/packages/agentic/src/mcp/__tests__/mcp-http.test.ts b/packages/agentic/src/mcp/__tests__/mcp-http.test.ts index 709109b90..98b0622d3 100644 --- a/packages/agentic/src/mcp/__tests__/mcp-http.test.ts +++ b/packages/agentic/src/mcp/__tests__/mcp-http.test.ts @@ -1,10 +1,13 @@ import type { StartedServer } from 'devframe/internal' import type { DevframeDefinition, McpRouteOptions } from 'devframe/types' import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client' +import { withMcpContent } from 'devframe' import { createDevServer } from 'devframe/adapters/dev' import { afterEach, describe, expect, it } from 'vitest' const TOKEN = 'a-high-entropy-test-bearer-token' +/** A 1x1 PNG. */ +const PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==' function defineTestDef(overrides?: Partial): DevframeDefinition { return { @@ -95,6 +98,38 @@ describe('mcp adapter (streamable http route)', () => { } }) + it('sends the content blocks attached with withMcpContent', async () => { + const started = await boot(true, defineTestDef({ + setup(ctx) { + ctx.agent.registerTool({ + id: 'snapshot', + description: 'Take a snapshot.', + safety: 'read', + handler: () => withMcpContent({ markdown: 'A red pixel.' }, [ + { type: 'text', text: 'A red pixel.' }, + { type: 'image', data: PNG_BASE64, mimeType: 'image/png' }, + ]), + }) + }, + })) + const client = new Client( + { name: 'test-client', version: '0.0.0' }, + { versionNegotiation: { mode: 'auto' } }, + ) + try { + await client.connect(originTransport(started)) + const result = await client.callTool({ name: 'snapshot', arguments: {} }) + expect(result.isError).toBeFalsy() + expect(result.content).toEqual([ + { type: 'text', text: 'A red pixel.' }, + { type: 'image', data: PNG_BASE64, mimeType: 'image/png' }, + ]) + } + finally { + await client.close() + } + }) + it('answers a bare GET with 405 (no session lifecycle)', async () => { const started = await boot() // Stateless serving has no session stream to open, so the SDK answers a diff --git a/packages/agentic/src/mcp/__tests__/mcp-server.test.ts b/packages/agentic/src/mcp/__tests__/mcp-server.test.ts index 21b7a9f0b..8142599c7 100644 --- a/packages/agentic/src/mcp/__tests__/mcp-server.test.ts +++ b/packages/agentic/src/mcp/__tests__/mcp-server.test.ts @@ -1,10 +1,27 @@ +import type { StandardSchemaV1 } from '@standard-schema/spec' import type { DevframeHost } from 'devframe/types' import { Client, InMemoryTransport } from '@modelcontextprotocol/client' +import { withMcpContent } from 'devframe' import { createHostContext } from 'devframe/node' import * as v from 'valibot' import { describe, expect, it } from 'vitest' import { buildMcpServerFromContext } from '../build-server' +/** A 1x1 PNG. */ +const PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==' + +/** A Standard Schema that accepts any value and converts to `jsonSchema`. */ +function objectSchema(jsonSchema: Record): StandardSchemaV1 { + return { + '~standard': { + version: 1, + vendor: 'test', + validate: (value: unknown) => ({ value }), + jsonSchema: { input: () => jsonSchema, output: () => jsonSchema }, + } as StandardSchemaV1['~standard'], + } +} + function nullHost(): DevframeHost { return { mountStatic: () => { /* no-op */ }, @@ -166,6 +183,81 @@ describe('mcp adapter (in-memory)', () => { } }) + it('sends the content blocks attached with withMcpContent in place of the JSON text', async () => { + const { ctx, client, cleanup } = await bootPair() + try { + ctx.agent.registerTool({ + id: 'snapshot', + description: 'Take a snapshot.', + handler: () => withMcpContent({ markdown: 'A red pixel.' }, [ + { type: 'text', text: 'A red pixel.' }, + { type: 'image', data: PNG_BASE64, mimeType: 'image/png' }, + { type: 'audio', data: 'AAAA', mimeType: 'audio/wav' }, + ]), + }) + + const result = await client.callTool({ name: 'snapshot', arguments: {} }) + expect(result.isError).toBeFalsy() + expect(result.content).toEqual([ + { type: 'text', text: 'A red pixel.' }, + { type: 'image', data: PNG_BASE64, mimeType: 'image/png' }, + { type: 'audio', data: 'AAAA', mimeType: 'audio/wav' }, + ]) + expect(result.structuredContent).toBeUndefined() + } + finally { + await cleanup() + } + }) + + it('keeps the plain result as structured content for an rpc-backed tool with an output schema', async () => { + const { ctx, client, cleanup } = await bootPair() + try { + ctx.rpc.register({ + name: 'snapshot', + type: 'query', + jsonSerializable: true, + args: [], + returns: objectSchema({ type: 'object', properties: { markdown: { type: 'string' } }, required: ['markdown'] }), + agent: { description: 'Take a snapshot.' }, + handler: () => withMcpContent({ markdown: 'A red pixel.' }, [ + { type: 'image', data: PNG_BASE64, mimeType: 'image/png' }, + ]), + } as never) + + const listed = await client.listTools() + const tool = listed.tools.find(t => t.name.endsWith('snapshot'))! + expect(tool.outputSchema).toBeDefined() + + const result = await client.callTool({ name: tool.name, arguments: {} }) + expect(result.isError).toBeFalsy() + expect(result.content).toEqual([{ type: 'image', data: PNG_BASE64, mimeType: 'image/png' }]) + expect(result.structuredContent).toEqual({ markdown: 'A red pixel.' }) + } + finally { + await cleanup() + } + }) + + it('returns DF0081 when the attached content blocks are invalid', async () => { + const { ctx, client, cleanup } = await bootPair() + try { + ctx.agent.registerTool({ + id: 'broken', + description: 'Returns a bad block.', + handler: () => withMcpContent({}, [{ type: 'image', data: PNG_BASE64 } as never]), + }) + + const result = await client.callTool({ name: 'broken', arguments: {} }) + expect(result.isError).toBe(true) + const content = result.content as Array<{ type: string, text: string }> + expect(content[0]!.text).toContain('DF0081') + } + finally { + await cleanup() + } + }) + it('calls an rpc-backed tool whose return schema has no native converter', async () => { const { ctx, client, cleanup } = await bootPair() try { diff --git a/packages/agentic/src/mcp/build-server.ts b/packages/agentic/src/mcp/build-server.ts index 0860f5da1..d0e756236 100644 --- a/packages/agentic/src/mcp/build-server.ts +++ b/packages/agentic/src/mcp/build-server.ts @@ -1,12 +1,12 @@ import type { Tool } from '@modelcontextprotocol/server' import type { StandardSchemaV1 } from '@standard-schema/spec' import type { RpcFunctionDefinitionAnyWithContext } from 'devframe/rpc' -import type { AgentTool, CreateMcpServerOptions, DevframeDefinition, DevframeHost, DevframeNodeContext, McpServerHandle } from 'devframe/types' +import type { AgentTool, CreateMcpServerOptions, DevframeDefinition, DevframeHost, DevframeNodeContext, McpContentBlock, McpServerHandle } from 'devframe/types' import { homedir } from 'node:os' import process from 'node:process' import { Server } from '@modelcontextprotocol/server' import { DEVFRAME_EVENTS } from 'devframe/constants' -import { argsToJsonSchema, diagnostics, formatMcpError, returnToJsonSchema, stringifyForMcp } from 'devframe/internal' +import { argsToJsonSchema, diagnostics, formatMcpError, getMcpContent, returnToJsonSchema, stringifyForMcp } from 'devframe/internal' import { createHostContext } from 'devframe/node' import { toAgentToolName } from 'devframe/utils/agent-tool-name' import { join } from 'pathe' @@ -273,13 +273,12 @@ function registerToolHandlers( ? usableOutputSchema(tool.outputSchema ?? computeOutputSchema(tool, ctx)) : undefined const result = await ctx.agent.invoke(tool?.id ?? name, args ?? {}) + // Blocks attached with `withMcpContent` replace the default JSON text + // block. `structuredContent` stays the plain result either way, so a + // tool with an output schema still matches it. + const content = mcpContentOf(name, result) ?? [{ type: 'text', text: stringifyForMcp(result) }] return { - content: [ - { - type: 'text', - text: stringifyForMcp(result), - }, - ], + content, ...(outputSchema ? { structuredContent: result as Record } : {}), } } @@ -368,6 +367,28 @@ function registerResourceHandlers( }) } +/** + * The content blocks a tool result carries through `withMcpContent`, checked + * and copied field by field, or `undefined` when it carries none. Invalid + * blocks throw DF0081, which the `tools/call` handler turns into an + * `isError` result. + */ +function mcpContentOf(name: string, result: unknown): McpContentBlock[] | undefined { + const blocks = getMcpContent(result) + if (blocks === undefined) + return undefined + if (!Array.isArray(blocks) || blocks.length === 0) + throw diagnostics.DF0081({ name, reason: 'expected a non-empty array' }) + return blocks.map((block: unknown, index): McpContentBlock => { + const b = (block ?? {}) as Record + if (b.type === 'text' && typeof b.text === 'string') + return { type: 'text', text: b.text } + if ((b.type === 'image' || b.type === 'audio') && typeof b.data === 'string' && typeof b.mimeType === 'string') + return { type: b.type, data: b.data, mimeType: b.mimeType } + throw diagnostics.DF0081({ name, reason: `block ${index} is not a valid text, image or audio block` }) + }) +} + /** * MCP constrains a tool's `outputSchema` to a JSON Schema of `type: * "object"`; clients (the SDK included) reject anything else. Non-object diff --git a/packages/devframe/src/agent/__tests__/mcp-content.test.ts b/packages/devframe/src/agent/__tests__/mcp-content.test.ts new file mode 100644 index 000000000..c219a7c20 --- /dev/null +++ b/packages/devframe/src/agent/__tests__/mcp-content.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { getMcpContent, withMcpContent } from '../mcp-content' + +describe('withMcpContent', () => { + const blocks = [ + { type: 'text', text: 'A red pixel.' }, + { type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' }, + ] as const + + it('attaches the blocks without changing the value', () => { + const value = { markdown: 'A red pixel.' } + const result = withMcpContent(value, blocks) + expect(result).toBe(value) + expect(getMcpContent(result)).toBe(blocks) + expect(Object.keys(result)).toEqual(['markdown']) + expect(JSON.stringify(result)).toBe('{"markdown":"A red pixel."}') + expect(structuredClone(result)).toEqual({ markdown: 'A red pixel.' }) + expect(getMcpContent(structuredClone(result))).toBeUndefined() + }) + + it('reads nothing from values without blocks', () => { + expect(getMcpContent({ markdown: 'x' })).toBeUndefined() + expect(getMcpContent('text')).toBeUndefined() + expect(getMcpContent(null)).toBeUndefined() + expect(getMcpContent(undefined)).toBeUndefined() + }) +}) diff --git a/packages/devframe/src/agent/mcp-content.ts b/packages/devframe/src/agent/mcp-content.ts new file mode 100644 index 000000000..b768300ca --- /dev/null +++ b/packages/devframe/src/agent/mcp-content.ts @@ -0,0 +1,42 @@ +import type { McpContentBlock } from '../types/mcp' + +/** + * Registry symbol for the content blocks attached by {@link withMcpContent}. + * `Symbol.for` keeps the key the same across duplicate copies of devframe. + */ +const MCP_CONTENT = Symbol.for('devframe:mcp-content') + +/** + * Attach MCP content blocks to a tool result, for example an image next to + * its text. The MCP adapter sends these blocks as the `content` of the + * `tools/call` result, in place of the default single JSON text block. + * + * The blocks are stored under a non-enumerable symbol key, so `value` itself + * does not change: the RPC transport to the SPA, `returns` validation, and the + * `structuredContent` of a tool with an output schema all see the plain value + * and never the (often large) block data. + * + * `value` must be an object that is not frozen. Returns `value`. + */ +export function withMcpContent(value: T, content: readonly McpContentBlock[]): T { + Object.defineProperty(value, MCP_CONTENT, { + value: content, + enumerable: false, + configurable: true, + writable: true, + }) + return value +} + +/** + * Read the blocks attached by {@link withMcpContent}, or `undefined` when the + * value carries none. The value is not validated here; the MCP adapter checks + * each block before it sends it. + * + * @internal + */ +export function getMcpContent(value: unknown): unknown { + if (value === null || typeof value !== 'object') + return undefined + return (value as Record)[MCP_CONTENT] +} diff --git a/packages/devframe/src/index.ts b/packages/devframe/src/index.ts index d3869beee..8248cf904 100644 --- a/packages/devframe/src/index.ts +++ b/packages/devframe/src/index.ts @@ -2,5 +2,6 @@ // `defineRpcFunction`) as values, plus every public type. `devframe/types` // remains available as the type-only subpath, but `devframe` is the canonical // import for both values and types. +export { withMcpContent } from './agent/mcp-content' export * from './define' export type * from './types' diff --git a/packages/devframe/src/internal/index.ts b/packages/devframe/src/internal/index.ts index c49ce87a7..b370be343 100644 --- a/packages/devframe/src/internal/index.ts +++ b/packages/devframe/src/internal/index.ts @@ -39,10 +39,13 @@ // `formatMcpError`: the pure agent-surface projections shared between the // browser WebMCP registration and `@devframes/agentic/mcp`, so the two // surfaces cannot drift. +// - `getMcpContent`: reads the content blocks that `withMcpContent` (from +// `devframe`) attaches to a tool result, for `@devframes/agentic/mcp`. // - `probeDevframeOrigin`: the registry's origin-candidate probe, // `@devframes/agentic/connect` reuses it for explicit `--port` probes. export { loadAutoMcpAdapter, normalizeBasePath, resolveBasePath, resolveMcpConfig } from '../adapters/_shared' export type { ResolvedMcpConfig } from '../adapters/_shared' +export { getMcpContent } from '../agent/mcp-content' export { formatMcpError, stringifyForMcp } from '../agent/stringify' export { argsToJsonSchema, returnToJsonSchema } from '../agent/to-json-schema' export { importAgenticMcp } from '../node/agentic' diff --git a/packages/devframe/src/node/diagnostics.ts b/packages/devframe/src/node/diagnostics.ts index 7a5b9a956..b05c58251 100644 --- a/packages/devframe/src/node/diagnostics.ts +++ b/packages/devframe/src/node/diagnostics.ts @@ -220,5 +220,10 @@ export const diagnostics = defineDiagnostics({ `The \`mcp\` option is enabled, but the optional peer "@devframes/agentic" could not be loaded: ${p.reason}`, fix: 'Install `@devframes/agentic` next to devframe (the MCP adapter and the MCP SDK live there), or remove the explicit `mcp` setting.', }, + DF0081: { + why: (p: { name: string, reason: string }) => + `Tool "${p.name}" returned invalid MCP content blocks: ${p.reason}`, + fix: 'Pass `withMcpContent` a non-empty array of `{ type: \'text\', text }`, `{ type: \'image\', data, mimeType }` or `{ type: \'audio\', data, mimeType }` blocks, with string fields and base64 `data`.', + }, }, }) diff --git a/packages/devframe/src/types/mcp.ts b/packages/devframe/src/types/mcp.ts index cdb9e003a..4b350563a 100644 --- a/packages/devframe/src/types/mcp.ts +++ b/packages/devframe/src/types/mcp.ts @@ -33,6 +33,16 @@ export interface CreateMcpServerOptions { onReady?: (info: { transport: 'stdio' }) => void } +/** + * One MCP content block in a tool result. Attach blocks to a tool's return + * value with `withMcpContent` (from `devframe`) to send them in place of the + * default JSON text block. `data` is base64. + */ +export type McpContentBlock + = | { type: 'text', text: string } + | { type: 'image', data: string, mimeType: string } + | { type: 'audio', data: string, mimeType: string } + export interface McpServerHandle { stop: () => Promise } diff --git a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts index f54ac7698..186f426ee 100644 --- a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts @@ -553,6 +553,18 @@ export type DevframeSnapshotRpcEntry = string | { export type DevframeSnapshotRpcInputs = readonly (readonly unknown[])[] | ((_: DevframeNodeContext) => readonly (readonly unknown[])[] | Promise); export type DevframeStorageScope = 'workspace' | 'project' | 'global'; export type McpAuthorization = string | ((_: Request) => boolean | Promise) | false; +export type McpContentBlock = { + type: 'text'; + text: string; +} | { + type: 'image'; + data: string; + mimeType: string; +} | { + type: 'audio'; + data: string; + mimeType: string; +}; export type McpSetting = boolean | 'auto' | McpRouteOptions; export type RemoteAssetsProvider = 'jsdelivr' | 'unpkg' | RemoteAssetsProviderCustom; export type RpcFunctionsHost = RpcFunctionsCollectorBase & { @@ -572,6 +584,7 @@ export type StaticAssetsSource = string | RemoteAssets; // #region Functions export declare function defineDevframe(_: DevframeDefinition): DevframeDefinition; +export declare function withMcpContent(_: T, _: readonly McpContentBlock[]): T; // #endregion // #region Variables diff --git a/tests/__snapshots__/tsnapi/devframe/index.snapshot.js b/tests/__snapshots__/tsnapi/devframe/index.snapshot.js index e681572ed..a936de496 100644 --- a/tests/__snapshots__/tsnapi/devframe/index.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/index.snapshot.js @@ -3,6 +3,7 @@ */ // #region Functions export function defineDevframe(_) {} +export function withMcpContent(_, _) {} // #endregion // #region Variables diff --git a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts index 4c08e1e91..eadd1d991 100644 --- a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts @@ -371,6 +371,13 @@ export declare const diagnostics: import("nostics").Diagnostics<{ }) => string; readonly fix: "Install `@devframes/agentic` next to devframe (the MCP adapter and the MCP SDK live there), or remove the explicit `mcp` setting."; }; + readonly DF0081: { + readonly why: (p: { + name: string; + reason: string; + }) => string; + readonly fix: "Pass `withMcpContent` a non-empty array of `{ type: 'text', text }`, `{ type: 'image', data, mimeType }` or `{ type: 'audio', data, mimeType }` blocks, with string fields and base64 `data`."; + }; }, readonly [(d: import("nostics").Diagnostic, { method }?: { method?: "log" | "warn" | "error"; }) => void], never>; @@ -385,6 +392,7 @@ export { createInstanceShell } export { CreateInstanceShellOptions } export { DevframeInstanceRecord } export { DevframeInstanceRegistration } +export { getMcpContent } export { importAgenticMcp } export { InstanceRegisterConfig } export { InstanceShell } diff --git a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.js b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.js index 30cfdb0d0..d5d4cefa2 100644 --- a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.js @@ -15,6 +15,7 @@ export { createInstanceShell } export { createRpcWireCodec } export { DevframeAgentHost } export { diagnostics } +export { getMcpContent } export { importAgenticMcp } export { importRuntimeModule } export { listLiveDevframeInstances } diff --git a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts index 2604d37fd..23f4eea77 100644 --- a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts @@ -69,6 +69,7 @@ export { EventsMap } export { EventUnsubscribe } export { McpAuthorization } export { McpConnectionInfo } +export { McpContentBlock } export { McpFetchHandler } export { McpRouteOptions } export { McpServerHandle } From 7221f04bc83aa7982ba8d6535fe37ed885af1a1a Mon Sep 17 00:00:00 2001 From: erKam Date: Thu, 15 Oct 2026 07:18:38 +0000 Subject: [PATCH 2/2] fix(mcp): check the holes of a sparse content block array Array.prototype.map skips the holes of a sparse array, so a hole passed the DF0081 check and reached the MCP client as null. Array.from visits each hole as undefined, so it now fails the check. --- .../src/mcp/__tests__/mcp-server.test.ts | 24 ++++++++++++++++++- packages/agentic/src/mcp/build-server.ts | 3 ++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/agentic/src/mcp/__tests__/mcp-server.test.ts b/packages/agentic/src/mcp/__tests__/mcp-server.test.ts index 8142599c7..93b88743b 100644 --- a/packages/agentic/src/mcp/__tests__/mcp-server.test.ts +++ b/packages/agentic/src/mcp/__tests__/mcp-server.test.ts @@ -1,5 +1,5 @@ import type { StandardSchemaV1 } from '@standard-schema/spec' -import type { DevframeHost } from 'devframe/types' +import type { DevframeHost, McpContentBlock } from 'devframe/types' import { Client, InMemoryTransport } from '@modelcontextprotocol/client' import { withMcpContent } from 'devframe' import { createHostContext } from 'devframe/node' @@ -258,6 +258,28 @@ describe('mcp adapter (in-memory)', () => { } }) + it('returns DF0081 when the attached content blocks have holes', async () => { + const { ctx, client, cleanup } = await bootPair() + try { + // eslint-disable-next-line unicorn/no-new-array + const sparse = new Array(2) as McpContentBlock[] + sparse[1] = { type: 'text', text: 'second' } + ctx.agent.registerTool({ + id: 'sparse', + description: 'Returns a sparse block array.', + handler: () => withMcpContent({}, sparse), + }) + + const result = await client.callTool({ name: 'sparse', arguments: {} }) + expect(result.isError).toBe(true) + const content = result.content as Array<{ type: string, text: string }> + expect(content[0]!.text).toContain('DF0081') + } + finally { + await cleanup() + } + }) + it('calls an rpc-backed tool whose return schema has no native converter', async () => { const { ctx, client, cleanup } = await bootPair() try { diff --git a/packages/agentic/src/mcp/build-server.ts b/packages/agentic/src/mcp/build-server.ts index d0e756236..382fdef0c 100644 --- a/packages/agentic/src/mcp/build-server.ts +++ b/packages/agentic/src/mcp/build-server.ts @@ -379,7 +379,8 @@ function mcpContentOf(name: string, result: unknown): McpContentBlock[] | undefi return undefined if (!Array.isArray(blocks) || blocks.length === 0) throw diagnostics.DF0081({ name, reason: 'expected a non-empty array' }) - return blocks.map((block: unknown, index): McpContentBlock => { + // `Array.from` visits the holes of a sparse array as `undefined`, so they fail the check below. + return Array.from(blocks, (block: unknown, index): McpContentBlock => { const b = (block ?? {}) as Record if (b.type === 'text' && typeof b.text === 'string') return { type: 'text', text: b.text }