mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-29 06:43:47 +00:00
2827 lines
109 KiB
TypeScript
2827 lines
109 KiB
TypeScript
import path from "node:path"
|
|
import fs from "node:fs/promises"
|
|
import os from "node:os"
|
|
import { describe, expect, test } from "bun:test"
|
|
import { Client, InMemoryTransport, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"
|
|
import {
|
|
createMcpHandler,
|
|
inputRequired,
|
|
inputResponse,
|
|
Server,
|
|
WebStandardStreamableHTTPServerTransport,
|
|
} from "@modelcontextprotocol/server"
|
|
import { Document, Event, Info } from "@opencode/schema/config"
|
|
import { ConfigMCP } from "@opencode/schema/config/mcp"
|
|
import { McpEvent } from "@opencode/schema/mcp-event"
|
|
import { ConfigPolicy } from "@opencode/schema/config/policy"
|
|
import { Config } from "@opencode/core/config"
|
|
import { ConfigMcpPlugin } from "@opencode/core/config/plugin/mcp"
|
|
import { Credential } from "@opencode/core/credential"
|
|
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
|
import { LayerNode } from "@opencode/util/effect/layer-node"
|
|
import { Bus } from "@opencode/core/bus"
|
|
import { ID, type Payload } from "@opencode/schema/event"
|
|
import { Form } from "@opencode/core/form"
|
|
import { Integration } from "@opencode/core/integration"
|
|
import { Environment } from "@opencode/core/environment/index"
|
|
import { EnvironmentUnavailable } from "@opencode/core/environment/unavailable"
|
|
import { Location } from "@opencode/core/location"
|
|
import { Mcp } from "@opencode/core/mcp/index"
|
|
import { McpClient } from "@opencode/core/mcp/client"
|
|
import { McpStdio } from "@opencode/core/mcp/stdio"
|
|
import { Permission } from "@opencode/core/permission"
|
|
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,
|
|
Effect,
|
|
Exit,
|
|
Fiber,
|
|
Layer,
|
|
Option,
|
|
PubSub,
|
|
Ref,
|
|
Schedule,
|
|
Schema,
|
|
Scope,
|
|
Sink,
|
|
Stream,
|
|
} from "effect"
|
|
import { TestClock } from "effect/testing"
|
|
import { ChildProcess, ChildProcessSpawner } from "effect/process"
|
|
import { ExitCode, makeHandle, ProcessId } from "effect/process/ChildProcessSpawner"
|
|
import { Image } from "@opencode/core/image"
|
|
import { Global } from "@opencode/util/global"
|
|
import { advance, drain } from "./lib/clock"
|
|
import { testEffect } from "./lib/effect"
|
|
import { registerIntegrationPolicy, setOrganizationPolicy } from "./fixture/policy"
|
|
import { imagePassthrough } from "./lib/image"
|
|
import { location } from "./fixture/location"
|
|
import { tmpdirScoped } from "./fixture/tmpdir"
|
|
import { hostEnvironmentLayer, recordingEnvironmentLayer } from "./fixture/environment"
|
|
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
|
|
let calls = 0
|
|
let invocations: Array<Parameters<Mcp.Interface["callTool"]>[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: {
|
|
/** Serve 2026-07-28 only through createMcpHandler; the default is a sessionful legacy transport. */
|
|
modern?: boolean
|
|
resources?: boolean
|
|
listChanged?: boolean
|
|
emptyElicitation?: boolean
|
|
urlElicitation?: boolean
|
|
/**
|
|
* List a tool whose input schema is not an object, and one whose `required` is a string, which
|
|
* only the 2026-07-28 schema accepts, ahead of the valid tools.
|
|
*/
|
|
malformedTool?: boolean
|
|
/** Legacy servers give up on the URL elicitation after this many milliseconds. */
|
|
elicitationTimeout?: number
|
|
respond?: (request: Request) => Response | undefined | Promise<Response | undefined>
|
|
} = {},
|
|
) {
|
|
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,
|
|
resourceReads: [] as string[],
|
|
templatesUnsupported: false,
|
|
missing: [] as string[],
|
|
templateLists: 0,
|
|
toolLists: 0,
|
|
toolCalls: [] as Array<{
|
|
name: string
|
|
arguments: Record<string, unknown> | undefined
|
|
sessionID: unknown
|
|
progressToken: unknown
|
|
}>,
|
|
initializations: 0,
|
|
urls: [] as string[],
|
|
sessions: [] as string[],
|
|
}
|
|
// One Server speaks one session, so a restart is a fresh Server and transport. Requests that
|
|
// still carry the previous session id are then unknown to the new transport.
|
|
const server = () => {
|
|
const protocol = new Server(
|
|
{ name: "mcp-resources", title: "MCP Resources", version: "1.0.0" },
|
|
{
|
|
capabilities: {
|
|
tools: {},
|
|
prompts: {},
|
|
...(input.resources === false ? {} : { resources: { listChanged: input.listChanged } }),
|
|
},
|
|
instructions: "Use the resources tools.",
|
|
},
|
|
)
|
|
protocol.setRequestHandler("tools/list", () => {
|
|
state.toolLists += 1
|
|
return Promise.resolve({
|
|
tools: [
|
|
// Servers send raw JSON; the cast lets the fixture send a tool the schema rejects.
|
|
...(input.malformedTool
|
|
? [
|
|
{ name: "malformed", inputSchema: { type: "string" } } as unknown as McpClient.Tool,
|
|
{
|
|
name: "modern-only",
|
|
inputSchema: { type: "object", required: "text" },
|
|
} as unknown as McpClient.Tool,
|
|
]
|
|
: []),
|
|
...(input.emptyElicitation
|
|
? [{ name: "empty-elicitation", inputSchema: { type: "object" as const, properties: {} } }]
|
|
: input.urlElicitation
|
|
? [{ name: "url-elicitation", inputSchema: { type: "object" as const, properties: {} } }]
|
|
: [{ name: "echo", inputSchema: { type: "object" as const, properties: {} } }]),
|
|
],
|
|
})
|
|
})
|
|
if (input.emptyElicitation) {
|
|
protocol.setRequestHandler("tools/call", async () => {
|
|
const result = await protocol.elicitInput({
|
|
mode: "form",
|
|
message: "Confirm",
|
|
requestedSchema: { type: "object", properties: {} },
|
|
})
|
|
return {
|
|
content: [{ type: "text", text: JSON.stringify(result) }],
|
|
structuredContent: result,
|
|
}
|
|
})
|
|
}
|
|
if (input.urlElicitation) {
|
|
const url = "https://example.com/authorize"
|
|
// Modern servers cannot call elicitInput; they return input_required and the client retries.
|
|
protocol.setRequestHandler("tools/call", async (request, ctx) => {
|
|
const responses = ctx.mcpReq.inputResponses
|
|
if (input.modern && !responses)
|
|
return inputRequired({ inputRequests: { auth: inputRequired.elicitUrl({ message: "Authorize", url }) } })
|
|
const response = inputResponse(responses, "auth")
|
|
const result = input.modern
|
|
? { action: response.kind === "elicit" ? response.action : "cancel" }
|
|
: await protocol.elicitInput(
|
|
{
|
|
mode: "url",
|
|
message: "Authorize access",
|
|
url,
|
|
elicitationId: "elicitation-test",
|
|
},
|
|
input.elicitationTimeout === undefined ? undefined : { timeout: input.elicitationTimeout },
|
|
)
|
|
return {
|
|
content: [{ type: "text", text: JSON.stringify(result) }],
|
|
structuredContent: result,
|
|
}
|
|
})
|
|
}
|
|
if (!input.emptyElicitation && !input.urlElicitation) {
|
|
protocol.setRequestHandler("tools/call", (request) => {
|
|
state.toolCalls.push({
|
|
name: request.params.name,
|
|
arguments: request.params.arguments,
|
|
sessionID: request.params._meta?.["ai.opencode/sessionID"],
|
|
progressToken: request.params._meta?.progressToken,
|
|
})
|
|
return Promise.resolve({ content: [] })
|
|
})
|
|
}
|
|
protocol.setRequestHandler("prompts/list", () => Promise.resolve({ prompts: [{ name: "greet" }] }))
|
|
protocol.setRequestHandler("prompts/get", (request) =>
|
|
Promise.resolve({
|
|
messages: [{ role: "user", content: { type: "text", text: `hi ${request.params.arguments?.name}` } }],
|
|
}),
|
|
)
|
|
if (input.resources !== false) {
|
|
protocol.setRequestHandler("resources/list", (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("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", (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
|
|
}
|
|
const build = async () => {
|
|
const protocol = server()
|
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
sessionIdGenerator: () => crypto.randomUUID(),
|
|
enableJsonResponse: true,
|
|
})
|
|
await protocol.connect(transport)
|
|
return { protocol, transport }
|
|
}
|
|
let current = await build()
|
|
const modern = input.modern ? createMcpHandler(server, { legacy: "reject" }) : undefined
|
|
const http = Bun.serve({
|
|
port: 0,
|
|
fetch: async (request) => {
|
|
state.urls.push(request.url)
|
|
const session = request.headers.get("mcp-session-id")
|
|
if (session !== null && !state.sessions.includes(session)) state.sessions.push(session)
|
|
const body: unknown = request.method === "POST" ? await request.clone().json() : undefined
|
|
if (typeof body === "object" && body !== null && "method" in body && body.method === "initialize") {
|
|
state.initializations += 1
|
|
}
|
|
return (await input.respond?.(request)) ?? modern?.fetch(request) ?? current.transport.handleRequest(request)
|
|
},
|
|
})
|
|
return {
|
|
state,
|
|
url: http.url.toString(),
|
|
clientVersion: () => current.protocol.getClientVersion(),
|
|
sendResourceListChanged: () =>
|
|
modern ? Promise.resolve(modern.notify.resourcesChanged()) : current.protocol.sendResourceListChanged(),
|
|
completeElicitation: () => current.protocol.createElicitationCompletionNotifier("elicitation-test")(),
|
|
restart: async () => {
|
|
await current.protocol.close().catch(() => {})
|
|
current = await build()
|
|
},
|
|
close: async () => {
|
|
await current.protocol.close().catch(() => {})
|
|
await modern?.close()
|
|
await http.stop(true)
|
|
},
|
|
}
|
|
}),
|
|
(server) => Effect.promise(server.close),
|
|
)
|
|
}
|
|
|
|
function resourceMcpLayer(
|
|
server: string | typeof ConfigMCP.Server.Type,
|
|
onFormCreated?: (form: Form.Info) => Effect.Effect<void>,
|
|
options?: Mcp.Options,
|
|
overrides?: {
|
|
policies?: readonly ConfigPolicy.Info[]
|
|
entries?: Config.Interface["entries"]
|
|
subscribe?: Bus.Interface["subscribe"]
|
|
environment?: Layer.Layer<Environment.Service>
|
|
published?: string[]
|
|
},
|
|
) {
|
|
const directory = AbsolutePath.make(import.meta.dir)
|
|
const unusedIntegration = () => Effect.die("unused integration service")
|
|
return Layer.effectDiscard(
|
|
Effect.gen(function* () {
|
|
const bus = yield* Bus.Service
|
|
const mcp = yield* Mcp.Service
|
|
yield* State.batch(
|
|
Effect.gen(function* () {
|
|
yield* ConfigMcpPlugin.register(bus.subscribe())
|
|
yield* registerIntegrationPolicy({ mcp, events: bus.subscribe() })
|
|
}),
|
|
)
|
|
}),
|
|
).pipe(
|
|
Layer.provideMerge(Mcp.layer(options)),
|
|
Layer.provideMerge(Form.layer),
|
|
Layer.provide(
|
|
Layer.mergeAll(
|
|
overrides?.entries
|
|
? Layer.succeed(
|
|
Config.Service,
|
|
Config.Service.of({
|
|
entries: overrides.entries,
|
|
changes: () => Stream.never,
|
|
}),
|
|
)
|
|
: Config.testLayer([
|
|
new Document({
|
|
type: "document",
|
|
info: new Info({
|
|
policies: overrides?.policies ?? [],
|
|
mcp: new ConfigMCP.Info({
|
|
servers: {
|
|
resources:
|
|
typeof server === "string"
|
|
? new ConfigMCP.Remote({ type: "remote", url: server, oauth: false })
|
|
: server,
|
|
},
|
|
}),
|
|
}),
|
|
}),
|
|
]),
|
|
Layer.succeed(Location.Service, Location.Service.of(location({ directory }))),
|
|
Layer.mock(Bus.Service, {
|
|
subscribe: overrides?.subscribe ?? (() => Stream.never),
|
|
publish: (definition, data) => {
|
|
const event = {
|
|
id: ID.create(),
|
|
type: definition.type,
|
|
data,
|
|
} as Payload<typeof definition>
|
|
overrides?.published?.push(event.type)
|
|
if (event.type !== Form.Event.Created.type || !onFormCreated) return Effect.succeed(event)
|
|
return onFormCreated(Schema.decodeUnknownSync(Form.Event.Created.data)(data).form).pipe(Effect.as(event))
|
|
},
|
|
}),
|
|
Layer.mock(Integration.Service, {
|
|
revision: () => 0,
|
|
connection: {
|
|
active: unusedIntegration,
|
|
resolve: unusedIntegration,
|
|
key: unusedIntegration,
|
|
external: unusedIntegration,
|
|
activate: unusedIntegration,
|
|
update: unusedIntegration,
|
|
remove: unusedIntegration,
|
|
status: unusedIntegration,
|
|
},
|
|
oauth: {
|
|
connect: unusedIntegration,
|
|
status: unusedIntegration,
|
|
complete: unusedIntegration,
|
|
cancel: unusedIntegration,
|
|
},
|
|
command: {
|
|
connect: unusedIntegration,
|
|
status: unusedIntegration,
|
|
cancel: unusedIntegration,
|
|
},
|
|
}),
|
|
Layer.mock(Credential.Service, {}),
|
|
overrides?.environment ?? hostEnvironmentLayer,
|
|
),
|
|
),
|
|
)
|
|
}
|
|
|
|
const connect = (server: string, config: typeof ConfigMCP.Server.Type, directory: string) =>
|
|
McpClient.connect(server, config, directory).pipe(Effect.provide(hostEnvironmentLayer))
|
|
|
|
// Reads no longer wait for startup, so tests that assert on a connected server settle it first.
|
|
const settled = (service: Mcp.Interface, name = "resources") =>
|
|
Effect.gen(function* () {
|
|
const status = (yield* service.servers()).find((server) => server.name === name)?.status
|
|
if (status?.status === "pending") return yield* Effect.fail(status)
|
|
return status
|
|
}).pipe(Effect.retry({ times: 200, schedule: Schedule.spaced("10 millis") }))
|
|
|
|
const mcp = Layer.mock(Mcp.Service, {
|
|
tools: () =>
|
|
Effect.succeed([
|
|
{
|
|
server: Mcp.ServerName.make("demo"),
|
|
name: "search",
|
|
description: "Search",
|
|
inputSchema: { type: "object", properties: {} },
|
|
outputSchema: {
|
|
type: "object",
|
|
properties: { ok: { type: "boolean" } },
|
|
required: ["ok"],
|
|
},
|
|
} satisfies Mcp.Tool,
|
|
{
|
|
server: Mcp.ServerName.make("demo"),
|
|
name: "status",
|
|
description: "Status",
|
|
inputSchema: { type: "object", properties: {} },
|
|
} satisfies Mcp.Tool,
|
|
{
|
|
server: Mcp.ServerName.make("demo"),
|
|
name: "issues",
|
|
description: "Returns JSON as text",
|
|
inputSchema: { type: "object", properties: {} },
|
|
} satisfies Mcp.Tool,
|
|
{
|
|
server: Mcp.ServerName.make("demo"),
|
|
name: "count",
|
|
description: "Returns a number as text",
|
|
inputSchema: { type: "object", properties: {} },
|
|
} satisfies Mcp.Tool,
|
|
{
|
|
server: Mcp.ServerName.make("demo"),
|
|
name: "typed",
|
|
description: "Declares a string output and returns JSON as text",
|
|
inputSchema: { type: "object", properties: {} },
|
|
outputSchema: { type: "string" },
|
|
} satisfies Mcp.Tool,
|
|
{
|
|
server: Mcp.ServerName.make("direct"),
|
|
name: "issues",
|
|
codemode: false,
|
|
description: "Returns JSON as text",
|
|
inputSchema: { type: "object", properties: {} },
|
|
} satisfies Mcp.Tool,
|
|
{
|
|
server: Mcp.ServerName.make("direct"),
|
|
name: "lookup",
|
|
codemode: false,
|
|
description: "Lookup",
|
|
inputSchema: { type: "object", properties: {} },
|
|
} satisfies Mcp.Tool,
|
|
{
|
|
server: Mcp.ServerName.make("direct"),
|
|
name: "fail",
|
|
codemode: false,
|
|
description: "Always fails",
|
|
inputSchema: { type: "object", properties: {} },
|
|
} satisfies Mcp.Tool,
|
|
{
|
|
server: Mcp.ServerName.make("direct"),
|
|
name: "media",
|
|
codemode: false,
|
|
description: "Returns text and an image",
|
|
inputSchema: { type: "object", properties: {} },
|
|
} satisfies Mcp.Tool,
|
|
{
|
|
server: Mcp.ServerName.make("direct"),
|
|
name: "attachments",
|
|
codemode: false,
|
|
description: "Returns resource blobs that cannot be sent inline",
|
|
inputSchema: { type: "object", properties: {} },
|
|
} satisfies Mcp.Tool,
|
|
]),
|
|
callTool: (input) =>
|
|
Effect.sync(() => {
|
|
calls += 1
|
|
invocations.push(input)
|
|
if (input.name === "fail")
|
|
return {
|
|
server: Mcp.ServerName.make(input.server),
|
|
tool: input.name,
|
|
isError: true,
|
|
content: [{ type: "text", text: "search index unavailable" }],
|
|
} satisfies Mcp.ToolResult
|
|
if (input.name === "media")
|
|
return {
|
|
server: Mcp.ServerName.make(input.server),
|
|
tool: input.name,
|
|
isError: false,
|
|
content: [
|
|
{ type: "text", text: "rendered chart" },
|
|
{ type: "media", data: "aGVsbG8=", mimeType: "image/png" },
|
|
],
|
|
} satisfies Mcp.ToolResult
|
|
if (input.name === "attachments")
|
|
return {
|
|
server: Mcp.ServerName.make(input.server),
|
|
tool: input.name,
|
|
isError: false,
|
|
content: [
|
|
{
|
|
type: "media",
|
|
data: Buffer.from("# Notes").toString("base64"),
|
|
mimeType: "text/markdown",
|
|
uri: "file:///README.md",
|
|
},
|
|
{
|
|
type: "media",
|
|
data: "UEFSMQ==",
|
|
mimeType: "application/vnd.apache.parquet",
|
|
uri: "file:///data.parquet",
|
|
},
|
|
],
|
|
} satisfies Mcp.ToolResult
|
|
if (input.name === "status")
|
|
return {
|
|
server: Mcp.ServerName.make(input.server),
|
|
tool: input.name,
|
|
isError: false,
|
|
content: [{ type: "text", text: "hello" }],
|
|
} satisfies Mcp.ToolResult
|
|
if (input.name === "issues" || input.name === "typed")
|
|
return {
|
|
server: Mcp.ServerName.make(input.server),
|
|
tool: input.name,
|
|
isError: false,
|
|
content: [{ type: "text", text: '{"issues":[{"id":1}]}' }],
|
|
} satisfies Mcp.ToolResult
|
|
if (input.name === "count")
|
|
return {
|
|
server: Mcp.ServerName.make(input.server),
|
|
tool: input.name,
|
|
isError: false,
|
|
content: [{ type: "text", text: "42" }],
|
|
} satisfies Mcp.ToolResult
|
|
return {
|
|
server: Mcp.ServerName.make(input.server),
|
|
tool: input.name,
|
|
isError: false,
|
|
structured: { ok: true },
|
|
content: [],
|
|
} satisfies Mcp.ToolResult
|
|
}),
|
|
})
|
|
const permissions = Layer.mock(Permission.Service, {
|
|
assert: (input) =>
|
|
Effect.gen(function* () {
|
|
if (!assertion) return yield* Effect.die("Permission test is not initialized")
|
|
yield* Deferred.succeed(assertion, input)
|
|
yield* decision
|
|
}),
|
|
})
|
|
const events = Layer.mock(Bus.Service, { subscribe: () => Stream.never })
|
|
const outputData = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-mcp-output-"))
|
|
const it = testEffect(
|
|
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node]), [
|
|
Mcp.node.replace(mcp),
|
|
Permission.node.replace(permissions),
|
|
Bus.node.replace(events),
|
|
Image.node.replace(imagePassthrough),
|
|
Global.node.replace(Global.layerWith({ data: outputData })),
|
|
]),
|
|
)
|
|
|
|
describe("MCP errors", () => {
|
|
test("expose useful messages", () => {
|
|
expect(new Mcp.NotFoundError({ server: Mcp.ServerName.make("demo") }).message).toBe("MCP server not found: demo")
|
|
expect(
|
|
new Mcp.ToolCallError({ server: Mcp.ServerName.make("demo"), tool: "search", message: "failed" }).message,
|
|
).toBe("failed")
|
|
expect(new McpClient.NeedsAuthError({ server: "demo", message: "Unauthorized" }).message).toBe("Unauthorized")
|
|
expect(new McpClient.ConnectError({ server: "demo", message: "offline" }).message).toBe("offline")
|
|
})
|
|
})
|
|
|
|
test("MCP tool names match V1 sanitization", () => {
|
|
expect(McpTool.namespace("context 7")).toBe("context_7")
|
|
expect(McpTool.name("context 7", "resolve.library/id")).toBe("context_7_resolve_library_id")
|
|
})
|
|
|
|
test("passes session IDs as MCP request metadata", async () => {
|
|
await Effect.runPromise(
|
|
Effect.scoped(
|
|
Effect.gen(function* () {
|
|
const server = yield* resourceServer()
|
|
const connection = yield* connect(
|
|
"session-metadata",
|
|
new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }),
|
|
import.meta.dir,
|
|
)
|
|
yield* connection.callTool({
|
|
name: "echo",
|
|
args: { text: "hello" },
|
|
sessionID: Session.ID.make("ses_mcp_metadata"),
|
|
})
|
|
yield* connection.callTool({ name: "echo" })
|
|
|
|
expect(server.state.toolCalls).toEqual([
|
|
{
|
|
name: "echo",
|
|
arguments: { text: "hello" },
|
|
sessionID: "ses_mcp_metadata",
|
|
progressToken: expect.any(Number),
|
|
},
|
|
{
|
|
name: "echo",
|
|
arguments: {},
|
|
sessionID: undefined,
|
|
progressToken: expect.any(Number),
|
|
},
|
|
])
|
|
expect(server.state.toolCalls[0]?.progressToken).not.toBe(server.state.toolCalls[1]?.progressToken)
|
|
}),
|
|
),
|
|
)
|
|
})
|
|
|
|
test("preserves output schema validation across paginated tool discovery", async () => {
|
|
const server = new Server({ name: "pagination", version: "1.0.0" }, { capabilities: { tools: {} } })
|
|
server.setRequestHandler("tools/list", ({ params }) =>
|
|
Promise.resolve(
|
|
params?.cursor === "page-2"
|
|
? {
|
|
tools: [
|
|
{
|
|
name: "second",
|
|
inputSchema: { type: "object" },
|
|
outputSchema: {
|
|
type: "object",
|
|
properties: { value: { type: "number" } },
|
|
required: ["value"],
|
|
},
|
|
},
|
|
],
|
|
}
|
|
: {
|
|
tools: [
|
|
{
|
|
name: "first",
|
|
inputSchema: { type: "object" },
|
|
outputSchema: {
|
|
type: "object",
|
|
properties: { value: { type: "string" } },
|
|
required: ["value"],
|
|
},
|
|
},
|
|
],
|
|
nextCursor: "page-2",
|
|
},
|
|
),
|
|
)
|
|
server.setRequestHandler("tools/call", ({ params }) =>
|
|
Promise.resolve({
|
|
content: [],
|
|
structuredContent: { value: params.name === "first" ? 42 : 1 },
|
|
}),
|
|
)
|
|
|
|
const client = new Client({ name: "pagination-test", version: "1.0.0" })
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)])
|
|
|
|
try {
|
|
// Without a cursor the SDK walks every page; the page-1 validator must survive the page-2 fetch.
|
|
const listed = await client.listTools()
|
|
expect(listed.tools.map((tool) => tool.name)).toEqual(["first", "second"])
|
|
expect(listed.nextCursor).toBeUndefined()
|
|
await expect(client.callTool({ name: "first", arguments: {} })).rejects.toThrow(
|
|
"Structured content does not match the tool's output schema",
|
|
)
|
|
await expect(client.callTool({ name: "second", arguments: {} })).resolves.toMatchObject({
|
|
structuredContent: { value: 1 },
|
|
})
|
|
} finally {
|
|
await Promise.all([client.close(), server.close()])
|
|
}
|
|
})
|
|
|
|
test("retains output schemas across paginated MCP discovery", async () => {
|
|
const tools = await Effect.runPromise(
|
|
Effect.scoped(
|
|
Effect.gen(function* () {
|
|
const connection = yield* connect(
|
|
"pagination",
|
|
new ConfigMCP.Local({
|
|
type: "local",
|
|
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
|
|
}),
|
|
import.meta.dir,
|
|
)
|
|
return yield* connection.tools()
|
|
}),
|
|
),
|
|
)
|
|
|
|
expect(tools.map((tool) => ({ name: tool.name, outputSchema: tool.outputSchema }))).toEqual([
|
|
{
|
|
name: "first",
|
|
outputSchema: {
|
|
type: "object",
|
|
properties: { value: { type: "string" } },
|
|
required: ["value"],
|
|
},
|
|
},
|
|
{
|
|
name: "second",
|
|
outputSchema: {
|
|
type: "object",
|
|
properties: { value: { type: "number" } },
|
|
required: ["value"],
|
|
},
|
|
},
|
|
])
|
|
})
|
|
|
|
test("accepts legacy MCP tools whose output schema is not a root object", async () => {
|
|
const result = await Effect.runPromise(
|
|
Effect.scoped(
|
|
Effect.gen(function* () {
|
|
const connection = yield* connect(
|
|
"union",
|
|
new ConfigMCP.Local({
|
|
type: "local",
|
|
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-union-output-schema.ts")],
|
|
}),
|
|
import.meta.dir,
|
|
)
|
|
return {
|
|
tools: yield* connection.tools(),
|
|
call: yield* connection.callTool({ name: "union" }),
|
|
}
|
|
}),
|
|
),
|
|
)
|
|
|
|
expect(result.tools.map((tool) => tool.name)).toEqual(["union"])
|
|
expect(result.tools[0]?.outputSchema).toHaveProperty("anyOf")
|
|
expect(result.call.structured).toEqual({ kind: "struct" })
|
|
})
|
|
|
|
test("lists paginated prompts and invokes them through the MCP client", async () => {
|
|
const result = await Effect.runPromise(
|
|
Effect.scoped(
|
|
Effect.gen(function* () {
|
|
const connection = yield* connect(
|
|
"prompts",
|
|
new ConfigMCP.Local({
|
|
type: "local",
|
|
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-prompts.ts")],
|
|
}),
|
|
import.meta.dir,
|
|
)
|
|
return {
|
|
prompts: yield* connection.prompts(),
|
|
result: yield* connection.prompt({ name: "first", args: { topic: "Effect" } }),
|
|
}
|
|
}),
|
|
),
|
|
)
|
|
|
|
expect(result.prompts).toEqual([
|
|
{
|
|
name: "first",
|
|
description: "First prompt",
|
|
arguments: [{ name: "topic", description: "Topic to explain", required: true }],
|
|
},
|
|
{ name: "second", description: "Second prompt", arguments: undefined },
|
|
])
|
|
expect(result.result).toEqual({ messages: [{ role: "user", content: { type: "text", text: "Effect" } }] })
|
|
})
|
|
|
|
test("spawns local MCP servers through the location environment", async () => {
|
|
const spawns: Array<ChildProcess.Command> = []
|
|
const cwd = path.join(import.meta.dir, "fixture")
|
|
const config = new ConfigMCP.Local({
|
|
type: "local",
|
|
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
|
|
cwd: "fixture",
|
|
environment: { MCP_LOCATION_TEST: "configured" },
|
|
})
|
|
|
|
await Effect.runPromise(
|
|
Effect.scoped(
|
|
Effect.gen(function* () {
|
|
const connection = yield* McpClient.connect("environment", config, import.meta.dir)
|
|
yield* connection.tools()
|
|
}),
|
|
).pipe(Effect.provide(recordingEnvironmentLayer(spawns))),
|
|
)
|
|
|
|
expect(spawns).toHaveLength(1)
|
|
const command = spawns[0]
|
|
if (!command || !ChildProcess.isStandardCommand(command)) throw new Error("Expected a standard process command")
|
|
expect(command.command).toBe(process.execPath)
|
|
expect(command.options.cwd).toBe(cwd)
|
|
expect(command.options.extendEnv).toBe(true)
|
|
expect(command.options.env).toEqual({ MCP_LOCATION_TEST: "configured" })
|
|
})
|
|
|
|
test("reports a local MCP server as failed when the location has no execution plane", async () => {
|
|
const config = new ConfigMCP.Local({ type: "local", command: ["example-mcp"] })
|
|
const driver = Environment.makeMemoryDriver()
|
|
const environment = Layer.succeed(
|
|
Environment.Service,
|
|
Environment.Service.of({ files: Environment.makeFiles(driver), spawner: EnvironmentUnavailable.spawner }),
|
|
)
|
|
|
|
await Effect.runPromise(
|
|
Effect.gen(function* () {
|
|
const service = yield* Mcp.Service
|
|
const status = yield* settled(service)
|
|
expect(status).toEqual({
|
|
status: "failed",
|
|
error: expect.stringContaining("location has no execution plane"),
|
|
})
|
|
}).pipe(Effect.provide(resourceMcpLayer(config, undefined, undefined, { environment }))),
|
|
)
|
|
})
|
|
|
|
test("rejects sends before the stdio transport is started", async () => {
|
|
await Effect.runPromise(
|
|
Effect.scoped(
|
|
Effect.gen(function* () {
|
|
const transport = yield* McpStdio.make({
|
|
server: "not-started",
|
|
command: process.execPath,
|
|
args: [path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
|
|
cwd: import.meta.dir,
|
|
environment: {},
|
|
})
|
|
yield* Effect.tryPromise({
|
|
try: () => transport.send({ jsonrpc: "2.0", method: "notifications/initialized" }),
|
|
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
|
|
}).pipe(
|
|
Effect.flip,
|
|
Effect.tap((error) => Effect.sync(() => expect(error.message).toBe("Not connected"))),
|
|
)
|
|
}).pipe(Effect.provide(hostEnvironmentLayer)),
|
|
),
|
|
)
|
|
})
|
|
|
|
test("joins concurrent stdio transport closes", async () => {
|
|
await Effect.runPromise(
|
|
Effect.scoped(
|
|
Effect.gen(function* () {
|
|
const transport = yield* McpStdio.make({
|
|
server: "concurrent-close",
|
|
command: "unused",
|
|
args: [],
|
|
cwd: import.meta.dir,
|
|
environment: {},
|
|
})
|
|
const first = transport.close()
|
|
expect(transport.close()).toBe(first)
|
|
yield* Effect.promise(() => first)
|
|
}).pipe(Effect.provide(hostEnvironmentLayer)),
|
|
),
|
|
)
|
|
})
|
|
|
|
const testMcpDescendants =
|
|
process.platform === "win32" ? testEffect(hostEnvironmentLayer).live.skip : testEffect(hostEnvironmentLayer).live
|
|
testMcpDescendants(
|
|
"terminates MCP descendants after the wrapper exits successfully",
|
|
Effect.gen(function* () {
|
|
const tmp = yield* tmpdirScoped()
|
|
const pidFile = path.join(tmp.path, "child.pid")
|
|
yield* Effect.addFinalizer(() =>
|
|
Effect.tryPromise(async () => process.kill(Number(await fs.readFile(pidFile, "utf8")), "SIGKILL")).pipe(
|
|
Effect.ignore,
|
|
),
|
|
)
|
|
const ready = yield* Deferred.make<void>()
|
|
// Stdin EOF exits the wrapper, but its ready descendant ignores SIGTERM and retains both pipes.
|
|
const transport = yield* McpStdio.make({
|
|
server: "held-stdio",
|
|
command: "node",
|
|
args: [path.join(import.meta.dir, "fixture/held-stdio.cjs"), "mcp", pidFile],
|
|
cwd: tmp.path,
|
|
environment: {},
|
|
})
|
|
transport.onmessage = () => {
|
|
Deferred.doneUnsafe(ready, Exit.void)
|
|
}
|
|
yield* Effect.promise(() => transport.start())
|
|
yield* Deferred.await(ready).pipe(Effect.timeout("3 seconds"))
|
|
const pid = Number(yield* Effect.promise(() => fs.readFile(pidFile, "utf8")))
|
|
|
|
yield* Effect.promise(() => transport.close()).pipe(Effect.timeout("6 seconds"))
|
|
|
|
// close() must kill the descendant, not merely observe the wrapper's bounded exitCode.
|
|
const stopped = yield* Effect.try(() => process.kill(pid, 0)).pipe(
|
|
Effect.exit,
|
|
Effect.repeat({ while: Exit.isSuccess, schedule: Schedule.spaced("25 millis") }),
|
|
Effect.timeout("2 seconds"),
|
|
)
|
|
expect(Exit.isFailure(stopped)).toBe(true)
|
|
}),
|
|
15_000,
|
|
)
|
|
|
|
test("closes a stdio process that finishes spawning after close", async () => {
|
|
const spawning = Deferred.makeUnsafe<void>()
|
|
const release = Deferred.makeUnsafe<void>()
|
|
const exited = Deferred.makeUnsafe<ExitCode>()
|
|
const signals: Array<string> = []
|
|
const driver = Environment.makeMemoryDriver()
|
|
const environment = Layer.succeed(
|
|
Environment.Service,
|
|
Environment.Service.of({
|
|
files: Environment.makeFiles(driver),
|
|
spawner: ChildProcessSpawner.make(() =>
|
|
Effect.gen(function* () {
|
|
yield* Deferred.succeed(spawning, undefined)
|
|
yield* Deferred.await(release)
|
|
return makeHandle({
|
|
pid: ProcessId(1),
|
|
exitCode: Deferred.await(exited),
|
|
isRunning: Deferred.isDone(exited).pipe(Effect.map((done) => !done)),
|
|
kill: (options) =>
|
|
Effect.gen(function* () {
|
|
signals.push(options?.killSignal ?? "SIGTERM")
|
|
yield* Deferred.succeed(exited, ExitCode(143))
|
|
}),
|
|
stdin: Sink.drain,
|
|
stdout: Stream.never,
|
|
stderr: Stream.empty,
|
|
all: Stream.never,
|
|
getInputFd: () => Sink.drain,
|
|
getOutputFd: () => Stream.empty,
|
|
unref: Effect.succeed(Effect.void),
|
|
})
|
|
}),
|
|
),
|
|
}),
|
|
)
|
|
|
|
await Effect.runPromise(
|
|
Effect.scoped(
|
|
Effect.gen(function* () {
|
|
const transport = yield* McpStdio.make({
|
|
server: "close-during-spawn",
|
|
command: "unused",
|
|
args: [],
|
|
cwd: import.meta.dir,
|
|
environment: {},
|
|
})
|
|
const start = transport.start()
|
|
yield* Deferred.await(spawning)
|
|
const close = transport.close()
|
|
yield* Deferred.succeed(release, undefined)
|
|
yield* Effect.promise(() => Promise.all([start, close]))
|
|
}).pipe(Effect.provide(environment)),
|
|
),
|
|
)
|
|
|
|
expect(signals).toEqual(["SIGTERM"])
|
|
})
|
|
|
|
test("applies the configured MCP catalog timeout", async () => {
|
|
const result = Effect.runPromise(
|
|
Effect.scoped(
|
|
Effect.gen(function* () {
|
|
const connection = yield* connect(
|
|
"catalog-timeout",
|
|
new ConfigMCP.Local({
|
|
type: "local",
|
|
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")],
|
|
environment: { MCP_TIMEOUT_TARGET: "catalog" },
|
|
timeout: new ConfigMCP.Timeout({ catalog: 10 }),
|
|
}),
|
|
import.meta.dir,
|
|
)
|
|
return yield* connection.tools()
|
|
}),
|
|
),
|
|
)
|
|
|
|
await expect(result).rejects.toThrow("Request timed out")
|
|
})
|
|
|
|
test("applies the configured MCP execution timeout", async () => {
|
|
const result = Effect.runPromise(
|
|
Effect.scoped(
|
|
Effect.gen(function* () {
|
|
const connection = yield* connect(
|
|
"execution-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.callTool({ name: "slow" })
|
|
}),
|
|
),
|
|
)
|
|
|
|
await expect(result).rejects.toThrow("Request timed out")
|
|
})
|
|
|
|
test("applies the configured MCP execution timeout to prompts", async () => {
|
|
const result = Effect.runPromise(
|
|
Effect.scoped(
|
|
Effect.gen(function* () {
|
|
const connection = yield* connect(
|
|
"prompt-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.prompt({ name: "slow" })
|
|
}),
|
|
),
|
|
)
|
|
|
|
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* 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* 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")
|
|
})
|
|
|
|
for (const entry of [
|
|
{ name: "direct tools", query: "", codemode: false, expected: "" },
|
|
{ name: "explicit remote code mode", query: "?codemode=true", codemode: undefined, expected: "?codemode=true" },
|
|
{ name: "explicit remote opt-out", query: "?codemode=false", codemode: undefined, expected: "?codemode=false" },
|
|
{ name: "portal opt-out", query: "?codemode=off", codemode: undefined, expected: "?codemode=off" },
|
|
]) {
|
|
testEffect(Layer.empty).live(`remote MCP code mode preference: ${entry.name}`, () =>
|
|
Effect.gen(function* () {
|
|
const server = yield* resourceServer()
|
|
const config = new ConfigMCP.Remote({
|
|
type: "remote",
|
|
url: server.url + entry.query,
|
|
oauth: false,
|
|
codemode: entry.codemode,
|
|
})
|
|
const connection = yield* connect("resources", config, import.meta.dir)
|
|
yield* connection.tools()
|
|
expect(server.state.initializations).toBe(1)
|
|
expect(server.state.toolLists).toBe(1)
|
|
expect(server.state.urls.length).toBeGreaterThanOrEqual(3)
|
|
expect(new Set(server.state.urls)).toEqual(new Set([server.url + entry.expected]))
|
|
expect(config.url).toBe(server.url + entry.query)
|
|
}),
|
|
)
|
|
}
|
|
|
|
for (const entry of [
|
|
{ name: "401", status: 401, query: "", codemode: undefined, attempts: 1 },
|
|
{ name: "403", status: 403, query: "", codemode: undefined, attempts: 1 },
|
|
{ name: "501", status: 501, query: "", codemode: undefined, attempts: 1 },
|
|
{ name: "user codemode=true", status: 404, query: "?codemode=true", codemode: undefined, attempts: 1 },
|
|
{ name: "user codemode=false", status: 404, query: "?codemode=false", codemode: undefined, attempts: 1 },
|
|
{ name: "empty user codemode", status: 404, query: "?codemode=", codemode: undefined, attempts: 1 },
|
|
{ name: "direct tools", status: 404, query: "", codemode: false, attempts: 1 },
|
|
{ name: "400 with user codemode=true", status: 400, query: "?codemode=true", codemode: undefined, attempts: 1 },
|
|
{ name: "400 with user codemode=false", status: 400, query: "?codemode=false", codemode: undefined, attempts: 1 },
|
|
{ name: "400 with empty user codemode", status: 400, query: "?codemode=", codemode: undefined, attempts: 1 },
|
|
{ name: "400 with direct tools", status: 400, query: "", codemode: false, attempts: 1 },
|
|
]) {
|
|
testEffect(Layer.empty).live(`does not retry MCP beyond the query fallback: ${entry.name}`, () =>
|
|
Effect.gen(function* () {
|
|
const server = yield* resourceServer({
|
|
respond: () => new Response(null, { status: entry.status }),
|
|
})
|
|
const config = new ConfigMCP.Remote({
|
|
type: "remote",
|
|
url: server.url + entry.query,
|
|
codemode: entry.codemode,
|
|
oauth: false,
|
|
})
|
|
const error = yield* connect("resources", config, import.meta.dir).pipe(Effect.flip)
|
|
|
|
expect(error).toBeInstanceOf(McpClient.ConnectError)
|
|
expect(server.state.initializations).toBe(entry.attempts)
|
|
expect(server.state.urls).toHaveLength(entry.attempts)
|
|
if (entry.query || entry.codemode === false) expect(server.state.urls).toEqual([config.url])
|
|
}),
|
|
)
|
|
}
|
|
|
|
test("reconnects and retries a tool call after the MCP session expires", async () => {
|
|
await Effect.runPromise(
|
|
Effect.scoped(
|
|
Effect.gen(function* () {
|
|
const server = yield* resourceServer()
|
|
yield* Effect.gen(function* () {
|
|
const service = yield* Mcp.Service
|
|
yield* service.callTool({ server: "resources", name: "echo", args: { n: 1 } })
|
|
expect(server.state.toolCalls).toHaveLength(1)
|
|
expect(server.state.initializations).toBe(1)
|
|
|
|
// The restarted server does not know the client's session, so the next request 404s.
|
|
yield* Effect.promise(server.restart)
|
|
const result = yield* service.callTool({ server: "resources", name: "echo", args: { n: 2 } })
|
|
|
|
expect(result.isError).toBe(false)
|
|
expect(server.state.toolCalls.map((call) => call.arguments)).toEqual([{ n: 1 }, { n: 2 }])
|
|
expect(server.state.initializations).toBe(2)
|
|
expect(server.state.sessions).toHaveLength(2)
|
|
expect((yield* service.servers()).find((entry) => entry.name === "resources")).toMatchObject({
|
|
status: { status: "connected" },
|
|
title: "MCP Resources",
|
|
})
|
|
}).pipe(Effect.provide(resourceMcpLayer(server.url)))
|
|
}),
|
|
),
|
|
)
|
|
})
|
|
|
|
describe.each([
|
|
["legacy", undefined],
|
|
["modern", "2026-07-28"],
|
|
] as const)("MCP connection over the %s protocol", (era, protocol) => {
|
|
test("exposes every connection operation", async () => {
|
|
await Effect.runPromise(
|
|
Effect.scoped(
|
|
Effect.gen(function* () {
|
|
const server = yield* resourceServer({ modern: era === "modern", listChanged: true })
|
|
server.state.resources = [{ name: "Readme", uri: "docs://readme" }]
|
|
server.state.templates = [{ name: "File", uriTemplate: "docs://{path}" }]
|
|
const connection = yield* connect(
|
|
"resources",
|
|
new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false, protocol }),
|
|
import.meta.dir,
|
|
)
|
|
|
|
expect(connection.modern).toBe(era === "modern")
|
|
expect(connection.title).toBe("MCP Resources")
|
|
expect(connection.instructions).toBe("Use the resources tools.")
|
|
expect((yield* connection.tools()).map((tool) => tool.name)).toEqual(["echo"])
|
|
expect((yield* connection.prompts()).map((prompt) => prompt.name)).toEqual(["greet"])
|
|
expect((yield* connection.resources()).map((resource) => resource.uri)).toEqual(["docs://readme"])
|
|
expect((yield* connection.resourceTemplates()).map((template) => template.uriTemplate)).toEqual([
|
|
"docs://{path}",
|
|
])
|
|
expect((yield* connection.readResource({ uri: "docs://readme" }))?.contents).toHaveLength(2)
|
|
expect((yield* connection.prompt({ name: "greet", args: { name: "bob" } })).messages[0]?.content).toEqual({
|
|
type: "text",
|
|
text: "hi bob",
|
|
})
|
|
const sessionID = Session.ID.make("ses_mcp_era")
|
|
yield* connection.callTool({ name: "echo", args: { text: "hi" }, sessionID })
|
|
expect(server.state.toolCalls.at(-1)).toMatchObject({ name: "echo", sessionID })
|
|
|
|
const changed = yield* Deferred.make<void>()
|
|
connection.onResourcesChanged(() => Deferred.doneUnsafe(changed, Exit.void))
|
|
yield* Effect.promise(server.sendResourceListChanged)
|
|
yield* Deferred.await(changed)
|
|
}),
|
|
),
|
|
)
|
|
})
|
|
|
|
test("skips tools the SDK rejects and keeps the rest of the server's tools", async () => {
|
|
await Effect.runPromise(
|
|
Effect.scoped(
|
|
Effect.gen(function* () {
|
|
const server = yield* resourceServer({ modern: era === "modern", resources: false, malformedTool: true })
|
|
const connection = yield* connect(
|
|
"malformed",
|
|
new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false, protocol }),
|
|
import.meta.dir,
|
|
)
|
|
|
|
// Only tools the SDK itself would reject for the negotiated era are skipped.
|
|
expect((yield* connection.tools()).map((tool) => tool.name)).toEqual(
|
|
era === "modern" ? ["modern-only", "echo"] : ["echo"],
|
|
)
|
|
yield* connection.callTool({ name: "echo", args: { text: "hi" } })
|
|
expect(server.state.toolCalls.at(-1)).toMatchObject({ name: "echo", arguments: { text: "hi" } })
|
|
}),
|
|
),
|
|
)
|
|
})
|
|
})
|
|
|
|
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* 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: [
|
|
{ uri: "docs://readme", text: "hello", mimeType: "text/plain" },
|
|
{ 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("does not reconnect an SSE stream after a JSON-RPC error response", async () => {
|
|
let requests = 0
|
|
const transport = new StreamableHTTPClientTransport(new URL("http://mcp.invalid"), {
|
|
fetch: async () => {
|
|
requests += 1
|
|
return new Response(
|
|
new ReadableStream({
|
|
start(controller) {
|
|
controller.enqueue(new TextEncoder().encode("id: prime\nretry: 1\ndata:\n\n"))
|
|
controller.enqueue(
|
|
new TextEncoder().encode(
|
|
'id: error\ndata: {"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not found"},"id":1}\n\n',
|
|
),
|
|
)
|
|
controller.close()
|
|
},
|
|
}),
|
|
{ status: 200, headers: { "content-type": "text/event-stream" } },
|
|
)
|
|
},
|
|
reconnectionOptions: {
|
|
initialReconnectionDelay: 1,
|
|
maxReconnectionDelay: 1,
|
|
reconnectionDelayGrowFactor: 1,
|
|
maxRetries: 2,
|
|
},
|
|
})
|
|
|
|
await transport.start()
|
|
await transport.send({ jsonrpc: "2.0", method: "resources/list", id: 1 })
|
|
await Bun.sleep(25)
|
|
await transport.close()
|
|
|
|
expect(requests).toBe(1)
|
|
})
|
|
|
|
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* 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,
|
|
})
|
|
}),
|
|
),
|
|
)
|
|
})
|
|
|
|
type CreatedForm = { readonly form: Form.Info; readonly directory: string | undefined }
|
|
|
|
// Clients drop `form.created` events without a Location, so the form must be published inside the server's Location.
|
|
const recordForm = (created: Deferred.Deferred<CreatedForm>, form: Form.Info) =>
|
|
Effect.serviceOption(Location.Service).pipe(
|
|
Effect.flatMap((location) =>
|
|
Deferred.succeed(created, { form, directory: Option.getOrUndefined(location)?.directory }),
|
|
),
|
|
Effect.asVoid,
|
|
)
|
|
|
|
test("accepts empty MCP elicitations without creating forms", async () => {
|
|
await Effect.runPromise(
|
|
Effect.scoped(
|
|
Effect.gen(function* () {
|
|
const server = yield* resourceServer({ resources: false, emptyElicitation: true })
|
|
const result = yield* Effect.gen(function* () {
|
|
const service = yield* Mcp.Service
|
|
const forms = yield* Form.Service
|
|
const result = yield* service.callTool({ server: "resources", name: "empty-elicitation" })
|
|
expect(yield* forms.list()).toEqual([])
|
|
return result
|
|
}).pipe(Effect.provide(resourceMcpLayer(server.url)))
|
|
|
|
expect(result.structured).toEqual({ action: "accept", content: {} })
|
|
}),
|
|
),
|
|
)
|
|
})
|
|
|
|
test("acknowledges completed MCP URL elicitations without returning internal content", async () => {
|
|
await Effect.runPromise(
|
|
Effect.scoped(
|
|
Effect.gen(function* () {
|
|
const server = yield* resourceServer({ resources: false, urlElicitation: true })
|
|
const created = yield* Deferred.make<CreatedForm>()
|
|
const result = yield* Effect.gen(function* () {
|
|
const service = yield* Mcp.Service
|
|
const forms = yield* Form.Service
|
|
const call = yield* service.callTool({ server: "resources", name: "url-elicitation" }).pipe(Effect.forkScoped)
|
|
|
|
const { form, directory } = yield* Deferred.await(created)
|
|
expect(directory).toBe(import.meta.dir)
|
|
expect(form.fields).toEqual([{ key: "elicitation", type: "external", url: "https://example.com/authorize" }])
|
|
|
|
yield* Effect.promise(server.completeElicitation)
|
|
const result = yield* Fiber.join(call)
|
|
expect(yield* forms.state(form.id)).toEqual({ status: "answered", answer: { elicitation: true } })
|
|
return result
|
|
}).pipe(
|
|
Effect.provide(resourceMcpLayer(server.url, (form) => recordForm(created, form))),
|
|
)
|
|
|
|
expect(result.structured).toEqual({ action: "accept" })
|
|
}),
|
|
),
|
|
)
|
|
})
|
|
|
|
test("cancels MCP elicitation forms when the server gives up on them", async () => {
|
|
await Effect.runPromise(
|
|
Effect.scoped(
|
|
Effect.gen(function* () {
|
|
const server = yield* resourceServer({ resources: false, urlElicitation: true, elicitationTimeout: 200 })
|
|
const created = yield* Deferred.make<CreatedForm>()
|
|
yield* Effect.gen(function* () {
|
|
const service = yield* Mcp.Service
|
|
const forms = yield* Form.Service
|
|
const call = yield* service.callTool({ server: "resources", name: "url-elicitation" }).pipe(Effect.forkScoped)
|
|
const { form } = yield* Deferred.await(created)
|
|
yield* Fiber.await(call)
|
|
// The server's cancellation and its tool error race; the form settles shortly after either.
|
|
const state = yield* forms.state(form.id).pipe(
|
|
Effect.repeat({ until: (state) => state.status !== "pending", schedule: Schedule.spaced("10 millis") }),
|
|
Effect.timeout("2 seconds"),
|
|
)
|
|
expect(state.status).toBe("cancelled")
|
|
}).pipe(Effect.provide(resourceMcpLayer(server.url, (form) => recordForm(created, form))))
|
|
}),
|
|
),
|
|
)
|
|
})
|
|
|
|
test("settles modern MCP URL elicitations when the user confirms", async () => {
|
|
await Effect.runPromise(
|
|
Effect.scoped(
|
|
Effect.gen(function* () {
|
|
const server = yield* resourceServer({ modern: true, resources: false, urlElicitation: true })
|
|
const created = yield* Deferred.make<CreatedForm>()
|
|
const result = yield* Effect.gen(function* () {
|
|
const service = yield* Mcp.Service
|
|
const forms = yield* Form.Service
|
|
const call = yield* service.callTool({ server: "resources", name: "url-elicitation" }).pipe(Effect.forkScoped)
|
|
|
|
const { form, directory } = yield* Deferred.await(created)
|
|
expect(directory).toBe(import.meta.dir)
|
|
expect(form.metadata).not.toHaveProperty("elicitationID")
|
|
yield* forms.reply({ id: form.id, answer: { elicitation: true } })
|
|
return yield* Fiber.join(call)
|
|
}).pipe(
|
|
Effect.provide(
|
|
resourceMcpLayer(
|
|
new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false, protocol: "2026-07-28" }),
|
|
(form) => recordForm(created, form),
|
|
),
|
|
),
|
|
)
|
|
|
|
expect(result.structured).toEqual({ action: "accept" })
|
|
}),
|
|
),
|
|
)
|
|
})
|
|
|
|
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
|
|
yield* settled(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" },
|
|
],
|
|
})
|
|
expect(server.clientVersion()).toMatchObject({ name: "sdk", version: "1.2.3" })
|
|
}).pipe(
|
|
Effect.provide(resourceMcpLayer(server.url, undefined, { clientInfo: { name: "sdk", version: "1.2.3" } })),
|
|
)
|
|
}),
|
|
),
|
|
)
|
|
})
|
|
|
|
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(
|
|
Effect.scoped(
|
|
Effect.gen(function* () {
|
|
const server = yield* resourceServer({ modern: true, resources: false })
|
|
const config = { type: "remote", url: server.url, oauth: false, protocol: "2026-07-28" } as const
|
|
yield* Effect.gen(function* () {
|
|
const service = yield* Mcp.Service
|
|
|
|
expect((yield* service.servers())[0]?.status).toEqual({ status: "disabled" })
|
|
expect(published).toContain(McpEvent.StatusChanged.type)
|
|
expect(yield* service.connect("missing").pipe(Effect.flip)).toBeInstanceOf(Mcp.NotFoundError)
|
|
expect(yield* service.disconnect("missing").pipe(Effect.flip)).toBeInstanceOf(Mcp.NotFoundError)
|
|
yield* service.add("dynamic", new ConfigMCP.Remote(config))
|
|
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status).toEqual({
|
|
status: "connected",
|
|
})
|
|
|
|
yield* service.add("dynamic", new ConfigMCP.Remote({ ...config, disabled: true }))
|
|
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status).toEqual({
|
|
status: "disabled",
|
|
})
|
|
expect(yield* service.tools()).toEqual([])
|
|
|
|
yield* service.connect("dynamic")
|
|
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status).toEqual({
|
|
status: "connected",
|
|
})
|
|
yield* service.disconnect("dynamic")
|
|
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status).toEqual({
|
|
status: "disabled",
|
|
})
|
|
expect(yield* service.tools()).toEqual([])
|
|
|
|
yield* service.connect("dynamic")
|
|
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status).toEqual({
|
|
status: "connected",
|
|
})
|
|
|
|
yield* service.remove("dynamic")
|
|
expect((yield* service.servers()).some((server) => server.name === "dynamic")).toBe(false)
|
|
expect(yield* service.tools()).toEqual([])
|
|
expect(yield* service.remove("dynamic").pipe(Effect.flip)).toBeInstanceOf(Mcp.NotFoundError)
|
|
}).pipe(
|
|
Effect.provide(
|
|
resourceMcpLayer(
|
|
new ConfigMCP.Local({ type: "local", command: ["unused"], disabled: true }),
|
|
undefined,
|
|
undefined,
|
|
{ published },
|
|
),
|
|
),
|
|
)
|
|
}),
|
|
),
|
|
)
|
|
})
|
|
|
|
testEffect(Layer.empty).live(
|
|
"merges MCP defaults into the winning configured server without changing runtime overrides",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const entries = [
|
|
new Document({
|
|
type: "document",
|
|
info: new Info({
|
|
mcp: new ConfigMCP.Info({
|
|
timeout: { startup: 10, catalog: 20, execution: 30 },
|
|
servers: {
|
|
resources: { type: "local", command: ["earlier"], disabled: true, timeout: { execution: 90 } },
|
|
pinned: { type: "local", command: ["pinned"], disabled: true, protocol: "2026-07-28" },
|
|
},
|
|
}),
|
|
}),
|
|
}),
|
|
new Document({
|
|
type: "document",
|
|
info: new Info({
|
|
mcp: new ConfigMCP.Info({
|
|
timeout: { catalog: 40 },
|
|
servers: {
|
|
resources: { type: "local", command: ["later"], disabled: true, timeout: { startup: 50 } },
|
|
},
|
|
}),
|
|
}),
|
|
}),
|
|
]
|
|
const original = JSON.stringify(entries)
|
|
yield* Effect.gen(function* () {
|
|
const service = yield* Mcp.Service
|
|
const check = yield* service.transform((editor) => {
|
|
expect(editor.get("resources")).toEqual({
|
|
type: "local",
|
|
command: ["later"],
|
|
disabled: true,
|
|
timeout: { startup: 50, catalog: 40, execution: 30 },
|
|
})
|
|
expect(editor.get("pinned")).toEqual({
|
|
type: "local",
|
|
command: ["pinned"],
|
|
disabled: true,
|
|
timeout: { startup: 10, catalog: 40, execution: 30 },
|
|
protocol: "2026-07-28",
|
|
})
|
|
})
|
|
yield* check.dispose
|
|
const runtime = {
|
|
type: "local",
|
|
command: ["runtime"],
|
|
disabled: true,
|
|
timeout: { catalog: 60 },
|
|
} satisfies ConfigMCP.Local
|
|
yield* service.add("resources", runtime)
|
|
yield* service.reload()
|
|
yield* service.transform((editor) => {
|
|
expect(editor.get("resources")).toEqual(runtime)
|
|
})
|
|
}).pipe(
|
|
Effect.provide(
|
|
resourceMcpLayer("https://unused.example", undefined, undefined, {
|
|
entries: () => Effect.succeed(entries),
|
|
}),
|
|
),
|
|
)
|
|
expect(JSON.stringify(entries)).toBe(original)
|
|
}),
|
|
)
|
|
|
|
testEffect(resourceMcpLayer(new ConfigMCP.Local({ type: "local", command: ["unused"], disabled: true }))).live(
|
|
"manages live MCP servers entirely through scoped transforms",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const service = yield* Mcp.Service
|
|
const server = yield* resourceServer({ modern: true, resources: false })
|
|
|
|
yield* Effect.scoped(
|
|
Effect.gen(function* () {
|
|
yield* service.transform((editor) => {
|
|
editor.set("dynamic", { type: "remote", url: server.url, oauth: false, protocol: "2026-07-28" })
|
|
})
|
|
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status.status).toBe(
|
|
"connected",
|
|
)
|
|
expect(yield* service.tools()).toHaveLength(1)
|
|
|
|
yield* service.transform((editor) => editor.update("dynamic", (server) => (server.codemode = false)))
|
|
expect((yield* service.tools()).map((tool) => tool.codemode)).toEqual([false])
|
|
|
|
const settings = { disabled: true }
|
|
yield* service.transform((editor) => {
|
|
editor.update("dynamic", (server) => {
|
|
server.disabled = settings.disabled
|
|
})
|
|
})
|
|
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status.status).toBe("disabled")
|
|
expect(yield* service.tools()).toEqual([])
|
|
|
|
settings.disabled = false
|
|
yield* service.reload()
|
|
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status.status).toBe(
|
|
"connected",
|
|
)
|
|
expect(yield* service.tools()).toHaveLength(1)
|
|
|
|
const removed = yield* service.transform((editor) => editor.remove("dynamic"))
|
|
expect((yield* service.servers()).some((server) => server.name === "dynamic")).toBe(false)
|
|
expect(yield* service.tools()).toEqual([])
|
|
|
|
yield* removed.dispose
|
|
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status.status).toBe(
|
|
"connected",
|
|
)
|
|
expect(yield* service.tools()).toHaveLength(1)
|
|
}),
|
|
)
|
|
|
|
expect((yield* service.servers()).map((server) => server.name)).toEqual([Mcp.ServerName.make("resources")])
|
|
expect(yield* service.tools()).toEqual([])
|
|
}),
|
|
)
|
|
|
|
test("restores runtime MCP config when a transform is disposed", async () => {
|
|
await Effect.runPromise(
|
|
Effect.scoped(
|
|
Effect.gen(function* () {
|
|
const service = yield* Mcp.Service
|
|
const config = new ConfigMCP.Remote({
|
|
type: "remote",
|
|
url: "https://example.com/mcp",
|
|
headers: { Authorization: "original" },
|
|
oauth: false,
|
|
disabled: true,
|
|
})
|
|
yield* service.add("dynamic", config)
|
|
const transformed = yield* service.transform((editor) =>
|
|
editor.update("dynamic", (server) => {
|
|
if (server.type === "remote") server.headers = { Authorization: "transformed" }
|
|
}),
|
|
)
|
|
let observed: string | undefined
|
|
yield* service.transform((editor) => {
|
|
const server = editor.get("dynamic")
|
|
observed = server?.type === "remote" ? server.headers?.Authorization : undefined
|
|
})
|
|
|
|
expect(observed).toBe("transformed")
|
|
expect(config.headers?.Authorization).toBe("original")
|
|
yield* transformed.dispose
|
|
expect(observed).toBe("original")
|
|
}).pipe(
|
|
Effect.provide(resourceMcpLayer(new ConfigMCP.Local({ type: "local", command: ["unused"], disabled: true }))),
|
|
),
|
|
),
|
|
)
|
|
})
|
|
|
|
test("isolates nested configured MCP mutations and reconciles them", async () => {
|
|
const published: string[] = []
|
|
const config = new ConfigMCP.Remote({
|
|
type: "remote",
|
|
url: "https://example.com/mcp",
|
|
headers: { Authorization: "original" },
|
|
oauth: false,
|
|
disabled: true,
|
|
})
|
|
await Effect.runPromise(
|
|
Effect.scoped(
|
|
Effect.gen(function* () {
|
|
const service = yield* Mcp.Service
|
|
expect(published.filter((type) => type === McpEvent.StatusChanged.type)).toHaveLength(1)
|
|
yield* service.transform((editor) =>
|
|
editor.update("resources", (server) => {
|
|
if (server.type === "remote" && server.headers) server.headers.Authorization = "transformed"
|
|
}),
|
|
)
|
|
|
|
expect(config.headers?.Authorization).toBe("original")
|
|
expect(published.filter((type) => type === McpEvent.StatusChanged.type)).toHaveLength(2)
|
|
}).pipe(Effect.provide(resourceMcpLayer(config, undefined, undefined, { published }))),
|
|
),
|
|
)
|
|
})
|
|
|
|
testEffect(Layer.empty).live("batches MCP transforms without connecting intermediate configurations", () =>
|
|
Effect.gen(function* () {
|
|
const server = yield* resourceServer()
|
|
yield* Effect.gen(function* () {
|
|
const service = yield* Mcp.Service
|
|
const registrations = yield* State.batch(
|
|
Effect.gen(function* () {
|
|
const added = yield* service.transform((editor) =>
|
|
editor.set("dynamic", {
|
|
type: "remote",
|
|
url: server.url,
|
|
oauth: false,
|
|
}),
|
|
)
|
|
expect((yield* service.servers()).some((server) => server.name === "dynamic")).toBe(false)
|
|
const disabled = yield* service.transform((editor) =>
|
|
editor.update("dynamic", (config) => (config.disabled = true)),
|
|
)
|
|
return [added, disabled]
|
|
}),
|
|
)
|
|
|
|
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status).toEqual({
|
|
status: "disabled",
|
|
})
|
|
expect(yield* service.tools()).toEqual([])
|
|
yield* State.batch(Effect.forEach(registrations, (registration) => registration.dispose))
|
|
expect((yield* service.servers()).some((server) => server.name === "dynamic")).toBe(false)
|
|
expect(server.state.initializations).toBe(0)
|
|
}).pipe(
|
|
Effect.provide(resourceMcpLayer(new ConfigMCP.Local({ type: "local", command: ["unused"], disabled: true }))),
|
|
)
|
|
}),
|
|
)
|
|
|
|
test("reconciles only changed MCP server config", async () => {
|
|
await Effect.runPromise(
|
|
Effect.scoped(
|
|
Effect.gen(function* () {
|
|
const server = yield* resourceServer()
|
|
const updates = yield* PubSub.unbounded<Payload>()
|
|
const resources = (codemode?: boolean) =>
|
|
new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false, codemode })
|
|
const added = new ConfigMCP.Local({ type: "local", command: ["unused"], disabled: true })
|
|
const dynamic = new ConfigMCP.Local({ type: "local", command: ["unused"], disabled: true })
|
|
const document = (servers: Record<string, typeof ConfigMCP.Server.Type>, username?: string) =>
|
|
new Document({
|
|
type: "document",
|
|
info: new Info({
|
|
username,
|
|
mcp: new ConfigMCP.Info({ servers }),
|
|
}),
|
|
})
|
|
let entries = [document({ resources: resources() })]
|
|
const publishUpdate = () =>
|
|
PubSub.publish(updates, {
|
|
id: ID.create(),
|
|
created: 0,
|
|
type: Event.Updated.type,
|
|
data: {},
|
|
} satisfies Payload<typeof Event.Updated>)
|
|
|
|
yield* Effect.gen(function* () {
|
|
const service = yield* Mcp.Service
|
|
yield* settled(service)
|
|
expect(server.state.toolLists).toBe(1)
|
|
expect(server.state.initializations).toBe(1)
|
|
|
|
yield* service.add("dynamic", dynamic)
|
|
entries = [document({ resources: resources() }, "unrelated")]
|
|
yield* publishUpdate()
|
|
entries = [document({ resources: resources(), added }, "unrelated")]
|
|
yield* publishUpdate()
|
|
const appended = yield* service.servers().pipe(
|
|
Effect.filterOrFail(
|
|
(items) => items.some((item) => item.name === "added"),
|
|
() => new Error("MCP config addition was not applied"),
|
|
),
|
|
Effect.retry({ times: 100, schedule: Schedule.spaced("10 millis") }),
|
|
)
|
|
expect(appended.map((item) => String(item.name)).toSorted()).toEqual(["added", "dynamic", "resources"])
|
|
expect(server.state.toolLists).toBe(1)
|
|
expect(server.state.initializations).toBe(1)
|
|
|
|
entries = [
|
|
document(
|
|
{
|
|
resources: resources(false),
|
|
added,
|
|
},
|
|
"unrelated",
|
|
),
|
|
]
|
|
yield* publishUpdate()
|
|
yield* Effect.sync(() => server.state.initializations).pipe(
|
|
Effect.filterOrFail(
|
|
(count) => count === 2,
|
|
() => new Error("MCP config change did not reconnect the server"),
|
|
),
|
|
Effect.retry({ times: 100, schedule: Schedule.spaced("10 millis") }),
|
|
)
|
|
|
|
entries = [document({ added }, "unrelated")]
|
|
yield* publishUpdate()
|
|
const removed = yield* service.servers().pipe(
|
|
Effect.filterOrFail(
|
|
(items) => !items.some((item) => item.name === "resources"),
|
|
() => new Error("MCP config removal was not applied"),
|
|
),
|
|
Effect.retry({ times: 100, schedule: Schedule.spaced("10 millis") }),
|
|
)
|
|
expect(removed.map((item) => String(item.name)).toSorted()).toEqual(["added", "dynamic"])
|
|
}).pipe(
|
|
Effect.provide(
|
|
resourceMcpLayer(resources(), undefined, undefined, {
|
|
entries: () => Effect.sync(() => entries),
|
|
subscribe: (() => Stream.fromPubSub(updates)) as Bus.Interface["subscribe"],
|
|
}),
|
|
),
|
|
)
|
|
}),
|
|
),
|
|
)
|
|
})
|
|
|
|
testEffect(Layer.empty).live("keeps MCP config snapshots stable during an in-flight replacement", () =>
|
|
Effect.gen(function* () {
|
|
const started = yield* Deferred.make<void>()
|
|
const release = yield* Deferred.make<void>()
|
|
const accepted = yield* Deferred.make<void>()
|
|
const server = yield* resourceServer({
|
|
respond: (request) =>
|
|
request.method !== "POST"
|
|
? undefined
|
|
: Effect.runPromise(
|
|
Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release)), Effect.as(undefined)),
|
|
),
|
|
})
|
|
|
|
yield* Effect.gen(function* () {
|
|
const service = yield* Mcp.Service
|
|
const replacing = yield* service
|
|
.transform((editor) => editor.update("resources", (config) => (config.disabled = false)))
|
|
.pipe(Effect.forkScoped({ startImmediately: true }))
|
|
yield* Deferred.await(started)
|
|
|
|
const restoring = yield* State.batch(
|
|
Effect.gen(function* () {
|
|
yield* service.transform((editor) => editor.update("resources", (config) => (config.disabled = true)))
|
|
yield* Deferred.succeed(accepted, undefined)
|
|
}),
|
|
).pipe(Effect.forkScoped({ startImmediately: true }))
|
|
yield* Deferred.await(accepted)
|
|
expect((yield* service.servers())[0]?.status).toEqual({ status: "pending" })
|
|
|
|
yield* Deferred.succeed(release, undefined)
|
|
yield* Fiber.join(replacing)
|
|
yield* Fiber.join(restoring)
|
|
expect((yield* service.servers())[0]?.status).toEqual({ status: "disabled" })
|
|
expect(yield* service.tools()).toEqual([])
|
|
expect(server.state.initializations).toBe(1)
|
|
}).pipe(
|
|
Effect.ensuring(Deferred.succeed(release, undefined)),
|
|
Effect.provide(
|
|
resourceMcpLayer(new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false, disabled: true })),
|
|
),
|
|
)
|
|
}),
|
|
)
|
|
|
|
const shutdownIt = testEffect(
|
|
AppNodeBuilder.build(
|
|
LayerNode.group([
|
|
Config.node,
|
|
Bus.node,
|
|
Integration.node,
|
|
Credential.node,
|
|
Form.node,
|
|
Environment.node,
|
|
Location.node,
|
|
]),
|
|
[
|
|
Location.node.replace(
|
|
Layer.succeed(
|
|
Location.Service,
|
|
Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })),
|
|
),
|
|
),
|
|
Environment.node.replace(hostEnvironmentLayer),
|
|
],
|
|
),
|
|
)
|
|
shutdownIt.effect("discards in-flight and queued MCP notifications after its layer closes", () =>
|
|
Effect.gen(function* () {
|
|
const bus = yield* Bus.Service
|
|
const entered = yield* Deferred.make<void>()
|
|
const release = yield* Deferred.make<void>()
|
|
const root = yield* Scope.make()
|
|
yield* Effect.addFinalizer(() =>
|
|
Deferred.succeed(release, undefined).pipe(Effect.andThen(State.shutdown(Scope.close(root, Exit.void)))),
|
|
)
|
|
const context = yield* Layer.buildWithScope(Mcp.layer(), root)
|
|
const service = Context.get(context, Mcp.Service)
|
|
const observed: string[] = []
|
|
let block = false
|
|
yield* Effect.acquireRelease(
|
|
bus.listen((event) =>
|
|
Effect.gen(function* () {
|
|
if (event.type !== McpEvent.StatusChanged.type) return
|
|
observed.push(Schema.decodeUnknownSync(McpEvent.StatusChanged.data)(event.data).server)
|
|
if (!block) return
|
|
block = false
|
|
yield* Deferred.succeed(entered, undefined)
|
|
yield* Deferred.await(release)
|
|
}),
|
|
),
|
|
(unsubscribe) => unsubscribe,
|
|
)
|
|
const source = { url: "https://example.com/initial", added: false }
|
|
yield* service
|
|
.transform((editor) => {
|
|
editor.set("fixture", { type: "remote", url: source.url, oauth: false, disabled: true })
|
|
if (source.added) editor.set("queued", { type: "local", command: ["unused"], disabled: true })
|
|
})
|
|
.pipe(Scope.provide(root))
|
|
|
|
block = true
|
|
source.url = "https://example.com/first"
|
|
source.added = true
|
|
const first = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
|
yield* Deferred.await(entered)
|
|
source.url = "https://example.com/second"
|
|
const second = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
|
|
|
const shutdown = yield* State.shutdown(Scope.close(root, Exit.void)).pipe(
|
|
Effect.forkChild({ startImmediately: true }),
|
|
)
|
|
yield* TestClock.adjust("1 millis")
|
|
expect(shutdown.pollUnsafe()).toBeDefined()
|
|
expect(first.pollUnsafe()).toBeDefined()
|
|
expect(second.pollUnsafe()).toBeDefined()
|
|
expect(yield* Deferred.isDone(release)).toBe(false)
|
|
yield* Fiber.join(shutdown)
|
|
observed.length = 0
|
|
yield* Deferred.succeed(release, undefined)
|
|
yield* Fiber.join(first)
|
|
yield* Fiber.join(second)
|
|
expect(observed).toEqual([])
|
|
expect((yield* service.servers()).map((server) => server.name)).toEqual([Mcp.ServerName.make("fixture")])
|
|
}),
|
|
)
|
|
|
|
describe.each(["connect", "disconnect"] as const)("%s during initial MCP startup", (operation) => {
|
|
shutdownIt.live("is not undone by the queued startup", () =>
|
|
Effect.gen(function* () {
|
|
const bus = yield* Bus.Service
|
|
const server = yield* resourceServer()
|
|
const entered = yield* Deferred.make<void>()
|
|
const release = yield* Deferred.make<void>()
|
|
const root = yield* Scope.make()
|
|
yield* Effect.addFinalizer(() =>
|
|
Deferred.succeed(release, undefined).pipe(Effect.andThen(State.shutdown(Scope.close(root, Exit.void)))),
|
|
)
|
|
const service = Context.get(yield* Layer.buildWithScope(Mcp.layer(), root), Mcp.Service)
|
|
// Pausing the disabled server's status event holds initial reconciliation after every entry exists
|
|
// but before the enabled server's startup is scheduled.
|
|
yield* Effect.acquireRelease(
|
|
bus.listen((event) =>
|
|
Effect.gen(function* () {
|
|
if (event.type !== McpEvent.StatusChanged.type) return
|
|
if (Schema.decodeUnknownSync(McpEvent.StatusChanged.data)(event.data).server !== "gate") return
|
|
yield* Deferred.succeed(entered, undefined)
|
|
yield* Deferred.await(release)
|
|
}),
|
|
),
|
|
(unsubscribe) => unsubscribe,
|
|
)
|
|
const reconciling = yield* service
|
|
.transform((editor) => {
|
|
editor.set("gate", { type: "local", command: ["unused"], disabled: true })
|
|
editor.set("resources", { type: "remote", url: server.url, oauth: false })
|
|
})
|
|
.pipe(Scope.provide(root), Effect.forkChild({ startImmediately: true }))
|
|
yield* Deferred.await(entered)
|
|
|
|
yield* service[operation]("resources")
|
|
yield* Deferred.succeed(release, undefined)
|
|
yield* Fiber.join(reconciling)
|
|
// The queued startup already holds or awaits the server lock, so this waits for it to finish.
|
|
yield* service.disconnect("resources")
|
|
|
|
expect(server.state.initializations).toBe(operation === "connect" ? 1 : 0)
|
|
expect((yield* service.servers()).find((item) => item.name === "resources")?.status).toEqual({
|
|
status: "disabled",
|
|
})
|
|
}),
|
|
)
|
|
})
|
|
|
|
test("serializes concurrent MCP lifecycle operations", async () => {
|
|
await Effect.runPromise(
|
|
Effect.scoped(
|
|
Effect.gen(function* () {
|
|
const server = yield* resourceServer({ modern: true, resources: false })
|
|
yield* Effect.gen(function* () {
|
|
const service = yield* Mcp.Service
|
|
|
|
// Whatever order the racing operations land in, the resulting state must be consistent.
|
|
yield* Effect.all(
|
|
[
|
|
service.connect("resources"),
|
|
service.connect("resources"),
|
|
service.disconnect("resources"),
|
|
service.connect("resources"),
|
|
],
|
|
{ concurrency: "unbounded", discard: true },
|
|
)
|
|
const status = (yield* service.servers()).find((server) => server.name === "resources")?.status
|
|
const tools = yield* service.tools()
|
|
expect(status?.status === "connected" || status?.status === "disabled").toBe(true)
|
|
if (status?.status === "disabled") expect(tools).toEqual([])
|
|
if (status?.status === "connected") expect(tools.length).toBeGreaterThan(0)
|
|
|
|
yield* service.disconnect("resources")
|
|
expect((yield* service.servers())[0]?.status).toEqual({ status: "disabled" })
|
|
expect(yield* service.tools()).toEqual([])
|
|
yield* service.connect("resources")
|
|
expect((yield* service.servers())[0]?.status).toEqual({ status: "connected" })
|
|
expect((yield* service.tools()).length).toBeGreaterThan(0)
|
|
}).pipe(
|
|
Effect.provide(
|
|
resourceMcpLayer(
|
|
new ConfigMCP.Remote({
|
|
type: "remote",
|
|
url: server.url,
|
|
oauth: false,
|
|
protocol: "2026-07-28",
|
|
disabled: true,
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
}),
|
|
),
|
|
)
|
|
})
|
|
|
|
testEffect(Layer.empty).live("preserves plugin transforms through MCP catalog updates", () =>
|
|
Effect.gen(function* () {
|
|
const tool = (server: string, name: string, description = name) =>
|
|
({
|
|
server: Mcp.ServerName.make(server),
|
|
name,
|
|
description,
|
|
codemode: false,
|
|
inputSchema: { type: "object", properties: {} },
|
|
}) satisfies Mcp.Tool
|
|
const healthy = [tool("demo", "search"), tool("other", "lookup")]
|
|
const catalog = yield* Ref.make(healthy)
|
|
|
|
yield* Effect.gen(function* () {
|
|
const registry = yield* Tool.Service
|
|
const registration = yield* McpTool.Service
|
|
const bus = yield* Bus.Service
|
|
yield* registration.flush
|
|
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([
|
|
"demo_search",
|
|
"other_lookup",
|
|
"execute",
|
|
])
|
|
const override = yield* registry.transform((editor) => {
|
|
editor.add({
|
|
name: "search",
|
|
options: { namespace: "demo", codemode: false },
|
|
description: "Override search",
|
|
input: Schema.Struct({}),
|
|
output: Schema.String,
|
|
execute: () => Effect.succeed({ output: "override" }),
|
|
})
|
|
})
|
|
const mutation = yield* registry.transform((editor) => {
|
|
editor.update("other_lookup", (tool) => {
|
|
tool.description += " updated"
|
|
})
|
|
editor.remove("repaired_lookup")
|
|
})
|
|
|
|
yield* Ref.set(catalog, [...healthy, tool("demo", "added")])
|
|
yield* bus.publish(McpEvent.ToolsChanged, { server: "demo" })
|
|
yield* waitForTool(registry, "demo_added")
|
|
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([
|
|
"demo_added",
|
|
"demo_search",
|
|
"other_lookup",
|
|
"execute",
|
|
])
|
|
expect((yield* toolDefinitions(registry)).find((tool) => tool.name === "demo_search")?.description).toBe(
|
|
"Override search",
|
|
)
|
|
expect((yield* toolDefinitions(registry)).find((tool) => tool.name === "other_lookup")?.description).toBe(
|
|
"lookup updated",
|
|
)
|
|
yield* Effect.forEach(["demo_search", "other_lookup"], (name) =>
|
|
executeTool(registry, {
|
|
sessionID: Session.ID.make("ses_mcp_invalid_catalog"),
|
|
...toolIdentity,
|
|
call: { type: "tool-call", id: `call_${name}`, name, input: {} },
|
|
}).pipe(
|
|
Effect.tap((result) =>
|
|
Effect.sync(() =>
|
|
expect(result).toMatchObject({
|
|
status: "completed",
|
|
output: name === "demo_search" ? "override" : "healthy",
|
|
}),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
|
|
yield* Ref.set(catalog, [
|
|
tool("demo", "status"),
|
|
tool("other", "lookup"),
|
|
tool("demo", "added"),
|
|
tool("repaired", "lookup"),
|
|
])
|
|
yield* bus.publish(McpEvent.ToolsChanged, { server: "demo" })
|
|
yield* waitForTool(registry, "demo_status")
|
|
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([
|
|
"demo_added",
|
|
"demo_search",
|
|
"demo_status",
|
|
"other_lookup",
|
|
"execute",
|
|
])
|
|
expect((yield* toolDefinitions(registry)).find((tool) => tool.name === "demo_search")?.description).toBe(
|
|
"Override search",
|
|
)
|
|
expect((yield* toolDefinitions(registry)).find((tool) => tool.name === "other_lookup")?.description).toBe(
|
|
"lookup updated",
|
|
)
|
|
yield* mutation.dispose
|
|
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toContain("repaired_lookup")
|
|
expect((yield* toolDefinitions(registry)).find((tool) => tool.name === "other_lookup")?.description).toBe(
|
|
"lookup",
|
|
)
|
|
|
|
yield* Ref.set(catalog, [tool("demo", "search", "Latest search"), tool("demo", "refreshed")])
|
|
yield* bus.publish(McpEvent.ToolsChanged, { server: "demo" })
|
|
yield* waitForTool(registry, "demo_refreshed")
|
|
expect((yield* toolDefinitions(registry)).find((tool) => tool.name === "demo_search")?.description).toBe(
|
|
"Override search",
|
|
)
|
|
|
|
yield* override.dispose
|
|
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([
|
|
"demo_refreshed",
|
|
"demo_search",
|
|
"execute",
|
|
])
|
|
expect((yield* toolDefinitions(registry)).find((tool) => tool.name === "demo_search")?.description).toBe(
|
|
"Latest search",
|
|
)
|
|
expect(
|
|
yield* executeTool(registry, {
|
|
sessionID: Session.ID.make("ses_mcp_invalid_catalog"),
|
|
...toolIdentity,
|
|
call: { type: "tool-call", id: "call_restored_search", name: "demo_search", input: {} },
|
|
}),
|
|
).toMatchObject({ status: "completed", output: "healthy" })
|
|
}).pipe(
|
|
Effect.provide(
|
|
Layer.fresh(
|
|
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node, Bus.node]), [
|
|
Mcp.node.replace(
|
|
Layer.mock(Mcp.Service, {
|
|
tools: () => Ref.get(catalog),
|
|
callTool: (input) =>
|
|
Effect.succeed({
|
|
server: Mcp.ServerName.make(input.server),
|
|
tool: input.name,
|
|
isError: false,
|
|
content: [{ type: "text", text: "healthy" }],
|
|
} satisfies Mcp.ToolResult),
|
|
}),
|
|
),
|
|
Permission.node.replace(Layer.mock(Permission.Service, { assert: () => Effect.void })),
|
|
Image.node.replace(imagePassthrough),
|
|
]),
|
|
),
|
|
),
|
|
)
|
|
}),
|
|
)
|
|
|
|
testEffect(Layer.empty).effect("coalesces queued MCP tool notifications after initial registration", () => {
|
|
let reads = 0
|
|
return Effect.gen(function* () {
|
|
const registry = yield* Tool.Service
|
|
const registration = yield* McpTool.Service
|
|
const bus = yield* Bus.Service
|
|
yield* registration.flush
|
|
expect(reads).toBe(1)
|
|
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["demo_read_1", "execute"])
|
|
|
|
yield* bus.publish(McpEvent.ToolsChanged, { server: "demo" })
|
|
yield* advance(() => reads >= 2)
|
|
expect(reads).toBe(2)
|
|
yield* Effect.forEach(Array.from({ length: 20 }), () => bus.publish(McpEvent.ToolsChanged, { server: "demo" }))
|
|
yield* advance(() => reads >= 3)
|
|
yield* drain
|
|
expect(reads).toBe(3)
|
|
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["demo_read_3", "execute"])
|
|
}).pipe(
|
|
Effect.provide(
|
|
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node, Bus.node]), [
|
|
Mcp.node.replace(
|
|
Layer.mock(Mcp.Service, {
|
|
tools: () =>
|
|
Effect.sync(() => [
|
|
{
|
|
server: Mcp.ServerName.make("demo"),
|
|
name: `read_${++reads}`,
|
|
codemode: false,
|
|
inputSchema: { type: "object", properties: {} },
|
|
} satisfies Mcp.Tool,
|
|
]),
|
|
}),
|
|
),
|
|
Permission.node.replace(Layer.mock(Permission.Service, { assert: () => Effect.void })),
|
|
Image.node.replace(imagePassthrough),
|
|
]),
|
|
),
|
|
)
|
|
})
|
|
|
|
it.effect("advertises MCP output schemas to Code Mode", () =>
|
|
Effect.gen(function* () {
|
|
const registry = yield* Tool.Service
|
|
const registration = yield* McpTool.Service
|
|
yield* registration.flush
|
|
const toolSet = yield* registry.snapshot()
|
|
const execute = toolSet.definitions.find((tool) => tool.name === "execute")
|
|
|
|
expect(toolSet.definitions.map((tool) => tool.name)).toEqual([
|
|
"direct_attachments",
|
|
"direct_fail",
|
|
"direct_issues",
|
|
"direct_lookup",
|
|
"direct_media",
|
|
"execute",
|
|
])
|
|
expect(codeModeListings(toolSet.codeModeCatalog!).find((tool) => tool.path === "demo.search")?.line).toContain(
|
|
"ok: boolean",
|
|
)
|
|
expect(execute?.description).not.toContain("tools.demo.search")
|
|
}),
|
|
)
|
|
|
|
it.effect("forwards the invoking session through direct and Code Mode MCP tools", () =>
|
|
Effect.gen(function* () {
|
|
assertion = yield* Deferred.make<Permission.AssertInput>()
|
|
decision = Effect.void
|
|
invocations = []
|
|
const registry = yield* Tool.Service
|
|
const registration = yield* McpTool.Service
|
|
yield* registration.flush
|
|
const toolSet = yield* registry.snapshot()
|
|
|
|
expect(toolSet.definitions.find((tool) => tool.name === "direct_lookup")?.inputSchema).not.toHaveProperty(
|
|
"properties.sessionID",
|
|
)
|
|
expect(codeModeListings(toolSet.codeModeCatalog!).find((tool) => tool.path === "demo.search")?.line).not.toContain(
|
|
"sessionID",
|
|
)
|
|
|
|
const directSessionID = Session.ID.make("ses_mcp_direct")
|
|
yield* toolSet.execute({
|
|
sessionID: directSessionID,
|
|
...toolIdentity,
|
|
call: { type: "tool-call", id: "call_mcp_direct", name: "direct_lookup", input: {} },
|
|
})
|
|
expect(invocations[0]).toEqual({
|
|
server: "direct",
|
|
name: "lookup",
|
|
args: {},
|
|
sessionID: directSessionID,
|
|
})
|
|
|
|
const codeModeSessionID = Session.ID.make("ses_mcp_codemode")
|
|
yield* toolSet.execute({
|
|
sessionID: codeModeSessionID,
|
|
...toolIdentity,
|
|
call: {
|
|
type: "tool-call",
|
|
id: "call_mcp_codemode",
|
|
name: "execute",
|
|
input: { code: "return await tools.demo.search({})" },
|
|
},
|
|
})
|
|
expect(invocations[1]).toEqual({
|
|
server: "demo",
|
|
name: "search",
|
|
args: {},
|
|
sessionID: codeModeSessionID,
|
|
})
|
|
}),
|
|
)
|
|
|
|
it.effect("returns content-only MCP results through Code Mode", () =>
|
|
Effect.gen(function* () {
|
|
assertion = yield* Deferred.make<Permission.AssertInput>()
|
|
decision = Effect.void
|
|
const registry = yield* Tool.Service
|
|
const registration = yield* McpTool.Service
|
|
yield* registration.flush
|
|
const toolSet = yield* registry.snapshot()
|
|
|
|
expect(codeModeListings(toolSet.codeModeCatalog!).some((tool) => tool.path === "demo.status")).toBe(true)
|
|
|
|
const execution = yield* toolSet.execute({
|
|
sessionID: Session.ID.make("ses_mcp_content_only"),
|
|
...toolIdentity,
|
|
call: {
|
|
type: "tool-call",
|
|
id: "call_mcp_content_only",
|
|
name: "execute",
|
|
input: { code: "return await tools.demo.status({})" },
|
|
},
|
|
})
|
|
|
|
expect(execution).toMatchObject({
|
|
output: { output: "hello", toolCalls: [{ tool: "demo.status", status: "completed" }] },
|
|
content: [{ type: "text", text: "hello" }],
|
|
})
|
|
}),
|
|
)
|
|
|
|
it.effect("parses JSON text results from MCP tools without an output schema", () =>
|
|
Effect.gen(function* () {
|
|
assertion = yield* Deferred.make<Permission.AssertInput>()
|
|
decision = Effect.void
|
|
const registry = yield* Tool.Service
|
|
const registration = yield* McpTool.Service
|
|
yield* registration.flush
|
|
const toolSet = yield* registry.snapshot()
|
|
|
|
const run = (code: string) =>
|
|
toolSet
|
|
.execute({
|
|
sessionID: Session.ID.make("ses_mcp_json_text"),
|
|
...toolIdentity,
|
|
call: { type: "tool-call", id: `call_${code.length}`, name: "execute", input: { code } },
|
|
})
|
|
.pipe(Effect.map((execution) => execution.output.output))
|
|
|
|
expect(yield* run("return (await tools.demo.issues({})).issues[0].id")).toBe("1")
|
|
expect(yield* run("return typeof (await tools.demo.count({}))")).toBe("string")
|
|
expect(yield* run("return typeof (await tools.demo.typed({}))")).toBe("string")
|
|
|
|
// Outside Code Mode the content the model reads is the original text.
|
|
expect(
|
|
yield* toolSet.execute({
|
|
sessionID: Session.ID.make("ses_mcp_json_text"),
|
|
...toolIdentity,
|
|
call: { type: "tool-call", id: "call_direct_issues", name: "direct_issues", input: {} },
|
|
}),
|
|
).toMatchObject({ output: { issues: [{ id: 1 }] }, content: [{ type: "text", text: '{"issues":[{"id":1}]}' }] })
|
|
}),
|
|
)
|
|
|
|
it.effect("advertises MCP tools directly when Code Mode is disabled for the server", () =>
|
|
Effect.gen(function* () {
|
|
const registry = yield* Tool.Service
|
|
const registration = yield* McpTool.Service
|
|
yield* registration.flush
|
|
const definitions = yield* toolDefinitions(registry)
|
|
const execute = definitions.find((tool) => tool.name === "execute")
|
|
|
|
expect(definitions.some((tool) => tool.name === "direct_lookup")).toBe(true)
|
|
expect(execute?.description).not.toContain("tools.direct.lookup")
|
|
}),
|
|
)
|
|
|
|
// Baseline (PLAN.md step 1): MCP isError must become one failed tool call, not a
|
|
// success whose text happens to describe an error.
|
|
it.effect("fails the call when MCP reports isError", () =>
|
|
Effect.gen(function* () {
|
|
assertion = yield* Deferred.make<Permission.AssertInput>()
|
|
decision = Effect.void
|
|
const registry = yield* Tool.Service
|
|
const registration = yield* McpTool.Service
|
|
yield* registration.flush
|
|
|
|
const execution = yield* executeTool(registry, {
|
|
sessionID: Session.ID.make("ses_mcp_is_error"),
|
|
...toolIdentity,
|
|
call: { type: "tool-call", id: "call_mcp_is_error", name: "direct_fail", input: {} },
|
|
})
|
|
|
|
expect(execution).toMatchObject({ status: "error", error: { message: "search index unavailable" } })
|
|
}),
|
|
)
|
|
|
|
// Baseline (PLAN.md step 1): mixed MCP text and media content must reach the model intact.
|
|
it.effect("preserves MCP text and media content for the model", () =>
|
|
Effect.gen(function* () {
|
|
assertion = yield* Deferred.make<Permission.AssertInput>()
|
|
decision = Effect.void
|
|
const registry = yield* Tool.Service
|
|
const registration = yield* McpTool.Service
|
|
yield* registration.flush
|
|
|
|
const execution = yield* executeTool(registry, {
|
|
sessionID: Session.ID.make("ses_mcp_media"),
|
|
...toolIdentity,
|
|
call: { type: "tool-call", id: "call_mcp_media", name: "direct_media", input: {} },
|
|
})
|
|
|
|
expect(execution.output).toBe("rendered chart")
|
|
expect(execution.content).toMatchObject([
|
|
{ type: "text", text: "rendered chart" },
|
|
{ type: "file", mime: "image/png" },
|
|
])
|
|
}),
|
|
)
|
|
|
|
it.effect("decodes text blobs and saves other MCP blobs instead of attaching them", () =>
|
|
Effect.gen(function* () {
|
|
assertion = yield* Deferred.make<Permission.AssertInput>()
|
|
decision = Effect.void
|
|
const registry = yield* Tool.Service
|
|
const registration = yield* McpTool.Service
|
|
yield* registration.flush
|
|
|
|
const execution = yield* executeTool(registry, {
|
|
sessionID: Session.ID.make("ses_mcp_attachments"),
|
|
...toolIdentity,
|
|
call: { type: "tool-call", id: "call_mcp_attachments", name: "direct_attachments", input: {} },
|
|
})
|
|
|
|
const text = execution.content?.map((part) => (part.type === "text" ? part.text : part.mime)) ?? []
|
|
const saved =
|
|
/^\[Binary resource file:\/\/\/data\.parquet \(application\/vnd\.apache\.parquet, 4 bytes\) saved to (.+)\]$/.exec(
|
|
text[1] ?? "",
|
|
)?.[1]
|
|
expect(text[0]).toBe("[Resource file:///README.md (text/markdown)]\n# Notes")
|
|
expect(saved?.startsWith(path.join(outputData, ToolOutput.DIRECTORY, "tool_"))).toBe(true)
|
|
expect(yield* Effect.promise(() => fs.readFile(saved ?? "", "utf8"))).toBe("PAR1")
|
|
}),
|
|
)
|
|
|
|
it.effect("waits for permission before calling an MCP tool", () =>
|
|
Effect.gen(function* () {
|
|
calls = 0
|
|
assertion = yield* Deferred.make<Permission.AssertInput>()
|
|
const permission = yield* Deferred.make<void>()
|
|
decision = Deferred.await(permission)
|
|
const registry = yield* Tool.Service
|
|
const registration = yield* McpTool.Service
|
|
yield* registration.flush
|
|
const toolSet = yield* registry.snapshot()
|
|
expect(codeModeListings(toolSet.codeModeCatalog!).some((tool) => tool.path === "demo.search")).toBe(true)
|
|
|
|
const fiber = yield* toolSet
|
|
.execute({
|
|
sessionID: Session.ID.make("ses_mcp_permission"),
|
|
...toolIdentity,
|
|
call: {
|
|
type: "tool-call",
|
|
id: "call_mcp_permission",
|
|
name: "execute",
|
|
input: { code: 'return await tools.demo.search({ query: "open issues" })' },
|
|
},
|
|
})
|
|
.pipe(Effect.forkScoped)
|
|
expect(yield* Deferred.await(assertion)).toEqual({
|
|
action: "demo_search",
|
|
resources: ["*"],
|
|
save: ["*"],
|
|
metadata: { input: { query: "open issues" } },
|
|
sessionID: Session.ID.make("ses_mcp_permission"),
|
|
agent: toolIdentity.agent,
|
|
source: {
|
|
type: "tool",
|
|
messageID: toolIdentity.messageID,
|
|
id: "call_mcp_permission",
|
|
},
|
|
})
|
|
expect(calls).toBe(0)
|
|
|
|
yield* Deferred.succeed(permission, undefined)
|
|
yield* Fiber.join(fiber)
|
|
expect(calls).toBe(1)
|
|
}),
|
|
)
|
|
|
|
it.effect("does not call MCP when permission is blocked", () =>
|
|
Effect.gen(function* () {
|
|
calls = 0
|
|
assertion = yield* Deferred.make<Permission.AssertInput>()
|
|
decision = Effect.fail(new Permission.BlockedError({ rules: [], permission: "demo_search", resources: ["*"] }))
|
|
const registry = yield* Tool.Service
|
|
const registration = yield* McpTool.Service
|
|
yield* registration.flush
|
|
const toolSet = yield* registry.snapshot()
|
|
expect(codeModeListings(toolSet.codeModeCatalog!).some((tool) => tool.path === "demo.search")).toBe(true)
|
|
|
|
const execution = yield* toolSet.execute({
|
|
sessionID: Session.ID.make("ses_mcp_blocked"),
|
|
...toolIdentity,
|
|
call: {
|
|
type: "tool-call",
|
|
id: "call_mcp_blocked",
|
|
name: "execute",
|
|
input: { code: "return await tools.demo.search({})" },
|
|
},
|
|
})
|
|
expect(execution.content).toEqual([{ type: "text", text: "Unable to execute demo_search" }])
|
|
expect(execution.metadata).toEqual({
|
|
toolCalls: [{ tool: "demo.search", status: "error" }],
|
|
error: true,
|
|
})
|
|
expect(calls).toBe(0)
|
|
}),
|
|
)
|
|
|
|
for (const modern of [false, true]) {
|
|
testEffect(Layer.empty).live(`integration policy denies MCP startup (${modern ? "modern" : "legacy"})`, () =>
|
|
Effect.gen(function* () {
|
|
const server = yield* resourceServer({ modern })
|
|
yield* Effect.gen(function* () {
|
|
const service = yield* Mcp.Service
|
|
expect(yield* service.servers()).toEqual([])
|
|
expect(Exit.isFailure(yield* service.connect("resources").pipe(Effect.exit))).toBe(true)
|
|
yield* service.add("resources", { type: "remote", url: server.url, oauth: false })
|
|
expect(yield* service.servers()).toEqual([])
|
|
expect(yield* service.tools()).toEqual([])
|
|
expect(yield* service.instructions()).toEqual([])
|
|
expect(yield* service.prompts()).toEqual([])
|
|
expect(yield* service.resourceCatalog()).toEqual({ resources: [], templates: [] })
|
|
expect(Exit.isFailure(yield* service.resources({ server: "resources" }).pipe(Effect.exit))).toBe(true)
|
|
expect(Exit.isFailure(yield* service.prompt({ server: "resources", name: "greet" }).pipe(Effect.exit))).toBe(
|
|
true,
|
|
)
|
|
expect(
|
|
Exit.isFailure(yield* service.readResource({ server: "resources", uri: "docs://readme" }).pipe(Effect.exit)),
|
|
).toBe(true)
|
|
expect(Exit.isFailure(yield* service.callTool({ server: "resources", name: "echo" }).pipe(Effect.exit))).toBe(
|
|
true,
|
|
)
|
|
expect(server.state.initializations).toBe(0)
|
|
}).pipe(
|
|
Effect.provide(
|
|
resourceMcpLayer(server.url, undefined, undefined, {
|
|
policies: [{ action: "integration.use", resource: "mcp:*", effect: "deny" }],
|
|
}),
|
|
),
|
|
)
|
|
}),
|
|
)
|
|
}
|
|
|
|
for (const source of ["config", "organization"] as const) {
|
|
testEffect(Config.testLayer()).live(
|
|
`revokes and restores a connected MCP through ${source} policy`,
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const server = yield* resourceServer()
|
|
const config = yield* Config.Service
|
|
const test = yield* Config.Test
|
|
const updates = yield* PubSub.unbounded<{ readonly type: string }>()
|
|
const document = (effect: ConfigPolicy.Effect) =>
|
|
new Document({
|
|
type: "document",
|
|
info: new Info({
|
|
mcp: new ConfigMCP.Info({
|
|
servers: { resources: new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }) },
|
|
}),
|
|
policies: [{ action: "integration.use", resource: "mcp:*", effect }],
|
|
}),
|
|
})
|
|
yield* test.setEntries([document("allow")])
|
|
yield* Effect.gen(function* () {
|
|
const service = yield* Mcp.Service
|
|
expect(yield* settled(service)).toEqual({ status: "connected" })
|
|
expect((yield* service.tools()).length).toBeGreaterThan(0)
|
|
if (source === "config") yield* test.setEntries([document("deny")])
|
|
if (source === "organization")
|
|
yield* setOrganizationPolicy([{ action: "integration.use", resource: "mcp:*", effect: "deny" }])
|
|
yield* PubSub.publish(updates, { type: Event.Updated.type })
|
|
yield* service.servers().pipe(
|
|
Effect.filterOrFail(
|
|
(servers) => servers.length === 0,
|
|
() => new Error("MCP policy was not applied"),
|
|
),
|
|
Effect.retry({ times: 200, schedule: Schedule.spaced("10 millis") }),
|
|
)
|
|
expect(yield* service.servers()).toEqual([])
|
|
expect(yield* service.tools()).toEqual([])
|
|
expect(yield* service.instructions()).toEqual([])
|
|
expect(yield* service.prompts()).toEqual([])
|
|
expect(yield* service.resourceCatalog()).toEqual({ resources: [], templates: [] })
|
|
expect(Exit.isFailure(yield* service.resources({ server: "resources" }).pipe(Effect.exit))).toBe(true)
|
|
expect(Exit.isFailure(yield* service.prompt({ server: "resources", name: "greet" }).pipe(Effect.exit))).toBe(
|
|
true,
|
|
)
|
|
expect(
|
|
Exit.isFailure(
|
|
yield* service.readResource({ server: "resources", uri: "docs://readme" }).pipe(Effect.exit),
|
|
),
|
|
).toBe(true)
|
|
expect(Exit.isFailure(yield* service.connect("resources").pipe(Effect.exit))).toBe(true)
|
|
expect(Exit.isFailure(yield* service.callTool({ server: "resources", name: "echo" }).pipe(Effect.exit))).toBe(
|
|
true,
|
|
)
|
|
expect(server.state.toolCalls).toEqual([])
|
|
expect(server.state.resourceReads).toEqual([])
|
|
yield* Effect.promise(server.restart)
|
|
if (source === "config") yield* test.setEntries([document("allow")])
|
|
if (source === "organization") yield* setOrganizationPolicy(undefined)
|
|
yield* PubSub.publish(updates, { type: Event.Updated.type })
|
|
yield* service.servers().pipe(
|
|
Effect.filterOrFail(
|
|
(servers) => servers.some((server) => server.status.status === "connected"),
|
|
() => new Error("MCP policy was not restored"),
|
|
),
|
|
Effect.retry({ times: 200, schedule: Schedule.spaced("10 millis") }),
|
|
)
|
|
expect(yield* settled(service)).toEqual({ status: "connected" })
|
|
expect((yield* service.tools()).length).toBeGreaterThan(0)
|
|
}).pipe(
|
|
Effect.provide(
|
|
resourceMcpLayer(server.url, undefined, undefined, {
|
|
entries: config.entries,
|
|
subscribe: (() => Stream.fromPubSub(updates)) as Bus.Interface["subscribe"],
|
|
}),
|
|
),
|
|
)
|
|
}),
|
|
)
|
|
}
|