diff --git a/packages/app/src/composer/model.ts b/packages/app/src/composer/model.ts index 1faa70cd920..83ec6cf2e78 100644 --- a/packages/app/src/composer/model.ts +++ b/packages/app/src/composer/model.ts @@ -164,32 +164,6 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue? }, })), ) - const resources = createMemo(() => - (data.location.mcp.resource.list({ directory: sdk().directory }) ?? []).map((resource) => ({ - id: `resource:${resource.server}:${resource.uri}`, - kind: "resource" as const, - label: `@${resource.name}`, - path: resource.uri, - description: resource.description, - mention: { - type: "file" as const, - path: resource.uri, - content: `@${resource.name}`, - start: 0, - end: 0, - mime: resource.mimeType ?? "text/plain", - filename: resource.name, - url: resource.uri, - source: { - type: "resource" as const, - text: { value: `@${resource.name}`, start: 0, end: resource.name.length + 1 }, - clientName: resource.server, - uri: resource.uri, - }, - }, - resource, - })), - ) const skills = createMemo(() => data.location.skill.list({ directory: sdk().directory }) ?? []) const context = createMemo(() => [ ...references(), @@ -216,7 +190,6 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue? label: `@${agent.name}`, mention: { type: "agent" as const, name: agent.name, content: `@${agent.name}`, start: 0, end: 0 }, })), - ...resources(), ...recent().map((path) => ({ id: `file:${path}`, kind: "file" as const, diff --git a/packages/core/src/mcp/index.ts b/packages/core/src/mcp/index.ts index b84b58455c9..b3927b5fd2d 100644 --- a/packages/core/src/mcp/index.ts +++ b/packages/core/src/mcp/index.ts @@ -120,10 +120,11 @@ export interface Interface extends State.Transformable { readonly args?: Record }) => Effect.Effect readonly resourceCatalog: () => Effect.Effect + readonly resources: (input: { readonly server: ServerName | string }) => Effect.Effect readonly readResource: (input: { readonly server: ServerName | string readonly uri: string - }) => Effect.Effect + }) => Effect.Effect } export class Service extends Context.Service()("@opencode/MCP") {} @@ -352,6 +353,41 @@ export const layer = (options?: Options) => ), ) + const loadCatalog = (name: ServerName, entry: ServerEntry, connection: McpClient.Connection) => + recovering(name, entry, connection, (connection) => + Effect.all( + { + resources: connection.resources(), + // Some servers declare resources without implementing template listing. + templates: connection.resourceTemplates().pipe(Effect.orElseSucceed(() => [])), + }, + { concurrency: "unbounded" }, + ), + ).pipe( + Effect.map((catalog) => + ResourceCatalog.make({ + resources: catalog.resources.map((resource) => + Resource.make({ + server: name, + name: resource.name, + uri: resource.uri, + description: resource.description, + mimeType: resource.mimeType, + }), + ), + templates: catalog.templates.map((template) => + ResourceTemplate.make({ + server: name, + name: template.name, + uriTemplate: template.uriTemplate, + description: template.description, + mimeType: template.mimeType, + }), + ), + }), + ), + ) + const watch = (name: ServerName, entry: ServerEntry, connection: McpClient.Connection) => { const live = whenLive(name, entry, connection) connection.onClose(() => @@ -662,57 +698,22 @@ export const layer = (options?: Options) => return { ...result, server: target.name, name: input.name } }), resourceCatalog: Effect.fn("MCP.resourceCatalog")(function* () { + const empty = ResourceCatalog.make({ resources: [], templates: [] }) const catalogs = yield* Effect.forEach( Array.from(entries), - ([name, entry]) => { - if (!entry.client) return Effect.succeed({ resources: [], templates: [] }) - return Effect.all( - { - resources: entry.client.resources().pipe(Effect.orElseSucceed(() => [])), - templates: entry.client.resourceTemplates().pipe(Effect.orElseSucceed(() => [])), - }, - { concurrency: "unbounded" }, - ).pipe( - Effect.map((catalog) => ({ - resources: catalog.resources.map((resource) => - Resource.make({ - server: name, - name: resource.name, - uri: resource.uri, - description: resource.description, - mimeType: resource.mimeType, - }), - ), - templates: catalog.templates.map((template) => - ResourceTemplate.make({ - server: name, - name: template.name, - uriTemplate: template.uriTemplate, - description: template.description, - mimeType: template.mimeType, - }), - ), - })), - ) - }, + ([name, entry]) => + entry.client + ? loadCatalog(name, entry, entry.client).pipe(Effect.orElseSucceed(() => empty)) + : Effect.succeed(empty), { concurrency: "unbounded" }, ) - return ResourceCatalog.make({ - resources: catalogs - .flatMap((catalog) => catalog.resources) - .toSorted( - (a, b) => - a.server.localeCompare(b.server) || a.name.localeCompare(b.name) || a.uri.localeCompare(b.uri), - ), - templates: catalogs - .flatMap((catalog) => catalog.templates) - .toSorted( - (a, b) => - a.server.localeCompare(b.server) || - a.name.localeCompare(b.name) || - a.uriTemplate.localeCompare(b.uriTemplate), - ), - }) + return mergeCatalogs(catalogs) + }), + resources: Effect.fn("MCP.resources")(function* (input) { + const target = yield* requireServer(input.server) + yield* target.entry.startup.await + if (!target.entry.client) return ResourceCatalog.make({ resources: [], templates: [] }) + return mergeCatalogs([yield* loadCatalog(target.name, target.entry, target.entry.client)]) }), readResource: Effect.fn("MCP.readResource")(function* (input) { const target = yield* requireServer(input.server) @@ -720,7 +721,7 @@ export const layer = (options?: Options) => if (!target.entry.client) return undefined const result = yield* recovering(target.name, target.entry, target.entry.client, (connection) => connection.readResource({ uri: input.uri }), - ).pipe(Effect.orElseSucceed(() => undefined)) + ) if (!result) return undefined return ResourceContent.make({ server: target.name, @@ -736,6 +737,24 @@ export const layer = (options?: Options) => }), ) +function mergeCatalogs(catalogs: ReadonlyArray) { + return ResourceCatalog.make({ + resources: catalogs + .flatMap((catalog) => catalog.resources) + .toSorted( + (a, b) => a.server.localeCompare(b.server) || a.name.localeCompare(b.name) || a.uri.localeCompare(b.uri), + ), + templates: catalogs + .flatMap((catalog) => catalog.templates) + .toSorted( + (a, b) => + a.server.localeCompare(b.server) || + a.name.localeCompare(b.name) || + a.uriTemplate.localeCompare(b.uriTemplate), + ), + }) +} + export function configured(options?: Options) { return makeLocationNode({ service: Service, diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index e9a6f989a1c..494de9b4217 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -71,6 +71,7 @@ import { PatchTool } from "../tool/plugin/patch.js" import { EditTool } from "../tool/plugin/edit.js" import { GlobTool } from "../tool/plugin/glob.js" import { GrepTool } from "../tool/plugin/grep.js" +import { McpResourceTools } from "../tool/plugin/mcp-resource.js" import { OpenCodeTools } from "../tool/plugin/opencode.js" import { QuestionTool } from "../tool/plugin/question.js" import { ReadToolFileSystem } from "../tool/read-filesystem.js" @@ -234,6 +235,7 @@ const pre = [ GlobTool.Plugin, GrepTool.Plugin, OpenCodeTools.Plugin, + McpResourceTools.Plugin, QuestionTool.Plugin, ReadTool.Plugin, ShellTool.Plugin, diff --git a/packages/core/src/tool/plugin/mcp-resource.ts b/packages/core/src/tool/plugin/mcp-resource.ts new file mode 100644 index 00000000000..f7bf6e3db9d --- /dev/null +++ b/packages/core/src/tool/plugin/mcp-resource.ts @@ -0,0 +1,101 @@ +export * as McpResourceTools from "./mcp-resource.js" + +import { ToolFailure } from "@opencode/ai" +import type { Context } from "@opencode/plugin/effect/plugin" +import { Effect, Schema } from "effect" +import { Mcp } from "../../mcp/index.js" +import { Permission } from "../../permission.js" + +export const Plugin = { + id: "opencode.tools.mcp-resources", + effect: Effect.fn("McpResourceTools.Plugin")(function* (ctx: Context) { + const mcp = yield* Mcp.Service + const permission = yield* Permission.Service + + yield* ctx.tool + .transform((editor) => { + editor.add({ + name: "list_mcp_resources", + options: { namespace: "opencode", codemode: true }, + description: + "List documents, records, and other data exposed by MCP servers. Use this when the user refers to something that is not a local file, such as a URI with a custom scheme, then load a match with read_mcp_resource. Each entry names the server to read it from. Templates are resources addressed by a parameter such as a record ID; fill in the uriTemplate placeholders before reading.", + input: Schema.Struct({ + server: Schema.optionalKey( + Schema.String.annotate({ description: "MCP server name as configured. Omit to list every server." }), + ), + }), + output: Schema.Struct({ + resources: Schema.Array(Mcp.Resource), + templates: Schema.Array(Mcp.ResourceTemplate), + }), + execute: (input, context) => + Effect.gen(function* () { + // Listing every server checks each server name so per-server rules still apply. + const servers = + input.server === undefined ? (yield* mcp.servers()).map((server) => server.name) : [input.server] + yield* permission.assert({ + action: "opencode_list_mcp_resources", + resources: servers, + save: servers, + metadata: {}, + sessionID: context.sessionID, + agent: context.agent, + source: { type: "tool", messageID: context.messageID, id: context.id }, + }) + if (input.server === undefined) return { output: yield* mcp.resourceCatalog() } + return { output: yield* mcp.resources({ server: input.server }) } + }).pipe(Effect.mapError((error) => new ToolFailure({ message: error.message, error }))), + }) + editor.add({ + name: "read_mcp_resource", + options: { namespace: "opencode", codemode: true }, + description: + "Read one MCP resource by server and URI. Not for local files. Always return the full contents rather than slicing or filtering them; oversized output is truncated automatically and the full content is saved to a file you can read. Images and PDFs are shown to you directly.", + input: Schema.Struct({ + server: Schema.String.annotate({ + description: "The server field of the discovered resource.", + }), + uri: Schema.String.annotate({ + description: "Exact resource URI from discovery, an expanded uriTemplate, or the user.", + }), + }), + output: Mcp.ResourceContent, + execute: (input, context) => + Effect.gen(function* () { + yield* permission.assert({ + action: "opencode_read_mcp_resource", + resources: [`${input.server}:${input.uri}`], + save: [`${input.server}:*`], + metadata: { server: input.server, uri: input.uri }, + sessionID: context.sessionID, + agent: context.agent, + source: { type: "tool", messageID: context.messageID, id: context.id }, + }) + const resource = yield* mcp.readResource(input) + if (!resource) + return yield* new ToolFailure({ + message: `MCP server "${input.server}" is not connected or does not expose resources`, + }) + return { + output: resource, + content: resource.contents.flatMap((part) => + part.type === "blob" && (part.mimeType?.startsWith("image/") || part.mimeType === "application/pdf") + ? [{ type: "file" as const, uri: `data:${part.mimeType};base64,${part.blob}`, mime: part.mimeType }] + : [], + ), + } + }).pipe( + Effect.mapError((error) => + error instanceof ToolFailure + ? error + : new ToolFailure({ + message: `Unable to read MCP resource ${input.server}:${input.uri}: ${error.message}`, + error, + }), + ), + ), + }) + }) + .pipe(Effect.orDie) + }), +} diff --git a/packages/core/src/tool/plugin/opencode.ts b/packages/core/src/tool/plugin/opencode.ts index bd583c85d13..0335e13b12f 100644 --- a/packages/core/src/tool/plugin/opencode.ts +++ b/packages/core/src/tool/plugin/opencode.ts @@ -84,7 +84,7 @@ export const Plugin = { draft.namespace({ name: "opencode", description: - "Tools for managing OpenCode itself, such as working with sessions and searching the available models.", + "Tools for managing OpenCode itself, such as working with sessions, searching the available models, and reading MCP resources.", }) draft.add({ name: "session_rename", diff --git a/packages/core/test/fixture/mcp.ts b/packages/core/test/fixture/mcp.ts index e39dd6ab9db..54d36bb4f20 100644 --- a/packages/core/test/fixture/mcp.ts +++ b/packages/core/test/fixture/mcp.ts @@ -21,6 +21,7 @@ export const emptyMcpLayer = Layer.succeed( prompts: () => Effect.succeed([]), prompt: () => Effect.undefined, resourceCatalog: () => Effect.succeed(Mcp.ResourceCatalog.make({ resources: [], templates: [] })), + resources: () => Effect.succeed(Mcp.ResourceCatalog.make({ resources: [], templates: [] })), readResource: () => Effect.undefined, }), ) diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index fec4994d1a6..330abe21238 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -32,7 +32,9 @@ import { AbsolutePath } from "@opencode/core/schema" import { Session } from "@opencode/core/session" import { State } from "@opencode/core/state" import { McpTool } from "@opencode/core/tool/mcp" +import { McpResourceTools } from "@opencode/core/tool/plugin/mcp-resource" import { Tool } from "@opencode/core/tool" +import { ToolOutput } from "@opencode/core/tool-output" import { Context, Deferred, @@ -58,7 +60,14 @@ import { imagePassthrough } from "./lib/image" import { location } from "./fixture/location" import { tmpdirScoped } from "./fixture/tmpdir" import { hostEnvironmentLayer, recordingEnvironmentLayer } from "./fixture/environment" -import { codeModeListings, executeTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool" +import { + codeModeListings, + executeTool, + registerToolPlugin, + toolDefinitions, + toolIdentity, + waitForTool, +} from "./lib/tool" let assertion: Deferred.Deferred | undefined let decision: Effect.Effect = Effect.void @@ -98,6 +107,9 @@ function resourceServer( { uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" }, ] as Array<{ uri: string; text: string; mimeType?: string } | { uri: string; blob: string; mimeType?: string }>, resourceLists: 0, + resourceReads: [] as string[], + templatesUnsupported: false, + missing: [] as string[], templateLists: 0, toolLists: 0, toolCalls: [] as Array<{ @@ -194,10 +206,15 @@ function resourceServer( }) protocol.setRequestHandler("resources/templates/list", (request) => { state.templateLists += 1 + if (state.templatesUnsupported) return Promise.reject(new Error("Method not found")) const page = state.templatePages?.[request.params?.cursor ?? "initial"] return Promise.resolve({ resourceTemplates: page?.items ?? state.templates, nextCursor: page?.nextCursor }) }) - protocol.setRequestHandler("resources/read", () => Promise.resolve({ contents: state.contents })) + protocol.setRequestHandler("resources/read", (request) => { + state.resourceReads.push(request.params.uri) + if (state.missing.includes(request.params.uri)) return Promise.reject(new Error("Resource not found")) + return Promise.resolve({ contents: state.contents }) + }) } return protocol } @@ -1392,6 +1409,152 @@ test("loads and reads MCP resources", async () => { ) }) +it.live("discovers and reads MCP resources through Code Mode", () => + Effect.gen(function* () { + assertion = yield* Deferred.make() + decision = Effect.void + const server = yield* resourceServer() + server.state.resourcePages = { + initial: { items: [{ name: "Readme", uri: "docs://readme" }], nextCursor: "resources-2" }, + "resources-2": { items: [{ name: "Guide", uri: "docs://guide" }] }, + } + server.state.templatePages = { + initial: { items: [{ name: "File", uriTemplate: "docs://{path}" }], nextCursor: "templates-2" }, + "templates-2": { items: [{ name: "Issue", uriTemplate: "issue://{id}" }] }, + } + + yield* Effect.gen(function* () { + const mcp = yield* Mcp.Service + yield* settled(mcp) + yield* registerToolPlugin(McpResourceTools.Plugin).pipe(Effect.provide(permissions)) + const tools = yield* Tool.Service + const snapshot = yield* tools.snapshot() + const sessionID = Session.ID.make("ses_resource_tools") + const run = (code: string) => + snapshot.execute({ + sessionID, + ...toolIdentity, + call: { type: "tool-call", id: "call_resource", name: "execute", input: { code } }, + }) + + // The SDK walks every page, so one call returns the full catalog. + const listed = yield* run('return await tools.opencode.list_mcp_resources({ server: "resources" })') + expect(JSON.parse(listed.output.output)).toEqual({ + resources: [ + { server: "resources", name: "Guide", uri: "docs://guide" }, + { server: "resources", name: "Readme", uri: "docs://readme" }, + ], + templates: [ + { server: "resources", name: "File", uriTemplate: "docs://{path}" }, + { server: "resources", name: "Issue", uriTemplate: "issue://{id}" }, + ], + }) + expect(server.state.resourceReads).toEqual([]) + + // Omitting the server lists every server, so the model can find which one owns a URI. + assertion = yield* Deferred.make() + const everywhere = yield* run("return await tools.opencode.list_mcp_resources({})") + expect(yield* Deferred.await(assertion)).toMatchObject({ + action: "opencode_list_mcp_resources", + resources: ["resources"], + save: ["resources"], + }) + expect(JSON.parse(everywhere.output.output)).toEqual({ + resources: [ + { server: "resources", name: "Guide", uri: "docs://guide" }, + { server: "resources", name: "Readme", uri: "docs://readme" }, + ], + templates: [ + { server: "resources", name: "File", uriTemplate: "docs://{path}" }, + { server: "resources", name: "Issue", uriTemplate: "issue://{id}" }, + ], + }) + + // A server may declare resources without implementing template listing. + server.state.templatesUnsupported = true + const untemplated = yield* run('return await tools.opencode.list_mcp_resources({ server: "resources" })') + expect(JSON.parse(untemplated.output.output)).toEqual({ + resources: [ + { server: "resources", name: "Guide", uri: "docs://guide" }, + { server: "resources", name: "Readme", uri: "docs://readme" }, + ], + templates: [], + }) + server.state.templatesUnsupported = false + + assertion = yield* Deferred.make() + const read = yield* run( + 'const resource = await tools.opencode.read_mcp_resource({ server: "resources", uri: "docs://readme" }); return resource.contents.filter(part => part.type === "text").map(part => part.text).join("\\n")', + ) + expect(read.content).toEqual([ + { type: "text", text: "hello" }, + { type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png" }, + ]) + expect(read.metadata?.toolCalls).toMatchObject([ + { + tool: "opencode.read_mcp_resource", + status: "completed", + input: { server: "resources", uri: "docs://readme" }, + }, + ]) + expect(server.state.resourceReads).toEqual(["docs://readme"]) + expect(yield* Deferred.await(assertion)).toEqual({ + action: "opencode_read_mcp_resource", + resources: ["resources:docs://readme"], + save: ["resources:*"], + metadata: { server: "resources", uri: "docs://readme" }, + sessionID, + agent: toolIdentity.agent, + source: { type: "tool", messageID: toolIdentity.messageID, id: "call_resource" }, + }) + + server.state.contents = [{ uri: "docs://readme", text: "line\n".repeat(20_000), mimeType: "text/plain" }] + const large = yield* run( + 'const resource = await tools.opencode.read_mcp_resource({ server: "resources", uri: "docs://readme" }); return resource.contents[0].text', + ) + const bounded = yield* ToolOutput.Service.use((output) => output.truncate(large)).pipe( + Effect.provide(AppNodeBuilder.build(ToolOutput.node)), + ) + expect(bounded.metadata?.truncated).toBe(true) + expect(bounded.content[0]).toMatchObject({ type: "text" }) + const outputPath = bounded.metadata?.outputPath + expect(typeof outputPath).toBe("string") + if (typeof outputPath !== "string") throw new Error("Missing full resource output") + expect(yield* Effect.promise(() => Bun.file(outputPath).text())).toBe("line\n".repeat(20_000)) + + // An empty contents array means the resource exists without content, not that it is missing. + server.state.contents = [] + const empty = yield* run( + 'return await tools.opencode.read_mcp_resource({ server: "resources", uri: "docs://empty" })', + ) + expect(empty.metadata?.error).toBeUndefined() + expect(JSON.parse(empty.output.output)).toEqual({ server: "resources", uri: "docs://empty", contents: [] }) + server.state.missing = ["docs://gone"] + const gone = yield* run( + 'return await tools.opencode.read_mcp_resource({ server: "resources", uri: "docs://gone" })', + ) + expect(gone.metadata?.error).toBe(true) + expect(gone.output.output).toContain("Unable to read MCP resource resources:docs://gone") + expect(gone.output.output).toContain("Resource not found") + const missing = yield* run( + 'return await tools.opencode.read_mcp_resource({ server: "missing", uri: "docs://readme" })', + ) + expect(missing.metadata?.error).toBe(true) + expect(missing.output.output).toContain("MCP server not found: missing") + + const reads = server.state.resourceReads.length + decision = Effect.fail( + new Permission.BlockedError({ rules: [], permission: "opencode_read_mcp_resource", resources: ["*"] }), + ) + const denied = yield* run( + 'return await tools.opencode.read_mcp_resource({ server: "resources", uri: "docs://denied" })', + ) + expect(denied.metadata?.error).toBe(true) + expect(server.state.resourceReads).toHaveLength(reads) + }).pipe(Effect.provide(resourceMcpLayer(server.url))) + }), +) + test("adds, disconnects, and reconnects MCP servers at runtime", async () => { const published: string[] = [] await Effect.runPromise(