Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/content/1.guide/15.agent-native.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
33 changes: 33 additions & 0 deletions docs/content/6.errors/DF0081.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/content/6.errors/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
35 changes: 35 additions & 0 deletions packages/agentic/src/mcp/__tests__/mcp-http.test.ts
Original file line number Diff line number Diff line change
@@ -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>): DevframeDefinition {
return {
Expand Down Expand Up @@ -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
Expand Down
116 changes: 115 additions & 1 deletion packages/agentic/src/mcp/__tests__/mcp-server.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,27 @@
import type { DevframeHost } from 'devframe/types'
import type { StandardSchemaV1 } from '@standard-schema/spec'
import type { DevframeHost, McpContentBlock } 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<string, unknown>): 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 */ },
Expand Down Expand Up @@ -166,6 +183,103 @@ 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('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 {
Expand Down
38 changes: 30 additions & 8 deletions packages/agentic/src/mcp/build-server.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<string, unknown> } : {}),
}
}
Expand Down Expand Up @@ -368,6 +367,29 @@ 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' })
// `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<string, unknown>
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
Expand Down
27 changes: 27 additions & 0 deletions packages/devframe/src/agent/__tests__/mcp-content.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
42 changes: 42 additions & 0 deletions packages/devframe/src/agent/mcp-content.ts
Original file line number Diff line number Diff line change
@@ -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<T extends object>(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<symbol, unknown>)[MCP_CONTENT]
}
1 change: 1 addition & 0 deletions packages/devframe/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Loading
Loading