mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
feat: list & read resources as codemode tools (#50680)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user