|
1 | | -import { describe, expect, mock, beforeEach } from "bun:test" |
| 1 | +import { describe, expect } from "bun:test" |
| 2 | +import { Server } from "@modelcontextprotocol/sdk/server/index.js" |
| 3 | +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js" |
| 4 | +import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" |
2 | 5 | import { LayerNode } from "@opencode-ai/core/effect/layer-node" |
3 | 6 | import { Effect } from "effect" |
4 | 7 | import { testEffect } from "../lib/effect" |
| 8 | +import { MCP } from "../../src/mcp/index" |
5 | 9 |
|
6 | | -// Track what options were passed to each transport constructor |
7 | | -const transportCalls: Array<{ |
8 | | - type: "streamable" | "sse" |
9 | | - url: string |
10 | | - options: { authProvider?: unknown; requestInit?: RequestInit } |
11 | | -}> = [] |
12 | | - |
13 | | -// Mock the transport constructors to capture their arguments |
14 | | -void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ |
15 | | - StreamableHTTPClientTransport: class MockStreamableHTTP { |
16 | | - constructor(url: URL, options?: { authProvider?: unknown; requestInit?: RequestInit }) { |
17 | | - transportCalls.push({ |
18 | | - type: "streamable", |
19 | | - url: url.toString(), |
20 | | - options: options ?? {}, |
21 | | - }) |
22 | | - } |
23 | | - async start() { |
24 | | - throw new Error("Mock transport cannot connect") |
25 | | - } |
26 | | - }, |
27 | | -})) |
| 10 | +const it = testEffect(LayerNode.compile(MCP.node)) |
28 | 11 |
|
29 | | -void mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({ |
30 | | - SSEClientTransport: class MockSSE { |
31 | | - constructor(url: URL, options?: { authProvider?: unknown; requestInit?: RequestInit }) { |
32 | | - transportCalls.push({ |
33 | | - type: "sse", |
34 | | - url: url.toString(), |
35 | | - options: options ?? {}, |
36 | | - }) |
| 12 | +const serve = Effect.acquireRelease( |
| 13 | + Effect.promise(async () => { |
| 14 | + const requests: Headers[] = [] |
| 15 | + const protocol = new Server({ name: "headers", version: "1.0.0" }, { capabilities: { tools: {} } }) |
| 16 | + protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] })) |
| 17 | + const transport = new WebStandardStreamableHTTPServerTransport({ |
| 18 | + sessionIdGenerator: () => crypto.randomUUID(), |
| 19 | + enableJsonResponse: true, |
| 20 | + }) |
| 21 | + await protocol.connect(transport) |
| 22 | + const http = Bun.serve({ |
| 23 | + port: 0, |
| 24 | + fetch(request) { |
| 25 | + requests.push(new Headers(request.headers)) |
| 26 | + return transport.handleRequest(request) |
| 27 | + }, |
| 28 | + }) |
| 29 | + return { |
| 30 | + requests, |
| 31 | + url: http.url.toString(), |
| 32 | + close: async () => { |
| 33 | + await http.stop(true) |
| 34 | + await protocol.close() |
| 35 | + }, |
37 | 36 | } |
38 | | - async start() { |
39 | | - throw new Error("Mock transport cannot connect") |
40 | | - } |
41 | | - }, |
42 | | -})) |
43 | | - |
44 | | -beforeEach(() => { |
45 | | - transportCalls.length = 0 |
46 | | -}) |
47 | | - |
48 | | -// Import MCP after mocking |
49 | | -const { MCP } = await import("../../src/mcp/index") |
50 | | -const it = testEffect(LayerNode.compile(MCP.node)) |
| 37 | + }), |
| 38 | + (server) => Effect.promise(server.close), |
| 39 | +) |
51 | 40 |
|
52 | 41 | describe("mcp.headers", () => { |
53 | 42 | it.instance("headers are passed to transports when oauth is enabled (default)", () => |
54 | 43 | Effect.gen(function* () { |
| 44 | + const server = yield* serve |
55 | 45 | const mcp = yield* MCP.Service |
56 | | - yield* mcp |
57 | | - .add("test-server", { |
58 | | - type: "remote", |
59 | | - url: "https://example.com/mcp", |
60 | | - headers: { |
61 | | - Authorization: "Bearer test-token", |
62 | | - "X-Custom-Header": "custom-value", |
63 | | - }, |
64 | | - }) |
65 | | - .pipe(Effect.catch(() => Effect.void)) |
66 | | - |
67 | | - // Both transports should have been created with headers |
68 | | - expect(transportCalls.length).toBeGreaterThanOrEqual(1) |
69 | | - |
70 | | - for (const call of transportCalls) { |
71 | | - expect(call.options.requestInit).toBeDefined() |
72 | | - expect(call.options.requestInit?.headers).toEqual({ |
| 46 | + const result = yield* mcp.add("test-server", { |
| 47 | + type: "remote", |
| 48 | + url: server.url, |
| 49 | + headers: { |
73 | 50 | Authorization: "Bearer test-token", |
74 | 51 | "X-Custom-Header": "custom-value", |
75 | | - }) |
76 | | - // OAuth should be enabled by default, so authProvider should exist |
77 | | - expect(call.options.authProvider).toBeDefined() |
| 52 | + }, |
| 53 | + }) |
| 54 | + |
| 55 | + expect(result.status).toMatchObject({ "test-server": { status: "connected" } }) |
| 56 | + expect(server.requests.length).toBeGreaterThan(0) |
| 57 | + for (const headers of server.requests) { |
| 58 | + expect(headers.get("authorization")).toBe("Bearer test-token") |
| 59 | + expect(headers.get("x-custom-header")).toBe("custom-value") |
78 | 60 | } |
79 | 61 | }), |
80 | 62 | ) |
81 | 63 |
|
82 | 64 | it.instance("headers are passed to transports when oauth is explicitly disabled", () => |
83 | 65 | Effect.gen(function* () { |
| 66 | + const server = yield* serve |
84 | 67 | const mcp = yield* MCP.Service |
85 | | - yield* mcp |
86 | | - .add("test-server-no-oauth", { |
87 | | - type: "remote", |
88 | | - url: "https://example.com/mcp", |
89 | | - oauth: false, |
90 | | - headers: { |
91 | | - Authorization: "Bearer test-token", |
92 | | - }, |
93 | | - }) |
94 | | - .pipe(Effect.catch(() => Effect.void)) |
95 | | - |
96 | | - expect(transportCalls.length).toBeGreaterThanOrEqual(1) |
97 | | - |
98 | | - for (const call of transportCalls) { |
99 | | - expect(call.options.requestInit).toBeDefined() |
100 | | - expect(call.options.requestInit?.headers).toEqual({ |
| 68 | + const result = yield* mcp.add("test-server-no-oauth", { |
| 69 | + type: "remote", |
| 70 | + url: server.url, |
| 71 | + oauth: false, |
| 72 | + headers: { |
101 | 73 | Authorization: "Bearer test-token", |
102 | | - }) |
103 | | - // OAuth is disabled, so no authProvider |
104 | | - expect(call.options.authProvider).toBeUndefined() |
| 74 | + }, |
| 75 | + }) |
| 76 | + |
| 77 | + expect(result.status).toMatchObject({ "test-server-no-oauth": { status: "connected" } }) |
| 78 | + expect(server.requests.length).toBeGreaterThan(0) |
| 79 | + for (const headers of server.requests) { |
| 80 | + expect(headers.get("authorization")).toBe("Bearer test-token") |
105 | 81 | } |
106 | 82 | }), |
107 | 83 | ) |
108 | 84 |
|
109 | 85 | it.instance("no requestInit when headers are not provided", () => |
110 | 86 | Effect.gen(function* () { |
| 87 | + const server = yield* serve |
111 | 88 | const mcp = yield* MCP.Service |
112 | | - yield* mcp |
113 | | - .add("test-server-no-headers", { |
114 | | - type: "remote", |
115 | | - url: "https://example.com/mcp", |
116 | | - }) |
117 | | - .pipe(Effect.catch(() => Effect.void)) |
118 | | - |
119 | | - expect(transportCalls.length).toBeGreaterThanOrEqual(1) |
| 89 | + const result = yield* mcp.add("test-server-no-headers", { |
| 90 | + type: "remote", |
| 91 | + url: server.url, |
| 92 | + }) |
120 | 93 |
|
121 | | - for (const call of transportCalls) { |
122 | | - // No headers means requestInit should be undefined |
123 | | - expect(call.options.requestInit).toBeUndefined() |
| 94 | + expect(result.status).toMatchObject({ "test-server-no-headers": { status: "connected" } }) |
| 95 | + expect(server.requests.length).toBeGreaterThan(0) |
| 96 | + for (const headers of server.requests) { |
| 97 | + expect(headers.has("authorization")).toBe(false) |
| 98 | + expect(headers.has("x-custom-header")).toBe(false) |
124 | 99 | } |
125 | 100 | }), |
126 | 101 | ) |
|
0 commit comments