|
1 | | -import type { DevframeHost } from 'devframe/types' |
| 1 | +import type { StandardSchemaV1 } from '@standard-schema/spec' |
| 2 | +import type { DevframeHost, McpContentBlock } from 'devframe/types' |
2 | 3 | import { Client, InMemoryTransport } from '@modelcontextprotocol/client' |
| 4 | +import { withMcpContent } from 'devframe' |
3 | 5 | import { createHostContext } from 'devframe/node' |
4 | 6 | import * as v from 'valibot' |
5 | 7 | import { describe, expect, it } from 'vitest' |
6 | 8 | import { buildMcpServerFromContext } from '../build-server' |
7 | 9 |
|
| 10 | +/** A 1x1 PNG. */ |
| 11 | +const PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==' |
| 12 | + |
| 13 | +/** A Standard Schema that accepts any value and converts to `jsonSchema`. */ |
| 14 | +function objectSchema(jsonSchema: Record<string, unknown>): StandardSchemaV1 { |
| 15 | + return { |
| 16 | + '~standard': { |
| 17 | + version: 1, |
| 18 | + vendor: 'test', |
| 19 | + validate: (value: unknown) => ({ value }), |
| 20 | + jsonSchema: { input: () => jsonSchema, output: () => jsonSchema }, |
| 21 | + } as StandardSchemaV1['~standard'], |
| 22 | + } |
| 23 | +} |
| 24 | + |
8 | 25 | function nullHost(): DevframeHost { |
9 | 26 | return { |
10 | 27 | mountStatic: () => { /* no-op */ }, |
@@ -187,6 +204,103 @@ describe('mcp adapter (in-memory)', () => { |
187 | 204 | } |
188 | 205 | }) |
189 | 206 |
|
| 207 | + it('sends the content blocks attached with withMcpContent in place of the JSON text', async () => { |
| 208 | + const { ctx, client, cleanup } = await bootPair() |
| 209 | + try { |
| 210 | + ctx.agent.registerTool({ |
| 211 | + id: 'snapshot', |
| 212 | + description: 'Take a snapshot.', |
| 213 | + handler: () => withMcpContent({ markdown: 'A red pixel.' }, [ |
| 214 | + { type: 'text', text: 'A red pixel.' }, |
| 215 | + { type: 'image', data: PNG_BASE64, mimeType: 'image/png' }, |
| 216 | + { type: 'audio', data: 'AAAA', mimeType: 'audio/wav' }, |
| 217 | + ]), |
| 218 | + }) |
| 219 | + |
| 220 | + const result = await client.callTool({ name: 'snapshot', arguments: {} }) |
| 221 | + expect(result.isError).toBeFalsy() |
| 222 | + expect(result.content).toEqual([ |
| 223 | + { type: 'text', text: 'A red pixel.' }, |
| 224 | + { type: 'image', data: PNG_BASE64, mimeType: 'image/png' }, |
| 225 | + { type: 'audio', data: 'AAAA', mimeType: 'audio/wav' }, |
| 226 | + ]) |
| 227 | + expect(result.structuredContent).toBeUndefined() |
| 228 | + } |
| 229 | + finally { |
| 230 | + await cleanup() |
| 231 | + } |
| 232 | + }) |
| 233 | + |
| 234 | + it('keeps the plain result as structured content for an rpc-backed tool with an output schema', async () => { |
| 235 | + const { ctx, client, cleanup } = await bootPair() |
| 236 | + try { |
| 237 | + ctx.rpc.register({ |
| 238 | + name: 'snapshot', |
| 239 | + type: 'query', |
| 240 | + jsonSerializable: true, |
| 241 | + args: [], |
| 242 | + returns: objectSchema({ type: 'object', properties: { markdown: { type: 'string' } }, required: ['markdown'] }), |
| 243 | + agent: { description: 'Take a snapshot.' }, |
| 244 | + handler: () => withMcpContent({ markdown: 'A red pixel.' }, [ |
| 245 | + { type: 'image', data: PNG_BASE64, mimeType: 'image/png' }, |
| 246 | + ]), |
| 247 | + } as never) |
| 248 | + |
| 249 | + const listed = await client.listTools() |
| 250 | + const tool = listed.tools.find(t => t.name.endsWith('snapshot'))! |
| 251 | + expect(tool.outputSchema).toBeDefined() |
| 252 | + |
| 253 | + const result = await client.callTool({ name: tool.name, arguments: {} }) |
| 254 | + expect(result.isError).toBeFalsy() |
| 255 | + expect(result.content).toEqual([{ type: 'image', data: PNG_BASE64, mimeType: 'image/png' }]) |
| 256 | + expect(result.structuredContent).toEqual({ markdown: 'A red pixel.' }) |
| 257 | + } |
| 258 | + finally { |
| 259 | + await cleanup() |
| 260 | + } |
| 261 | + }) |
| 262 | + |
| 263 | + it('returns DF0081 when the attached content blocks are invalid', async () => { |
| 264 | + const { ctx, client, cleanup } = await bootPair() |
| 265 | + try { |
| 266 | + ctx.agent.registerTool({ |
| 267 | + id: 'broken', |
| 268 | + description: 'Returns a bad block.', |
| 269 | + handler: () => withMcpContent({}, [{ type: 'image', data: PNG_BASE64 } as never]), |
| 270 | + }) |
| 271 | + |
| 272 | + const result = await client.callTool({ name: 'broken', arguments: {} }) |
| 273 | + expect(result.isError).toBe(true) |
| 274 | + const content = result.content as Array<{ type: string, text: string }> |
| 275 | + expect(content[0]!.text).toContain('DF0081') |
| 276 | + } |
| 277 | + finally { |
| 278 | + await cleanup() |
| 279 | + } |
| 280 | + }) |
| 281 | + |
| 282 | + it('returns DF0081 when the attached content blocks have holes', async () => { |
| 283 | + const { ctx, client, cleanup } = await bootPair() |
| 284 | + try { |
| 285 | + // eslint-disable-next-line unicorn/no-new-array |
| 286 | + const sparse = new Array(2) as McpContentBlock[] |
| 287 | + sparse[1] = { type: 'text', text: 'second' } |
| 288 | + ctx.agent.registerTool({ |
| 289 | + id: 'sparse', |
| 290 | + description: 'Returns a sparse block array.', |
| 291 | + handler: () => withMcpContent({}, sparse), |
| 292 | + }) |
| 293 | + |
| 294 | + const result = await client.callTool({ name: 'sparse', arguments: {} }) |
| 295 | + expect(result.isError).toBe(true) |
| 296 | + const content = result.content as Array<{ type: string, text: string }> |
| 297 | + expect(content[0]!.text).toContain('DF0081') |
| 298 | + } |
| 299 | + finally { |
| 300 | + await cleanup() |
| 301 | + } |
| 302 | + }) |
| 303 | + |
190 | 304 | it('calls an rpc-backed tool whose return schema has no native converter', async () => { |
191 | 305 | const { ctx, client, cleanup } = await bootPair() |
192 | 306 | try { |
|
0 commit comments