|
|
@@ -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
|