feat(core): support MCP resources (#35656)

This commit is contained in:
Aiden Cline 2026-07-07 11:55:06 -05:00 committed by GitHub
commit 66a8d830aa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 618 additions and 84 deletions

View file

@ -4,16 +4,27 @@ import {
CallToolRequestSchema,
GetPromptRequestSchema,
ListPromptsRequestSchema,
ListResourcesRequestSchema,
ListResourceTemplatesRequestSchema,
ListToolsRequestSchema,
ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js"
const server = new Server({ name: "timeout", version: "1.0.0" }, { capabilities: { prompts: {}, tools: {} } })
const server = new Server(
{ name: "timeout", version: "1.0.0" },
{ capabilities: { prompts: {}, resources: {}, tools: {} } },
)
server.setRequestHandler(ListToolsRequestSchema, async () => {
if (process.env.MCP_TIMEOUT_TARGET === "catalog") await Bun.sleep(100)
return { tools: [{ name: "slow", inputSchema: { type: "object" } }] }
})
server.setRequestHandler(ListPromptsRequestSchema, () => Promise.resolve({ prompts: [{ name: "slow" }] }))
server.setRequestHandler(ListResourcesRequestSchema, async () => {
if (process.env.MCP_TIMEOUT_TARGET === "resource-catalog") await Bun.sleep(100)
return { resources: [{ name: "slow", uri: "test://slow" }] }
})
server.setRequestHandler(ListResourceTemplatesRequestSchema, () => Promise.resolve({ resourceTemplates: [] }))
server.setRequestHandler(CallToolRequestSchema, async () => {
await Bun.sleep(100)
return { content: [] }
@ -22,5 +33,9 @@ server.setRequestHandler(GetPromptRequestSchema, async () => {
await Bun.sleep(100)
return { messages: [] }
})
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
await Bun.sleep(100)
return { contents: [{ uri: request.params.uri, text: "slow" }] }
})
await server.connect(new StdioServerTransport())

View file

@ -14,15 +14,12 @@ export const emptyMcpLayer = Layer.succeed(
instructions: () => Effect.succeed([]),
prompts: () => Effect.succeed([]),
prompt: () => Effect.succeed(undefined),
resourceCatalog: () => Effect.succeed(new MCP.ResourceCatalog({ resources: [], templates: [] })),
resourceCatalog: () => Effect.succeed(MCP.ResourceCatalog.make({ resources: [], templates: [] })),
readResource: () => Effect.succeed(undefined),
}),
)
export const emptyConfigLayer = Layer.succeed(
Config.Service,
Config.Service.of({ entries: () => Effect.succeed([]) }),
)
export const emptyConfigLayer = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))
export const testLocationLayer = Layer.succeed(
Location.Service,

View file

@ -3,26 +3,165 @@ import { describe, expect, test } from "bun:test"
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 { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
import {
CallToolRequestSchema,
ListResourcesRequestSchema,
ListResourceTemplatesRequestSchema,
ListToolsRequestSchema,
ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js"
import { ConfigMCP } from "@opencode-ai/core/config/mcp"
import { Config } from "@opencode-ai/core/config"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Form } from "@opencode-ai/core/form"
import { Integration } from "@opencode-ai/core/integration"
import { Location } from "@opencode-ai/core/location"
import { MCP } from "@opencode-ai/core/mcp/index"
import { MCPClient } from "@opencode-ai/core/mcp/client"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { McpTool } from "@opencode-ai/core/tool/mcp"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { Deferred, Effect, Fiber, Layer, Stream } from "effect"
import { Deferred, Effect, Exit, Fiber, Layer, Stream } from "effect"
import { testEffect } from "./lib/effect"
import { location } from "./fixture/location"
import { settleTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
let assertion: Deferred.Deferred<PermissionV2.AssertInput> | undefined
let decision: Effect.Effect<void, PermissionV2.Error> = Effect.void
let calls = 0
type ResourcePage = {
items: Array<{ name: string; uri: string; description?: string; mimeType?: string }>
nextCursor?: string
}
type ResourceTemplatePage = {
items: Array<{ name: string; uriTemplate: string; description?: string; mimeType?: string }>
nextCursor?: string
}
function resourceServer(input: { resources?: boolean; listChanged?: boolean } = {}) {
return Effect.acquireRelease(
Effect.promise(async () => {
const state = {
resources: [] as ResourcePage["items"],
templates: [] as ResourceTemplatePage["items"],
resourcePages: undefined as Record<string, ResourcePage> | undefined,
templatePages: undefined as Record<string, ResourceTemplatePage> | undefined,
contents: [
{ uri: "docs://readme", text: "hello", mimeType: "text/plain" },
{ uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
] as Array<{ uri: string; text: string; mimeType?: string } | { uri: string; blob: string; mimeType?: string }>,
resourceLists: 0,
templateLists: 0,
}
const protocol = new Server(
{ name: "mcp-resources", version: "1.0.0" },
{
capabilities: {
tools: {},
...(input.resources === false ? {} : { resources: { listChanged: input.listChanged } }),
},
},
)
protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] }))
if (input.resources !== false) {
protocol.setRequestHandler(ListResourcesRequestSchema, (request) => {
state.resourceLists += 1
const page = state.resourcePages?.[request.params?.cursor ?? "initial"]
return Promise.resolve({ resources: page?.items ?? state.resources, nextCursor: page?.nextCursor })
})
protocol.setRequestHandler(ListResourceTemplatesRequestSchema, (request) => {
state.templateLists += 1
const page = state.templatePages?.[request.params?.cursor ?? "initial"]
return Promise.resolve({ resourceTemplates: page?.items ?? state.templates, nextCursor: page?.nextCursor })
})
protocol.setRequestHandler(ReadResourceRequestSchema, () => Promise.resolve({ contents: state.contents }))
}
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
enableJsonResponse: true,
})
await protocol.connect(transport)
const http = Bun.serve({
port: 0,
fetch: (request) => transport.handleRequest(request),
})
return {
state,
url: http.url.toString(),
sendResourceListChanged: () => protocol.sendResourceListChanged(),
close: async () => {
await protocol.close().catch(() => {})
await http.stop(true)
},
}
}),
(server) => Effect.promise(server.close),
)
}
function resourceMcpLayer(url: string) {
const directory = AbsolutePath.make(import.meta.dir)
const unusedIntegration = () => Effect.die("unused integration service")
return MCP.layer.pipe(
Layer.provide(
Layer.mergeAll(
Layer.succeed(
Config.Service,
Config.Service.of({
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: new Config.Info({
mcp: new ConfigMCP.Info({
servers: { resources: new ConfigMCP.Remote({ type: "remote", url, oauth: false }) },
}),
}),
}),
]),
}),
),
Layer.succeed(Location.Service, Location.Service.of(location({ directory }))),
Layer.mock(EventV2.Service, {
subscribe: () => Stream.never,
publish: (definition, data) =>
Effect.succeed({
id: EventV2.ID.create(),
type: definition.type,
data,
} as EventV2.Payload<typeof definition>),
}),
Layer.mock(Form.Service, {}),
Layer.mock(Integration.Service, {
connection: {
active: unusedIntegration,
resolve: unusedIntegration,
key: unusedIntegration,
oauth: unusedIntegration,
update: unusedIntegration,
remove: unusedIntegration,
},
attempt: {
status: unusedIntegration,
complete: unusedIntegration,
cancel: unusedIntegration,
},
}),
Layer.mock(Credential.Service, {}),
),
),
)
}
const mcp = Layer.mock(MCP.Service, {
tools: () =>
Effect.succeed([
@ -241,6 +380,163 @@ test("applies the configured MCP execution timeout to prompts", async () => {
await expect(result).rejects.toThrow("Request timed out")
})
test("applies configured MCP timeouts to resource operations", async () => {
const catalog = Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const connection = yield* MCPClient.connect(
"resource-catalog-timeout",
new ConfigMCP.Local({
type: "local",
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")],
environment: { MCP_TIMEOUT_TARGET: "resource-catalog" },
timeout: new ConfigMCP.Timeout({ catalog: 10 }),
}),
import.meta.dir,
)
return yield* connection.resources()
}),
),
)
await expect(catalog).rejects.toThrow("Request timed out")
const read = Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const connection = yield* MCPClient.connect(
"resource-read-timeout",
new ConfigMCP.Local({
type: "local",
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")],
timeout: new ConfigMCP.Timeout({ execution: 10 }),
}),
import.meta.dir,
)
return yield* connection.readResource({ uri: "test://slow" })
}),
),
)
await expect(read).rejects.toThrow("Request timed out")
})
test("lists, reads, and reports MCP resource changes", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const server = yield* resourceServer({ listChanged: true })
server.state.resourcePages = {
initial: {
items: [{ name: "Readme", uri: "docs://readme", description: "Project docs" }],
nextCursor: "resources-2",
},
"resources-2": { items: [{ name: "Logo", uri: "docs://logo", mimeType: "image/png" }] },
}
server.state.templatePages = {
initial: {
items: [{ name: "File", uriTemplate: "docs://{path}" }],
nextCursor: "templates-2",
},
"templates-2": { items: [{ name: "Issue", uriTemplate: "issue://{id}", description: "Issue" }] },
}
const connection = yield* MCPClient.connect(
"resources",
new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }),
import.meta.dir,
)
expect(yield* connection.resources()).toEqual([
{ name: "Readme", uri: "docs://readme", description: "Project docs", mimeType: undefined },
{ name: "Logo", uri: "docs://logo", description: undefined, mimeType: "image/png" },
])
expect(yield* connection.resourceTemplates()).toEqual([
{ name: "File", uriTemplate: "docs://{path}", description: undefined, mimeType: undefined },
{ name: "Issue", uriTemplate: "issue://{id}", description: "Issue", mimeType: undefined },
])
expect(yield* connection.readResource({ uri: "docs://readme" })).toEqual({
contents: [
{ type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" },
{ type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
],
})
const changed = yield* Deferred.make<void>()
connection.onResourcesChanged(() => Deferred.doneUnsafe(changed, Exit.void))
yield* Effect.promise(server.sendResourceListChanged)
yield* Deferred.await(changed)
}),
),
)
})
test("skips MCP resource requests when the capability is absent", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const server = yield* resourceServer({ resources: false })
const connection = yield* MCPClient.connect(
"resources",
new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }),
import.meta.dir,
)
expect(yield* connection.resources()).toEqual([])
expect(yield* connection.resourceTemplates()).toEqual([])
expect(yield* connection.readResource({ uri: "docs://readme" })).toBeUndefined()
expect({ resources: server.state.resourceLists, templates: server.state.templateLists }).toEqual({
resources: 0,
templates: 0,
})
}),
),
)
})
test("loads and reads MCP resources", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const server = yield* resourceServer()
server.state.resources = [{ name: "Readme", uri: "docs://readme" }]
server.state.templates = [{ name: "File", uriTemplate: "docs://{path}" }]
yield* Effect.gen(function* () {
const service = yield* MCP.Service
expect(yield* service.resourceCatalog()).toEqual({
resources: [
{
server: "resources",
name: "Readme",
uri: "docs://readme",
description: undefined,
mimeType: undefined,
},
],
templates: [
{
server: "resources",
name: "File",
uriTemplate: "docs://{path}",
description: undefined,
mimeType: undefined,
},
],
})
server.state.resources = [{ name: "Guide", uri: "docs://guide" }]
expect((yield* service.resourceCatalog()).resources.map((resource) => resource.uri)).toEqual(["docs://guide"])
expect(yield* service.readResource({ server: "resources", uri: "docs://readme" })).toEqual({
server: "resources",
uri: "docs://readme",
contents: [
{ type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" },
{ type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
],
})
}).pipe(Effect.provide(resourceMcpLayer(server.url)))
}),
),
)
})
it.effect("advertises MCP output schemas to Code Mode", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service