|
| 1 | +import type { StartedServer } from '../../../node/server' |
| 2 | +import type { DevframeDefinition } from '../../../types/devframe' |
| 3 | +import { Client } from '@modelcontextprotocol/sdk/client/index.js' |
| 4 | +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' |
| 5 | +import { afterEach, describe, expect, it } from 'vitest' |
| 6 | +import { createDevServer } from '../../dev' |
| 7 | + |
| 8 | +function defineTestDef(overrides?: Partial<DevframeDefinition>): DevframeDefinition { |
| 9 | + return { |
| 10 | + id: 'mcp-http-test', |
| 11 | + name: 'MCP HTTP Test', |
| 12 | + version: '1.2.3', |
| 13 | + packageName: '@devframe/mcp-http-test', |
| 14 | + homepage: 'https://example.com', |
| 15 | + description: 'Test fixture for the route-based MCP server.', |
| 16 | + setup(ctx) { |
| 17 | + ctx.agent.registerTool({ |
| 18 | + id: 'greet', |
| 19 | + description: 'Say hello.', |
| 20 | + safety: 'read', |
| 21 | + handler: (args: { name?: string }) => ({ greeting: `hi ${args.name ?? 'there'}` }), |
| 22 | + }) |
| 23 | + }, |
| 24 | + ...overrides, |
| 25 | + } |
| 26 | +} |
| 27 | + |
| 28 | +describe('mcp adapter (streamable http route)', () => { |
| 29 | + let server: StartedServer | undefined |
| 30 | + |
| 31 | + afterEach(async () => { |
| 32 | + await server?.close() |
| 33 | + server = undefined |
| 34 | + }) |
| 35 | + |
| 36 | + async function boot(def = defineTestDef()): Promise<StartedServer> { |
| 37 | + server = await createDevServer(def, { host: '127.0.0.1', mcp: true }) |
| 38 | + return server |
| 39 | + } |
| 40 | + |
| 41 | + it('advertises the mcp endpoint in __connection.json', async () => { |
| 42 | + const started = await boot() |
| 43 | + const res = await fetch(`${started.origin}/__connection.json`) |
| 44 | + const meta = await res.json() as { backend: string, mcp?: { path: string } } |
| 45 | + expect(meta.backend).toBe('websocket') |
| 46 | + expect(meta.mcp).toEqual({ path: '__mcp' }) |
| 47 | + }) |
| 48 | + |
| 49 | + it('omits the mcp block when the route is disabled', async () => { |
| 50 | + server = await createDevServer(defineTestDef(), { host: '127.0.0.1', mcp: false }) |
| 51 | + const res = await fetch(`${server.origin}/__connection.json`) |
| 52 | + const meta = await res.json() as { mcp?: unknown } |
| 53 | + expect(meta.mcp).toBeUndefined() |
| 54 | + }) |
| 55 | + |
| 56 | + it('establishes a stateful session and lists agent tools', async () => { |
| 57 | + const started = await boot() |
| 58 | + const transport = new StreamableHTTPClientTransport(new URL(`${started.origin}/__mcp`)) |
| 59 | + const client = new Client({ name: 'test-client', version: '0.0.0' }) |
| 60 | + try { |
| 61 | + await client.connect(transport) |
| 62 | + // Stateful mode issues an Mcp-Session-Id on initialize. |
| 63 | + expect(transport.sessionId).toBeTypeOf('string') |
| 64 | + expect(transport.sessionId!.length).toBeGreaterThan(0) |
| 65 | + |
| 66 | + const tools = await client.listTools() |
| 67 | + expect(tools.tools.map(t => t.name)).toContain('greet') |
| 68 | + |
| 69 | + const result = await client.callTool({ name: 'greet', arguments: { name: 'devframe' } }) |
| 70 | + const content = result.content as Array<{ type: string, text: string }> |
| 71 | + expect(JSON.parse(content[0]!.text)).toEqual({ greeting: 'hi devframe' }) |
| 72 | + } |
| 73 | + finally { |
| 74 | + await client.close() |
| 75 | + } |
| 76 | + }) |
| 77 | + |
| 78 | + it('tears the session down on DELETE and rejects reuse of the id', async () => { |
| 79 | + const started = await boot() |
| 80 | + const url = `${started.origin}/__mcp` |
| 81 | + |
| 82 | + // Initialize over raw HTTP to capture the issued session id from the |
| 83 | + // response header (the body is an SSE stream we can discard). |
| 84 | + const init = await fetch(url, { |
| 85 | + method: 'POST', |
| 86 | + headers: { |
| 87 | + 'content-type': 'application/json', |
| 88 | + 'accept': 'application/json, text/event-stream', |
| 89 | + }, |
| 90 | + body: JSON.stringify({ |
| 91 | + jsonrpc: '2.0', |
| 92 | + id: 1, |
| 93 | + method: 'initialize', |
| 94 | + params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'x', version: '0' } }, |
| 95 | + }), |
| 96 | + }) |
| 97 | + const sessionId = init.headers.get('mcp-session-id') |
| 98 | + await init.body?.cancel() |
| 99 | + expect(sessionId).toBeTruthy() |
| 100 | + |
| 101 | + // DELETE ends the session. |
| 102 | + const del = await fetch(url, { |
| 103 | + method: 'DELETE', |
| 104 | + headers: { 'mcp-session-id': sessionId! }, |
| 105 | + }) |
| 106 | + await del.body?.cancel() |
| 107 | + expect(del.status).toBeLessThan(300) |
| 108 | + |
| 109 | + // Reusing the terminated id is no longer a known session — the server |
| 110 | + // answers 404 rather than falling through to the SPA static catch-all. |
| 111 | + const stale = await fetch(url, { |
| 112 | + method: 'POST', |
| 113 | + headers: { |
| 114 | + 'content-type': 'application/json', |
| 115 | + 'accept': 'application/json, text/event-stream', |
| 116 | + 'mcp-session-id': sessionId!, |
| 117 | + }, |
| 118 | + body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list' }), |
| 119 | + }) |
| 120 | + await stale.body?.cancel() |
| 121 | + expect(stale.status).toBe(404) |
| 122 | + }) |
| 123 | + |
| 124 | + it('rejects a disallowed cross-origin request', async () => { |
| 125 | + const started = await boot() |
| 126 | + const res = await fetch(`${started.origin}/__mcp`, { |
| 127 | + method: 'POST', |
| 128 | + headers: { |
| 129 | + 'content-type': 'application/json', |
| 130 | + 'accept': 'application/json, text/event-stream', |
| 131 | + 'origin': 'http://evil.example.com', |
| 132 | + }, |
| 133 | + body: JSON.stringify({ |
| 134 | + jsonrpc: '2.0', |
| 135 | + id: 1, |
| 136 | + method: 'initialize', |
| 137 | + params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'x', version: '0' } }, |
| 138 | + }), |
| 139 | + }) |
| 140 | + expect(res.status).toBe(403) |
| 141 | + }) |
| 142 | +}) |
0 commit comments