feat: list & read resources as codemode tools (#50680)

This commit is contained in:
Filip
2026-09-23 22:08:03 +02:00
committed by GitHub
parent fb4422ef62
commit 33b686feb9
7 changed files with 338 additions and 79 deletions
-27
View File
@@ -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<ComposerSuggestion[]>(() => [
...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,
+68 -49
View File
@@ -120,10 +120,11 @@ export interface Interface extends State.Transformable<Editor> {
readonly args?: Record<string, string>
}) => Effect.Effect<PromptResult | undefined, NotFoundError>
readonly resourceCatalog: () => Effect.Effect<ResourceCatalog>
readonly resources: (input: { readonly server: ServerName | string }) => Effect.Effect<ResourceCatalog, Error>
readonly readResource: (input: {
readonly server: ServerName | string
readonly uri: string
}) => Effect.Effect<ResourceContent | undefined, NotFoundError>
}) => Effect.Effect<ResourceContent | undefined, Error>
}
export class Service extends Context.Service<Service, Interface>()("@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<ResourceCatalog>) {
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,
+2
View File
@@ -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,
@@ -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)
}),
}
+1 -1
View File
@@ -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",
+1
View File
@@ -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,
}),
)
+165 -2
View File
@@ -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<Permission.AssertInput> | undefined
let decision: Effect.Effect<void, Permission.Error> = 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<Permission.AssertInput>()
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<Permission.AssertInput>()
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<Permission.AssertInput>()
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(