mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-10 11:39:45 -04:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aea42ad0dd | |||
| c92707ca94 | |||
| f7510a8e84 | |||
| 4d6add0985 | |||
| 346c9615c4 | |||
| 08609209bb | |||
| f6a00292e6 | |||
| 805a004f89 | |||
| 0ecc41c83c | |||
| 325f105009 |
@@ -0,0 +1,39 @@
|
|||||||
|
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||||
|
import { isImageAttachment } from "@/util/media"
|
||||||
|
|
||||||
|
type ResourceContent = {
|
||||||
|
uri: string
|
||||||
|
mimeType?: string
|
||||||
|
text?: string
|
||||||
|
blob?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Part = SessionV1.TextPartInput | SessionV1.FilePartInput
|
||||||
|
|
||||||
|
export function toParts(contents: readonly ResourceContent[]): Part[] {
|
||||||
|
return contents.flatMap((content) => {
|
||||||
|
if (content.text) return [{ type: "text", text: content.text }]
|
||||||
|
if (!content.blob) return []
|
||||||
|
|
||||||
|
const mime = content.mimeType?.split(";", 1)[0]?.trim().toLowerCase() || "application/octet-stream"
|
||||||
|
if (isImageAttachment(mime)) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: "file",
|
||||||
|
mime,
|
||||||
|
filename: content.uri,
|
||||||
|
url: `data:${mime};base64,${content.blob}`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `[Binary resource: ${mime}, ${Buffer.from(content.blob, "base64").byteLength} bytes]`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as McpContent from "./content"
|
||||||
@@ -220,7 +220,12 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
|
|||||||
text: part.text,
|
text: part.text,
|
||||||
})
|
})
|
||||||
// text/plain and directory files are converted into text parts, ignore them
|
// text/plain and directory files are converted into text parts, ignore them
|
||||||
if (part.type === "file" && part.mime !== "text/plain" && part.mime !== "application/x-directory") {
|
if (
|
||||||
|
part.type === "file" &&
|
||||||
|
part.mime !== "text/plain" &&
|
||||||
|
part.mime !== "application/x-directory" &&
|
||||||
|
part.source?.type !== "resource"
|
||||||
|
) {
|
||||||
if (options?.stripMedia && isMedia(part.mime)) {
|
if (options?.stripMedia && isMedia(part.mime)) {
|
||||||
userMessage.parts.push({
|
userMessage.parts.push({
|
||||||
type: "text",
|
type: "text",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { Plugin } from "../plugin"
|
|||||||
import MAX_STEPS from "../session/prompt/max-steps.txt"
|
import MAX_STEPS from "../session/prompt/max-steps.txt"
|
||||||
import { ToolRegistry } from "@/tool/registry"
|
import { ToolRegistry } from "@/tool/registry"
|
||||||
import { MCP } from "../mcp"
|
import { MCP } from "../mcp"
|
||||||
|
import { McpContent } from "@/mcp/content"
|
||||||
import { LSP } from "@/lsp/lsp"
|
import { LSP } from "@/lsp/lsp"
|
||||||
import { ulid } from "ulid"
|
import { ulid } from "ulid"
|
||||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||||
@@ -729,27 +730,14 @@ export const layer = Layer.effect(
|
|||||||
if (Exit.isSuccess(exit)) {
|
if (Exit.isSuccess(exit)) {
|
||||||
const content = exit.value
|
const content = exit.value
|
||||||
if (!content) throw new Error(`Resource not found: ${clientName}/${uri}`)
|
if (!content) throw new Error(`Resource not found: ${clientName}/${uri}`)
|
||||||
const items = Array.isArray(content.contents) ? content.contents : [content.contents]
|
pieces.push(
|
||||||
for (const c of items) {
|
...McpContent.toParts(content.contents).map((item) => ({
|
||||||
if ("text" in c && c.text) {
|
...item,
|
||||||
pieces.push({
|
messageID: info.id,
|
||||||
messageID: info.id,
|
sessionID: input.sessionID,
|
||||||
sessionID: input.sessionID,
|
...(item.type === "text" ? { synthetic: true } : {}),
|
||||||
type: "text",
|
})),
|
||||||
synthetic: true,
|
)
|
||||||
text: c.text,
|
|
||||||
})
|
|
||||||
} else if ("blob" in c && c.blob) {
|
|
||||||
const mime = "mimeType" in c ? c.mimeType : part.mime
|
|
||||||
pieces.push({
|
|
||||||
messageID: info.id,
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
type: "text",
|
|
||||||
synthetic: true,
|
|
||||||
text: `[Binary content: ${mime}]`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID })
|
pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID })
|
||||||
} else {
|
} else {
|
||||||
const error = Cause.squash(exit.cause)
|
const error = Cause.squash(exit.cause)
|
||||||
@@ -992,7 +980,7 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const parts = yield* Effect.forEach(resolvedParts, (part) =>
|
const parts = yield* Effect.forEach(resolvedParts, (part) =>
|
||||||
part.type === "file" && part.mime.startsWith("image/")
|
part.type === "file" && part.source?.type !== "resource" && part.mime.startsWith("image/")
|
||||||
? image.normalize(part).pipe(
|
? image.normalize(part).pipe(
|
||||||
Effect.catchIf(
|
Effect.catchIf(
|
||||||
(error) => error instanceof Image.ResizerUnavailableError,
|
(error) => error instanceof Image.ResizerUnavailableError,
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { MCP } from "@/mcp"
|
||||||
|
import { McpContent } from "@/mcp/content"
|
||||||
|
import { Effect, Schema } from "effect"
|
||||||
|
import * as Tool from "./tool"
|
||||||
|
|
||||||
|
export const ListParameters = Schema.Struct({
|
||||||
|
clientName: Schema.optional(Schema.String).annotate({
|
||||||
|
description: "Only list resources from this MCP server",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ReadParameters = Schema.Struct({
|
||||||
|
clientName: Schema.String.annotate({
|
||||||
|
description: "The MCP server that provides the resource",
|
||||||
|
}),
|
||||||
|
uri: Schema.String.annotate({
|
||||||
|
description: "The resource URI to read",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ListMcpResourcesTool = Tool.define(
|
||||||
|
"list_mcp_resources",
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const mcp = yield* MCP.Service
|
||||||
|
|
||||||
|
return {
|
||||||
|
description:
|
||||||
|
"List resources exposed by connected MCP servers. Resources are read-only context such as files, schemas, documents, images, and application data. Use read_mcp_resource with the returned clientName and uri to retrieve one.",
|
||||||
|
parameters: ListParameters,
|
||||||
|
execute: (params: Schema.Schema.Type<typeof ListParameters>) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const resources = Object.values(yield* mcp.resources())
|
||||||
|
.filter((resource) => !params.clientName || resource.client === params.clientName)
|
||||||
|
.toSorted(
|
||||||
|
(a, b) => a.client.localeCompare(b.client) || a.name.localeCompare(b.name) || a.uri.localeCompare(b.uri),
|
||||||
|
)
|
||||||
|
.map((resource) => ({
|
||||||
|
clientName: resource.client,
|
||||||
|
name: resource.name,
|
||||||
|
uri: resource.uri,
|
||||||
|
...(resource.description ? { description: resource.description } : {}),
|
||||||
|
...(resource.mimeType ? { mimeType: resource.mimeType } : {}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: "MCP resources",
|
||||||
|
metadata: { count: resources.length },
|
||||||
|
output: resources.length ? JSON.stringify({ resources }, null, 2) : "No MCP resources found.",
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const ReadMcpResourceTool = Tool.define(
|
||||||
|
"read_mcp_resource",
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const mcp = yield* MCP.Service
|
||||||
|
|
||||||
|
return {
|
||||||
|
description:
|
||||||
|
"Read a resource from a connected MCP server. Use list_mcp_resources to discover resources when needed. Text is returned directly, images are attached for inspection, and other binary data is described by MIME type and size.",
|
||||||
|
parameters: ReadParameters,
|
||||||
|
execute: (params: Schema.Schema.Type<typeof ReadParameters>, ctx: Tool.Context) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* ctx.ask({
|
||||||
|
permission: "read_mcp_resource",
|
||||||
|
patterns: [`${params.clientName}:${params.uri}`],
|
||||||
|
always: [`${params.clientName}:${params.uri}`],
|
||||||
|
metadata: { clientName: params.clientName, uri: params.uri },
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = yield* mcp.readResource(params.clientName, params.uri)
|
||||||
|
if (!result) throw new Error(`Failed to read MCP resource ${params.uri} from ${params.clientName}`)
|
||||||
|
|
||||||
|
const parts = McpContent.toParts(result.contents)
|
||||||
|
const output = parts
|
||||||
|
.filter((part): part is Extract<McpContent.Part, { type: "text" }> => part.type === "text")
|
||||||
|
.map((part) => part.text)
|
||||||
|
.join("\n\n")
|
||||||
|
const attachments = parts.filter(
|
||||||
|
(part): part is Extract<McpContent.Part, { type: "file" }> => part.type === "file",
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${params.clientName}: ${params.uri}`,
|
||||||
|
metadata: { clientName: params.clientName, uri: params.uri },
|
||||||
|
output: output || `Read MCP resource ${params.uri}`,
|
||||||
|
...(attachments.length ? { attachments } : {}),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
@@ -52,6 +52,8 @@ import { BackgroundJob } from "@/background/job"
|
|||||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
|
import { ListMcpResourcesTool, ReadMcpResourceTool } from "./mcp-resource"
|
||||||
|
import { MCP } from "@/mcp"
|
||||||
|
|
||||||
export function webSearchEnabled(providerID: ProviderV2.ID, flags = { exa: false, parallel: false }) {
|
export function webSearchEnabled(providerID: ProviderV2.ID, flags = { exa: false, parallel: false }) {
|
||||||
return providerID === ProviderV2.ID.opencode || flags.exa || flags.parallel
|
return providerID === ProviderV2.ID.opencode || flags.exa || flags.parallel
|
||||||
@@ -105,6 +107,8 @@ export const layer = Layer.effect(
|
|||||||
const greptool = yield* GrepTool
|
const greptool = yield* GrepTool
|
||||||
const patchtool = yield* ApplyPatchTool
|
const patchtool = yield* ApplyPatchTool
|
||||||
const skilltool = yield* SkillTool
|
const skilltool = yield* SkillTool
|
||||||
|
const listMcpResources = yield* ListMcpResourcesTool
|
||||||
|
const readMcpResource = yield* ReadMcpResourceTool
|
||||||
const agent = yield* Agent.Service
|
const agent = yield* Agent.Service
|
||||||
|
|
||||||
const state = yield* InstanceState.make<State>(
|
const state = yield* InstanceState.make<State>(
|
||||||
@@ -212,6 +216,8 @@ export const layer = Layer.effect(
|
|||||||
question: Tool.init(question),
|
question: Tool.init(question),
|
||||||
lsp: Tool.init(lsptool),
|
lsp: Tool.init(lsptool),
|
||||||
plan: Tool.init(plan),
|
plan: Tool.init(plan),
|
||||||
|
listMcpResources: Tool.init(listMcpResources),
|
||||||
|
readMcpResource: Tool.init(readMcpResource),
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -231,6 +237,8 @@ export const layer = Layer.effect(
|
|||||||
tool.search,
|
tool.search,
|
||||||
tool.skill,
|
tool.skill,
|
||||||
tool.patch,
|
tool.patch,
|
||||||
|
tool.listMcpResources,
|
||||||
|
tool.readMcpResource,
|
||||||
...(flags.experimentalLspTool ? [tool.lsp] : []),
|
...(flags.experimentalLspTool ? [tool.lsp] : []),
|
||||||
...(flags.experimentalPlanMode && flags.client === "cli" ? [tool.plan] : []),
|
...(flags.experimentalPlanMode && flags.client === "cli" ? [tool.plan] : []),
|
||||||
],
|
],
|
||||||
@@ -335,6 +343,7 @@ export const defaultLayer = Layer.suspend(() =>
|
|||||||
Layer.provide(Format.defaultLayer),
|
Layer.provide(Format.defaultLayer),
|
||||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||||
Layer.provide(Truncate.defaultLayer),
|
Layer.provide(Truncate.defaultLayer),
|
||||||
|
Layer.provide(MCP.defaultLayer),
|
||||||
)
|
)
|
||||||
.pipe(Layer.provide(Database.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer)),
|
.pipe(Layer.provide(Database.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer)),
|
||||||
)
|
)
|
||||||
@@ -435,6 +444,7 @@ export const node = LayerNode.make(layer.pipe(Layer.provide(Ripgrep.defaultLayer
|
|||||||
Truncate.node,
|
Truncate.node,
|
||||||
RuntimeFlags.node,
|
RuntimeFlags.node,
|
||||||
Database.node,
|
Database.node,
|
||||||
|
MCP.node,
|
||||||
])
|
])
|
||||||
|
|
||||||
export * as ToolRegistry from "./registry"
|
export * as ToolRegistry from "./registry"
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { McpContent } from "@/mcp/content"
|
||||||
|
|
||||||
|
describe("mcp.content", () => {
|
||||||
|
test("converts text, images, and other binary resources", () => {
|
||||||
|
expect(
|
||||||
|
McpContent.toParts([
|
||||||
|
{ uri: "fixture://guide", mimeType: "text/plain", text: "hello" },
|
||||||
|
{ uri: "fixture://picture", mimeType: "IMAGE/PNG; charset=binary", blob: "aGVsbG8=" },
|
||||||
|
{ uri: "fixture://archive", mimeType: "application/zip", blob: "aGVsbG8=" },
|
||||||
|
]),
|
||||||
|
).toEqual([
|
||||||
|
{ type: "text", text: "hello" },
|
||||||
|
{
|
||||||
|
type: "file",
|
||||||
|
mime: "image/png",
|
||||||
|
filename: "fixture://picture",
|
||||||
|
url: "data:image/png;base64,aGVsbG8=",
|
||||||
|
},
|
||||||
|
{ type: "text", text: "[Binary resource: application/zip, 5 bytes]" },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not treat SVG resources as model images", () => {
|
||||||
|
expect(McpContent.toParts([{ uri: "fixture://vector", mimeType: "image/svg+xml", blob: "PHN2Zy8+" }])).toEqual([
|
||||||
|
{ type: "text", text: "[Binary resource: image/svg+xml, 6 bytes]" },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -319,6 +319,43 @@ describe("session.message-v2.toModelMessage", () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("does not forward MCP resource references as file downloads", async () => {
|
||||||
|
const messageID = "m-user"
|
||||||
|
const input: SessionV1.WithParts[] = [
|
||||||
|
{
|
||||||
|
info: userInfo(messageID),
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
...basePart(messageID, "p1"),
|
||||||
|
type: "text",
|
||||||
|
text: "# Resource contents",
|
||||||
|
synthetic: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...basePart(messageID, "p2"),
|
||||||
|
type: "file",
|
||||||
|
mime: "application/json",
|
||||||
|
filename: "status",
|
||||||
|
url: "status://info",
|
||||||
|
source: {
|
||||||
|
type: "resource",
|
||||||
|
clientName: "resource-only-fixture",
|
||||||
|
uri: "status://info",
|
||||||
|
text: { value: "@status", start: 0, end: 7 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as SessionV1.Part[],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [{ type: "text", text: "# Resource contents" }],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
test("converts assistant tool completion into tool-call + tool-result messages with attachments", async () => {
|
test("converts assistant tool completion into tool-call + tool-result messages with attachments", async () => {
|
||||||
const userID = "m-user"
|
const userID = "m-user"
|
||||||
const assistantID = "m-assistant"
|
const assistantID = "m-assistant"
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
|
||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect, Layer } from "effect"
|
||||||
|
import { Agent } from "@/agent/agent"
|
||||||
|
import { MCP } from "@/mcp"
|
||||||
|
import { MessageID, SessionID } from "@/session/schema"
|
||||||
|
import { ListMcpResourcesTool, ReadMcpResourceTool } from "@/tool/mcp-resource"
|
||||||
|
import { Tool } from "@/tool/tool"
|
||||||
|
import { Truncate } from "@/tool/truncate"
|
||||||
|
import { testEffect } from "../lib/effect"
|
||||||
|
|
||||||
|
const mcp = Layer.mock(MCP.Service, {
|
||||||
|
resources: () =>
|
||||||
|
Effect.succeed({
|
||||||
|
"zeta:guide": {
|
||||||
|
client: "zeta",
|
||||||
|
name: "guide",
|
||||||
|
uri: "fixture://guide",
|
||||||
|
description: "Guide",
|
||||||
|
mimeType: "text/plain",
|
||||||
|
},
|
||||||
|
"alpha:picture": {
|
||||||
|
client: "alpha",
|
||||||
|
name: "picture",
|
||||||
|
uri: "fixture://picture",
|
||||||
|
mimeType: "image/png",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
readResource: (clientName, uri) =>
|
||||||
|
Effect.succeed(
|
||||||
|
clientName === "alpha" && uri === "fixture://picture"
|
||||||
|
? {
|
||||||
|
contents: [
|
||||||
|
{ uri, mimeType: "text/plain", text: "caption" },
|
||||||
|
{ uri, mimeType: "image/png", blob: "aGVsbG8=" },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
const it = testEffect(Layer.mergeAll(mcp, Truncate.defaultLayer, Agent.defaultLayer))
|
||||||
|
|
||||||
|
const baseCtx: Omit<Tool.Context, "ask"> = {
|
||||||
|
sessionID: SessionID.make("ses_test"),
|
||||||
|
messageID: MessageID.make("msg_test"),
|
||||||
|
agent: "build",
|
||||||
|
abort: AbortSignal.any([]),
|
||||||
|
messages: [],
|
||||||
|
metadata: () => Effect.void,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("tool.mcp-resource", () => {
|
||||||
|
it.instance("lists resources in a deterministic order", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const info = yield* ListMcpResourcesTool
|
||||||
|
const tool = yield* info.init()
|
||||||
|
const result = yield* tool.execute({}, { ...baseCtx, ask: () => Effect.void })
|
||||||
|
|
||||||
|
expect(JSON.parse(result.output)).toEqual({
|
||||||
|
resources: [
|
||||||
|
{ clientName: "alpha", name: "picture", uri: "fixture://picture", mimeType: "image/png" },
|
||||||
|
{
|
||||||
|
clientName: "zeta",
|
||||||
|
name: "guide",
|
||||||
|
uri: "fixture://guide",
|
||||||
|
description: "Guide",
|
||||||
|
mimeType: "text/plain",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.instance("returns text and image content from a resource", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
|
||||||
|
const info = yield* ReadMcpResourceTool
|
||||||
|
const tool = yield* info.init()
|
||||||
|
const result = yield* tool.execute(
|
||||||
|
{ clientName: "alpha", uri: "fixture://picture" },
|
||||||
|
{
|
||||||
|
...baseCtx,
|
||||||
|
ask: (request) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
requests.push(request)
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.output).toBe("caption")
|
||||||
|
expect(result.attachments).toEqual([
|
||||||
|
{
|
||||||
|
type: "file",
|
||||||
|
mime: "image/png",
|
||||||
|
filename: "fixture://picture",
|
||||||
|
url: "data:image/png;base64,aGVsbG8=",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(requests[0]).toMatchObject({
|
||||||
|
permission: "read_mcp_resource",
|
||||||
|
patterns: ["alpha:fixture://picture"],
|
||||||
|
always: ["alpha:fixture://picture"],
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -72,6 +72,8 @@ describe("tool.registry", () => {
|
|||||||
const ids = yield* registry.ids()
|
const ids = yield* registry.ids()
|
||||||
|
|
||||||
expect(ids).not.toContain("task_status")
|
expect(ids).not.toContain("task_status")
|
||||||
|
expect(ids).toContain("list_mcp_resources")
|
||||||
|
expect(ids).toContain("read_mcp_resource")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user