fix(mcp): restore legacy SDK compatibility (#39373)

This commit is contained in:
Aiden Cline
2026-07-28 11:53:35 -05:00
committed by GitHub
parent 7edefb3347
commit 982a9044c5
31 changed files with 1082 additions and 596 deletions
+15 -51
View File
@@ -1,6 +1,8 @@
import { describe, expect, test } from "bun:test"
import { Client, InMemoryTransport } from "@modelcontextprotocol/client"
import { Server } from "@modelcontextprotocol/server"
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
import { McpCatalog } from "@/mcp/catalog"
import { Effect } from "effect"
@@ -28,10 +30,7 @@ describe("McpCatalog.convertTool", () => {
test("preserves content when structuredContent is also present", async () => {
const content = [{ type: "image" as const, mimeType: "image/png", data: "AAAA" }]
const structuredContent = { image: { mimeType: "image/png", data: "AAAA" } }
const converted = McpCatalog.convertTool({
def: mcpTool(),
client: clientReturning({ content, structuredContent }),
})
const converted = McpCatalog.convertTool(mcpTool(), clientReturning({ content, structuredContent }))
const output = await converted.execute?.({}, options)
@@ -40,10 +39,7 @@ describe("McpCatalog.convertTool", () => {
test("falls back to structuredContent only when content is absent", async () => {
const structuredContent = { results: [{ title: "one" }] }
const converted = McpCatalog.convertTool({
def: mcpTool(),
client: clientReturning({ content: [], structuredContent }),
})
const converted = McpCatalog.convertTool(mcpTool(), clientReturning({ content: [], structuredContent }))
const output = await converted.execute?.({}, options)
@@ -54,52 +50,18 @@ describe("McpCatalog.convertTool", () => {
})
})
describe("McpCatalog.callTool", () => {
test("forwards the request options", async () => {
const controller = new AbortController()
let request: unknown
let options: unknown
const client = {
callTool: async (input: unknown, config: unknown) => {
request = input
options = config
return { content: [] }
},
} as unknown as Client
await McpCatalog.callTool({ def: mcpTool(), client, timeout: 123 }, { value: true }, controller.signal)
expect(request).toEqual({ name: "screenshot", arguments: { value: true } })
expect(options).toMatchObject({ resetTimeoutOnProgress: true, signal: controller.signal, timeout: 123 })
expect(typeof (options as { onprogress?: unknown }).onprogress).toBe("function")
})
test("throws text returned by an MCP tool error", async () => {
const client = clientReturning({
isError: true,
content: [
{ type: "image", data: "AAAA", mimeType: "image/png" },
{ type: "text", text: "first" },
{ type: "text", text: "second" },
],
})
await expect(McpCatalog.callTool({ def: mcpTool(), client }, {})).rejects.toThrow("first\n\nsecond")
})
})
test("preserves output schema validation across paginated tool discovery", async () => {
const server = new Server({ name: "pagination", version: "1.0.0" }, { capabilities: { tools: {} } })
server.setRequestHandler("tools/list", ({ params }) =>
server.setRequestHandler(ListToolsRequestSchema, ({ params }) =>
Promise.resolve(
params?.cursor === "page-2"
? {
tools: [
{
name: "second",
inputSchema: { type: "object" as const },
inputSchema: { type: "object" },
outputSchema: {
type: "object" as const,
type: "object",
properties: { value: { type: "number" } },
required: ["value"],
},
@@ -110,9 +72,9 @@ test("preserves output schema validation across paginated tool discovery", async
tools: [
{
name: "first",
inputSchema: { type: "object" as const },
inputSchema: { type: "object" },
outputSchema: {
type: "object" as const,
type: "object",
properties: { value: { type: "string" } },
required: ["value"],
},
@@ -122,7 +84,7 @@ test("preserves output schema validation across paginated tool discovery", async
},
),
)
server.setRequestHandler("tools/call", ({ params }) =>
server.setRequestHandler(CallToolRequestSchema, ({ params }) =>
Promise.resolve({
content: [],
structuredContent: { value: params.name === "first" ? 42 : 1 },
@@ -136,7 +98,9 @@ test("preserves output schema validation across paginated tool discovery", async
try {
const tools = await Effect.runPromise(McpCatalog.defs(client))
expect(tools?.map((tool) => tool.name)).toEqual(["first", "second"])
await expect(client.callTool({ name: "first", arguments: {} })).rejects.toThrow(/output schema/i)
await expect(client.callTool({ name: "first", arguments: {} })).rejects.toThrow(
"Structured content does not match the tool's output schema",
)
} finally {
await Promise.all([client.close(), server.close()])
}
+4 -21
View File
@@ -1,5 +1,7 @@
import { describe, expect } from "bun:test"
import { Server, WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/server"
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Effect } from "effect"
import { testEffect } from "../lib/effect"
@@ -11,7 +13,7 @@ const serve = Effect.acquireRelease(
Effect.promise(async () => {
const requests: Headers[] = []
const protocol = new Server({ name: "headers", version: "1.0.0" }, { capabilities: { tools: {} } })
protocol.setRequestHandler("tools/list", () => Promise.resolve({ tools: [] }))
protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] }))
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
enableJsonResponse: true,
@@ -36,11 +38,6 @@ const serve = Effect.acquireRelease(
(server) => Effect.promise(server.close),
)
const serveUnauthorized = Effect.acquireRelease(
Effect.sync(() => Bun.serve({ port: 0, fetch: () => new Response("Unauthorized", { status: 401 }) })),
(server) => Effect.sync(() => server.stop(true)),
)
describe("mcp.headers", () => {
it.instance("headers are passed to transports when oauth is enabled (default)", () =>
Effect.gen(function* () {
@@ -102,18 +99,4 @@ describe("mcp.headers", () => {
}
}),
)
it.instance("reports 401 as failed when oauth is explicitly disabled", () =>
Effect.gen(function* () {
const server = yield* serveUnauthorized
const mcp = yield* MCP.Service
const result = yield* mcp.add("unauthorized-no-oauth", {
type: "remote",
url: server.url.toString(),
oauth: false,
})
expect(result.status).toMatchObject({ "unauthorized-no-oauth": { status: "failed" } })
}),
)
})
+18 -13
View File
@@ -1,12 +1,18 @@
import path from "node:path"
import { pathToFileURL } from "node:url"
import { expect } from "bun:test"
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
import {
Server,
WebStandardStreamableHTTPServerTransport,
GetPromptRequestSchema,
ListPromptsRequestSchema,
ListResourcesRequestSchema,
ListResourceTemplatesRequestSchema,
ListToolsRequestSchema,
ReadResourceRequestSchema,
type ServerCapabilities,
type Tool,
} from "@modelcontextprotocol/server"
} from "@modelcontextprotocol/sdk/types.js"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Cause, Effect, Exit } from "effect"
import type { MCP as MCPNS } from "../../src/mcp/index"
@@ -60,35 +66,35 @@ function lifecycleServer(input?: { capabilities?: ServerCapabilities; instructio
})
if (capabilities.tools) {
protocol.setRequestHandler("tools/list", (request) => {
protocol.setRequestHandler(ListToolsRequestSchema, (request) => {
if (state.listToolsError) throw new Error(state.listToolsError)
const page = state.toolPages?.[request.params?.cursor ?? "initial"]
return Promise.resolve({ tools: page?.items ?? state.tools, nextCursor: page?.nextCursor })
})
}
if (capabilities.prompts) {
protocol.setRequestHandler("prompts/list", (request) => {
protocol.setRequestHandler(ListPromptsRequestSchema, (request) => {
const page = state.promptPages?.[request.params?.cursor ?? "initial"]
return Promise.resolve({ prompts: page?.items ?? state.prompts, nextCursor: page?.nextCursor })
})
protocol.setRequestHandler("prompts/get", async () => {
protocol.setRequestHandler(GetPromptRequestSchema, async () => {
if (state.requestDelay) await Bun.sleep(state.requestDelay)
return { messages: [{ role: "user", content: { type: "text", text: "prompt result" } }] }
})
}
if (capabilities.resources) {
protocol.setRequestHandler("resources/list", (request) => {
protocol.setRequestHandler(ListResourcesRequestSchema, (request) => {
const page = state.resourcePages?.[request.params?.cursor ?? "initial"]
return Promise.resolve({ resources: page?.items ?? state.resources, nextCursor: page?.nextCursor })
})
protocol.setRequestHandler("resources/templates/list", (request) => {
protocol.setRequestHandler(ListResourceTemplatesRequestSchema, (request) => {
const page = state.resourceTemplatePages?.[request.params?.cursor ?? "initial"]
return Promise.resolve({
resourceTemplates: page?.items ?? state.resourceTemplates,
nextCursor: page?.nextCursor,
})
})
protocol.setRequestHandler("resources/read", async (request) => {
protocol.setRequestHandler(ReadResourceRequestSchema, async (request) => {
if (state.requestDelay) await Bun.sleep(state.requestDelay)
return { contents: [{ uri: request.params.uri, text: "resource result" }] }
})
@@ -139,7 +145,7 @@ function hangingLifecycleServer() {
return Effect.acquireRelease(
Effect.promise(async () => {
const protocol = new Server({ name: "mcp-lifecycle-hanging", version: "1.0.0" }, { capabilities: { tools: {} } })
protocol.setRequestHandler("tools/list", () => Promise.resolve({ tools: [] }))
protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] }))
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
enableJsonResponse: true,
@@ -278,7 +284,7 @@ it.instance("follows cursors when listing tools, prompts, resources, and templat
}),
)
it.instance("accepts empty cursors and terminates on repeated cursors", () =>
it.instance("accepts empty cursors and rejects repeated cursors", () =>
Effect.gen(function* () {
const empty = yield* lifecycleServer({ capabilities: { prompts: {} } })
empty.state.promptPages = {
@@ -295,8 +301,7 @@ it.instance("accepts empty cursors and terminates on repeated cursors", () =>
const result = yield* mcp.add("looping-cursor", remote(looping.url))
expect(Object.keys(yield* mcp.prompts())).toEqual(["empty-cursor:prompt-one", "empty-cursor:prompt-two"])
expect(statusName(result.status, "looping-cursor")).toBe("connected")
expect(Object.keys(yield* mcp.tools())).toEqual([])
expect(statusName(result.status, "looping-cursor")).toBe("failed")
}),
)
@@ -1,5 +1,7 @@
import { expect } from "bun:test"
import { Server, WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/server"
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
import { ListResourcesRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { FSUtil } from "@opencode-ai/core/fs-util"
@@ -38,13 +40,13 @@ function serveOAuthMcp(options: OAuthMcpOptions = {}) {
let requiresAuth = true
if (capabilities === "tools") {
protocol.setRequestHandler("tools/list", () => {
protocol.setRequestHandler(ListToolsRequestSchema, () => {
listToolsCalls++
return Promise.resolve({ tools: [{ name: "test_tool", inputSchema: { type: "object" } }] })
})
}
if (capabilities === "resources") {
protocol.setRequestHandler("resources/list", () =>
protocol.setRequestHandler(ListResourcesRequestSchema, () =>
Promise.resolve({ resources: [{ name: "docs", uri: "docs://readme" }] }),
)
}
@@ -1,5 +1,7 @@
import { expect } from "bun:test"
import { Server, WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/server"
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Deferred, Effect, Layer, Option } from "effect"
import { Config } from "../../src/config/config"
@@ -39,7 +41,7 @@ const serveOAuthMcp = Effect.acquireRelease(
Effect.promise(async () => {
const requests: Array<{ pathname: string; headers: Headers }> = []
const protocol = new Server({ name: "oauth-browser", version: "1.0.0" }, { capabilities: { tools: {} } })
protocol.setRequestHandler("tools/list", () => Promise.resolve({ tools: [] }))
protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] }))
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
enableJsonResponse: true,
@@ -74,7 +74,7 @@ describe("McpOAuthCallback.ensureRunning", () => {
const response = await fetch(`${redirectUri}?code=code&state=success`)
expect(response.status).toBe(200)
expect(await callback).toEqual({ code: "code", iss: undefined })
expect(await callback).toBe("code")
expect(McpOAuthCallback.isRunning()).toBe(false)
})
@@ -1,4 +1,5 @@
import { test, expect, describe } from "bun:test"
import { determineScope } from "@modelcontextprotocol/sdk/client/auth.js"
import { McpOAuthProvider, OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "../../src/mcp/oauth-provider"
import type { McpAuth } from "../../src/mcp/auth"
@@ -59,3 +60,43 @@ describe("McpOAuthProvider.clientMetadata", () => {
expect(provider.clientMetadata.token_endpoint_auth_method).toBe("none")
})
})
describe("MCP OAuth scope selection", () => {
test("adds offline_access when the authorization server and client support refresh tokens", () => {
expect(
determineScope({
resourceMetadata: {
resource: "https://mcp.example.com/mcp",
scopes_supported: ["resource.read"],
},
authServerMetadata: {
issuer: "https://auth.example.com",
authorization_endpoint: "https://auth.example.com/authorize",
token_endpoint: "https://auth.example.com/token",
response_types_supported: ["code"],
scopes_supported: ["resource.read", "offline_access"],
},
clientMetadata: makeProvider({}).clientMetadata,
}),
).toBe("resource.read offline_access")
})
test("does not add unsupported authorization server scopes", () => {
expect(
determineScope({
resourceMetadata: {
resource: "https://mcp.example.com/mcp",
scopes_supported: ["resource.read"],
},
authServerMetadata: {
issuer: "https://auth.example.com",
authorization_endpoint: "https://auth.example.com/authorize",
token_endpoint: "https://auth.example.com/token",
response_types_supported: ["code"],
scopes_supported: ["resource.read"],
},
clientMetadata: makeProvider({}).clientMetadata,
}),
).toBe("resource.read")
})
})
@@ -24,24 +24,4 @@ describe("mcp session recovery", () => {
{ method: "ping", session: "replacement" },
])
})
test("retries a concurrent stale response after recovery completes", async () => {
const child = Bun.spawn([process.execPath, path.join(import.meta.dir, "../fixture/mcp-session-recovery.ts")], {
cwd: path.join(import.meta.dir, "../.."),
env: { ...process.env, MCP_RECOVERY_CONCURRENT: "1" },
stdout: "pipe",
stderr: "pipe",
})
const [code, stdout, stderr] = await Promise.all([
child.exited,
Bun.readableStreamToText(child.stdout),
Bun.readableStreamToText(child.stderr),
])
expect(code, stderr).toBe(0)
const posts = JSON.parse(stdout) as Array<{ method: string; session: string | null }>
expect(posts.filter((post) => post.method === "initialize").map((post) => post.session)).toEqual([null, null])
expect(posts.filter((post) => post.method === "ping" && post.session === "expired")).toHaveLength(2)
expect(posts.filter((post) => post.method === "ping" && post.session === "replacement")).toHaveLength(2)
})
})