* Revert the outbound MCP SDK v2 migration * Drop the v2 MCP packages from the lockfile
Showing the changes to e2e/src (2 of 91 files). Show all
Unified · Split
| @@ -1,83 +0,0 @@ | |||
| 1 | import { createServer, type IncomingHttpHeaders, type IncomingMessage } from "node:http"; | ||
| 2 | |||
| 3 | import { Effect, Scope } from "effect"; | ||
| 4 | import { createMcpHandler, McpServer } from "@modelcontextprotocol/server"; | ||
| 5 | |||
| 6 | /** Network address of a scoped modern-only MCP fixture. */ | ||
| 7 | export type ModernOnlyMcpFixture = { | ||
| 8 | readonly url: string; | ||
| 9 | }; | ||
| 10 | |||
| 11 | const requestHeaders = (source: IncomingHttpHeaders): Headers => { | ||
| 12 | const headers = new Headers(); | ||
| 13 | for (const [name, value] of Object.entries(source)) { | ||
| 14 | if (Array.isArray(value)) { | ||
| 15 | for (const item of value) headers.append(name, item); | ||
| 16 | } else if (value !== undefined) { | ||
| 17 | headers.set(name, value); | ||
| 18 | } | ||
| 19 | } | ||
| 20 | return headers; | ||
| 21 | }; | ||
| 22 | |||
| 23 | const requestBody = (request: IncomingMessage): Promise<string> => | ||
| 24 | new Promise((resolve) => { | ||
| 25 | const chunks: Uint8Array[] = []; | ||
| 26 | request.on("data", (chunk: Uint8Array) => chunks.push(chunk)); | ||
| 27 | request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); | ||
| 28 | }); | ||
| 29 | |||
| 30 | const makeServer = (): McpServer => { | ||
| 31 | const server = new McpServer({ name: "executor-modern-only-e2e", version: "1.0.0" }); | ||
| 32 | server.registerTool( | ||
| 33 | "modern_ping", | ||
| 34 | { description: "Answers from a modern-only MCP server" }, | ||
| 35 | async () => ({ content: [{ type: "text", text: "pong-modern" }] }), | ||
| 36 | ); | ||
| 37 | server.registerTool( | ||
| 38 | "modern_identity", | ||
| 39 | { description: "Names the fixture's protocol posture" }, | ||
| 40 | async () => ({ content: [{ type: "text", text: "modern-only" }] }), | ||
| 41 | ); | ||
| 42 | return server; | ||
| 43 | }; | ||
| 44 | |||
| 45 | /** | ||
| 46 | * Serve a real v2 MCP handler that rejects every legacy-classified request. | ||
| 47 | * The fixture follows the e2e suite's scoped, ephemeral localhost convention. | ||
| 48 | */ | ||
| 49 | export const serveModernOnlyMcp = (): Effect.Effect<ModernOnlyMcpFixture, never, Scope.Scope> => | ||
| 50 | Effect.acquireRelease( | ||
| 51 | Effect.callback<ModernOnlyMcpFixture & { readonly close: () => Promise<void> }>((resume) => { | ||
| 52 | const handler = createMcpHandler(makeServer, { legacy: "reject" }); | ||
| 53 | const server = createServer((incoming, outgoing) => { | ||
| 54 | void requestBody(incoming).then(async (body) => { | ||
| 55 | const host = incoming.headers.host ?? "127.0.0.1"; | ||
| 56 | const request = new Request(new URL(incoming.url ?? "/", `http://${host}`), { | ||
| 57 | method: incoming.method, | ||
| 58 | headers: requestHeaders(incoming.headers), | ||
| 59 | ...(incoming.method === "GET" || incoming.method === "HEAD" ? {} : { body }), | ||
| 60 | }); | ||
| 61 | const response = await handler.fetch(request); | ||
| 62 | outgoing.writeHead(response.status, Object.fromEntries(response.headers)); | ||
| 63 | outgoing.end(Buffer.from(await response.arrayBuffer())); | ||
| 64 | }); | ||
| 65 | }); | ||
| 66 | |||
| 67 | server.listen(0, "127.0.0.1", () => { | ||
| 68 | const address = server.address(); | ||
| 69 | const port = typeof address === "object" && address ? address.port : 0; | ||
| 70 | resume( | ||
| 71 | Effect.succeed({ | ||
| 72 | url: `http://127.0.0.1:${port}/mcp`, | ||
| 73 | close: async () => { | ||
| 74 | server.close(); | ||
| 75 | server.closeAllConnections(); | ||
| 76 | await handler.close(); | ||
| 77 | }, | ||
| 78 | }), | ||
| 79 | ); | ||
| 80 | }); | ||
| 81 | }), | ||
| 82 | (fixture) => Effect.promise(fixture.close).pipe(Effect.ignore), | ||
| 83 | ); | ||
| @@ -1,112 +0,0 @@ | |||
| 1 | import { Effect } from "effect"; | ||
| 2 | import { | ||
| 3 | Client, | ||
| 4 | StreamableHTTPClientTransport, | ||
| 5 | withInputRequired, | ||
| 6 | type InputRequiredResult, | ||
| 7 | type Request as McpRequest, | ||
| 8 | } from "@modelcontextprotocol/client"; | ||
| 9 | import { CallToolResultSchema } from "@modelcontextprotocol/core"; | ||
| 10 | |||
| 11 | /** The first MCP revision served through the modern per-request protocol. */ | ||
| 12 | export const MODERN_MCP_PROTOCOL_VERSION = "2026-07-28"; | ||
| 13 | |||
| 14 | /** Negotiation postures covered by the modern e2e client. */ | ||
| 15 | export type ModernMcpNegotiationMode = | ||
| 16 | | "auto" | ||
| 17 | | { readonly pin: typeof MODERN_MCP_PROTOCOL_VERSION }; | ||
| 18 | |||
| 19 | /** Connection inputs for a scoped modern MCP client. */ | ||
| 20 | export type ModernMcpClientOptions = { | ||
| 21 | readonly url: string; | ||
| 22 | readonly bearer: string; | ||
| 23 | readonly mode: ModernMcpNegotiationMode; | ||
| 24 | readonly manualInputRequired?: boolean; | ||
| 25 | }; | ||
| 26 | |||
| 27 | const makeClient = (mode: ModernMcpNegotiationMode, manualInputRequired: boolean): Client => | ||
| 28 | new Client( | ||
| 29 | { name: "executor-modern-e2e", version: "1.0.0" }, | ||
| 30 | { | ||
| 31 | capabilities: { elicitation: { form: {} } }, | ||
| 32 | versionNegotiation: { mode }, | ||
| 33 | ...(manualInputRequired ? { inputRequired: { autoFulfill: false } } : {}), | ||
| 34 | }, | ||
| 35 | ); | ||
| 36 | |||
| 37 | /** | ||
| 38 | * Connect a real v2 MCP client and close it when the surrounding Effect scope | ||
| 39 | * ends. The caller selects pinned or auto negotiation explicitly. | ||
| 40 | */ | ||
| 41 | export const connectModernMcpClient = (options: ModernMcpClientOptions) => | ||
| 42 | Effect.acquireRelease( | ||
| 43 | Effect.promise(async () => { | ||
| 44 | const client = makeClient(options.mode, options.manualInputRequired ?? false); | ||
| 45 | const transport = new StreamableHTTPClientTransport(new URL(options.url), { | ||
| 46 | requestInit: { headers: { authorization: `Bearer ${options.bearer}` } }, | ||
| 47 | }); | ||
| 48 | await client.connect(transport); | ||
| 49 | return client; | ||
| 50 | }), | ||
| 51 | (client) => Effect.promise(() => client.close()).pipe(Effect.ignore), | ||
| 52 | ); | ||
| 53 | |||
| 54 | /** | ||
| 55 | * Issue a modern tool call in manual input-required mode, returning either the | ||
| 56 | * completed tool result or the server's next input request. | ||
| 57 | */ | ||
| 58 | export const callModernToolWithInputRequired = ( | ||
| 59 | client: Client, | ||
| 60 | params: Record<string, unknown>, | ||
| 61 | ): Effect.Effect<InputRequiredResult | Awaited<ReturnType<Client["callTool"]>>> => { | ||
| 62 | const request: McpRequest = { method: "tools/call", params }; | ||
| 63 | return Effect.promise(() => | ||
| 64 | client.request(request, withInputRequired(CallToolResultSchema), { | ||
| 65 | allowInputRequired: true, | ||
| 66 | }), | ||
| 67 | ); | ||
| 68 | }; | ||
| 69 | |||
| 70 | /** Join the text content returned by an MCP tool call. */ | ||
| 71 | export const modernToolText = (result: Awaited<ReturnType<Client["callTool"]>>): string => | ||
| 72 | result.content | ||
| 73 | .filter((part) => part.type === "text") | ||
| 74 | .map((part) => part.text) | ||
| 75 | .join("\n"); | ||
| 76 | |||
| 77 | /** The authentication response observed during an unauthenticated modern probe. */ | ||
| 78 | export type ModernMcpAuthChallenge = { | ||
| 79 | readonly status: number | undefined; | ||
| 80 | readonly wwwAuthenticate: string | null; | ||
| 81 | }; | ||
| 82 | |||
| 83 | /** | ||
| 84 | * Drive the real pinned-modern probe without credentials and capture the HTTP | ||
| 85 | * authentication challenge that prevented the connection. | ||
| 86 | */ | ||
| 87 | export const readModernMcpAuthChallenge = (url: string): Effect.Effect<ModernMcpAuthChallenge> => | ||
| 88 | Effect.promise(async () => { | ||
| 89 | let challenge: ModernMcpAuthChallenge = { | ||
| 90 | status: undefined, | ||
| 91 | wwwAuthenticate: null, | ||
| 92 | }; | ||
| 93 | const client = makeClient({ pin: MODERN_MCP_PROTOCOL_VERSION }, false); | ||
| 94 | const transport = new StreamableHTTPClientTransport(new URL(url), { | ||
| 95 | fetch: async (input, init) => { | ||
| 96 | const response = await fetch(input, init); | ||
| 97 | if (response.status === 401) { | ||
| 98 | challenge = { | ||
| 99 | status: response.status, | ||
| 100 | wwwAuthenticate: response.headers.get("www-authenticate"), | ||
| 101 | }; | ||
| 102 | } | ||
| 103 | return response; | ||
| 104 | }, | ||
| 105 | }); | ||
| 106 | await client.connect(transport).then( | ||
| 107 | () => undefined, | ||
| 108 | () => undefined, | ||
| 109 | ); | ||
| 110 | await client.close(); | ||
| 111 | return challenge; | ||
| 112 | }); | ||