|
| 1 | +import type { StandardSchemaV1 } from '@standard-schema/spec' |
1 | 2 | import type { DevframeHost } 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 */ }, |
@@ -166,6 +183,81 @@ describe('mcp adapter (in-memory)', () => { |
166 | 183 | } |
167 | 184 | }) |
168 | 185 |
|
| 186 | + it('sends the content blocks attached with withMcpContent in place of the JSON text', async () => { |
| 187 | + const { ctx, client, cleanup } = await bootPair() |
| 188 | + try { |
| 189 | + ctx.agent.registerTool({ |
| 190 | + id: 'snapshot', |
| 191 | + description: 'Take a snapshot.', |
| 192 | + handler: () => withMcpContent({ markdown: 'A red pixel.' }, [ |
| 193 | + { type: 'text', text: 'A red pixel.' }, |
| 194 | + { type: 'image', data: PNG_BASE64, mimeType: 'image/png' }, |
| 195 | + { type: 'audio', data: 'AAAA', mimeType: 'audio/wav' }, |
| 196 | + ]), |
| 197 | + }) |
| 198 | + |
| 199 | + const result = await client.callTool({ name: 'snapshot', arguments: {} }) |
| 200 | + expect(result.isError).toBeFalsy() |
| 201 | + expect(result.content).toEqual([ |
| 202 | + { type: 'text', text: 'A red pixel.' }, |
| 203 | + { type: 'image', data: PNG_BASE64, mimeType: 'image/png' }, |
| 204 | + { type: 'audio', data: 'AAAA', mimeType: 'audio/wav' }, |
| 205 | + ]) |
| 206 | + expect(result.structuredContent).toBeUndefined() |
| 207 | + } |
| 208 | + finally { |
| 209 | + await cleanup() |
| 210 | + } |
| 211 | + }) |
| 212 | + |
| 213 | + it('keeps the plain result as structured content for an rpc-backed tool with an output schema', async () => { |
| 214 | + const { ctx, client, cleanup } = await bootPair() |
| 215 | + try { |
| 216 | + ctx.rpc.register({ |
| 217 | + name: 'snapshot', |
| 218 | + type: 'query', |
| 219 | + jsonSerializable: true, |
| 220 | + args: [], |
| 221 | + returns: objectSchema({ type: 'object', properties: { markdown: { type: 'string' } }, required: ['markdown'] }), |
| 222 | + agent: { description: 'Take a snapshot.' }, |
| 223 | + handler: () => withMcpContent({ markdown: 'A red pixel.' }, [ |
| 224 | + { type: 'image', data: PNG_BASE64, mimeType: 'image/png' }, |
| 225 | + ]), |
| 226 | + } as never) |
| 227 | + |
| 228 | + const listed = await client.listTools() |
| 229 | + const tool = listed.tools.find(t => t.name.endsWith('snapshot'))! |
| 230 | + expect(tool.outputSchema).toBeDefined() |
| 231 | + |
| 232 | + const result = await client.callTool({ name: tool.name, arguments: {} }) |
| 233 | + expect(result.isError).toBeFalsy() |
| 234 | + expect(result.content).toEqual([{ type: 'image', data: PNG_BASE64, mimeType: 'image/png' }]) |
| 235 | + expect(result.structuredContent).toEqual({ markdown: 'A red pixel.' }) |
| 236 | + } |
| 237 | + finally { |
| 238 | + await cleanup() |
| 239 | + } |
| 240 | + }) |
| 241 | + |
| 242 | + it('returns DF0081 when the attached content blocks are invalid', async () => { |
| 243 | + const { ctx, client, cleanup } = await bootPair() |
| 244 | + try { |
| 245 | + ctx.agent.registerTool({ |
| 246 | + id: 'broken', |
| 247 | + description: 'Returns a bad block.', |
| 248 | + handler: () => withMcpContent({}, [{ type: 'image', data: PNG_BASE64 } as never]), |
| 249 | + }) |
| 250 | + |
| 251 | + const result = await client.callTool({ name: 'broken', arguments: {} }) |
| 252 | + expect(result.isError).toBe(true) |
| 253 | + const content = result.content as Array<{ type: string, text: string }> |
| 254 | + expect(content[0]!.text).toContain('DF0081') |
| 255 | + } |
| 256 | + finally { |
| 257 | + await cleanup() |
| 258 | + } |
| 259 | + }) |
| 260 | + |
169 | 261 | it('calls an rpc-backed tool whose return schema has no native converter', async () => { |
170 | 262 | const { ctx, client, cleanup } = await bootPair() |
171 | 263 | try { |
|
0 commit comments