mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
refactor(core): isolate session MCP registry behind a session view
This commit is contained in:
@@ -98,7 +98,7 @@ export function make(input: {
|
||||
readonly connection: ACPConnection.Connection
|
||||
}): Interface {
|
||||
const sessions = new Map<string, Attached>()
|
||||
const registeredMcp = new Map<string, Map<string, string>>()
|
||||
const registeredMcp = new Map<string, Set<string>>()
|
||||
const active = new Map<string, { readonly control: TurnControl; readonly turn: Promise<PromptResponse> }>()
|
||||
const capabilities = { writeTextFile: false, childSessionUpdates: false }
|
||||
|
||||
@@ -482,34 +482,22 @@ async function messages(client: OpenCodeClient, sessionID: string) {
|
||||
|
||||
async function registerMcpServers(
|
||||
client: OpenCodeClient,
|
||||
registered: Map<string, Map<string, string>>,
|
||||
registered: Map<string, Set<string>>,
|
||||
session: Attached,
|
||||
servers: readonly McpServer[],
|
||||
) {
|
||||
const current = registered.get(session.id) ?? new Map<string, string>()
|
||||
const current = registered.get(session.id) ?? new Set<string>()
|
||||
registered.set(session.id, current)
|
||||
await Promise.all(
|
||||
servers.flatMap((server) => {
|
||||
const config = mcpConfig(server)
|
||||
const key = stableStringify(config)
|
||||
if (current.get(server.name) === key) return []
|
||||
current.set(server.name, key)
|
||||
return [
|
||||
client.session.mcp.add({ sessionID: session.id, server: server.name, config }).catch((error) => {
|
||||
if (current.get(server.name) === key) current.delete(server.name)
|
||||
throw error
|
||||
}),
|
||||
]
|
||||
servers.map(async (server) => {
|
||||
await client.session.mcp.add({ sessionID: session.id, server: server.name, config: mcpConfig(server) })
|
||||
current.add(server.name)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function releaseMcpServers(
|
||||
client: OpenCodeClient,
|
||||
registered: Map<string, Map<string, string>>,
|
||||
sessionID: string,
|
||||
) {
|
||||
const servers = Array.from(registered.get(sessionID)?.keys() ?? [])
|
||||
async function releaseMcpServers(client: OpenCodeClient, registered: Map<string, Set<string>>, sessionID: string) {
|
||||
const servers = Array.from(registered.get(sessionID) ?? [])
|
||||
registered.delete(sessionID)
|
||||
await Promise.all(
|
||||
servers.map((server) =>
|
||||
@@ -537,15 +525,6 @@ function mcpConfig(server: McpServer) {
|
||||
}
|
||||
}
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`
|
||||
if (!value || typeof value !== "object") return JSON.stringify(value)
|
||||
return `{${Object.entries(value)
|
||||
.toSorted(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`)
|
||||
.join(",")}}`
|
||||
}
|
||||
|
||||
async function sendUsageUpdate(
|
||||
client: OpenCodeClient,
|
||||
connection: ACPConnection.Connection,
|
||||
|
||||
@@ -317,7 +317,7 @@ describe("acp service directory behavior", () => {
|
||||
expect(invalidConfig).toMatchObject({ _tag: "ACPInvalidConfigOptionError" })
|
||||
})
|
||||
|
||||
test("registers MCP configs per session and deduplicates identical re-registrations", async () => {
|
||||
test("registers MCP configs for the session on every attach", async () => {
|
||||
const local: McpServer = {
|
||||
name: "tools",
|
||||
command: "bun",
|
||||
@@ -349,13 +349,15 @@ describe("acp service directory behavior", () => {
|
||||
},
|
||||
})
|
||||
|
||||
await fixture.service.newSession({ cwd: "/workspace", mcpServers: [local, local, remote] })
|
||||
await fixture.service.newSession({ cwd: "/workspace", mcpServers: [local, remote] })
|
||||
await fixture.service.resumeSession({ cwd: "/workspace", sessionId: "ses_1", mcpServers: [local, remote] })
|
||||
await fixture.service.resumeSession({ cwd: "/workspace", sessionId: "ses_1", mcpServers: [changed] })
|
||||
await fixture.service.newSession({ cwd: "/workspace", mcpServers: [local] })
|
||||
|
||||
const adds = fixture.requests.filter((request) => request.method === "PUT" && mcp.test(request.path))
|
||||
expect(adds.map((request) => request.path.match(mcp)?.slice(1))).toEqual([
|
||||
["ses_1", "tools"],
|
||||
["ses_1", "docs"],
|
||||
["ses_1", "tools"],
|
||||
["ses_1", "docs"],
|
||||
["ses_1", "tools"],
|
||||
@@ -369,6 +371,13 @@ describe("acp service directory behavior", () => {
|
||||
environment: { TOKEN: "x" },
|
||||
},
|
||||
},
|
||||
{
|
||||
config: {
|
||||
type: "local",
|
||||
command: ["bun", "server.ts"],
|
||||
environment: { TOKEN: "x" },
|
||||
},
|
||||
},
|
||||
{
|
||||
config: {
|
||||
type: "local",
|
||||
@@ -392,7 +401,7 @@ describe("acp service directory behavior", () => {
|
||||
oauth: false,
|
||||
},
|
||||
})
|
||||
expect(adds.map((request) => request.query)).toEqual([{}, {}, {}, {}])
|
||||
expect(adds.map((request) => request.query)).toEqual([{}, {}, {}, {}, {}, {}])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import { LocationLifecycle } from "./location-lifecycle.js"
|
||||
import { FileAccess } from "./file-access.js"
|
||||
import { ModelResolver } from "./model-resolver.js"
|
||||
import { Mcp } from "./mcp/index.js"
|
||||
import { McpSession } from "./mcp/session.js"
|
||||
import { Permission } from "./permission.js"
|
||||
import { Plugin } from "./plugin.js"
|
||||
import { PluginHooks } from "./plugin/hooks.js"
|
||||
@@ -88,6 +89,7 @@ const nodes = [
|
||||
FileMutation.node,
|
||||
Formatter.node,
|
||||
Mcp.node,
|
||||
McpSession.node,
|
||||
Permission.node,
|
||||
Tool.node,
|
||||
ToolOutput.node,
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
export * as McpElicitation from "./elicitation.js"
|
||||
|
||||
import { Effect } from "effect"
|
||||
import { waitForAbort } from "@opencode/util/process"
|
||||
import { Form } from "../form.js"
|
||||
import type { McpClient } from "./client.js"
|
||||
|
||||
const URL_FIELD_KEY = "elicitation"
|
||||
|
||||
/** Answers one connection's elicitation requests with forms owned by `sessionID`. */
|
||||
export const handler = (forms: Form.Interface, sessionID: string): McpClient.ElicitationHandler => {
|
||||
// Legacy era only: pending URL-mode elicitation forms, settled by notifications/elicitation/complete.
|
||||
const pending = new Map<string, Form.ID>()
|
||||
return {
|
||||
create: (request) =>
|
||||
Effect.gen(function* () {
|
||||
if (request.params.mode === "url") {
|
||||
const formID = Form.ID.create()
|
||||
// Legacy only: 2026-07-28 has no elicitationId and no completion notification, so the form
|
||||
// settles when the user confirms and the SDK retries the tool call itself.
|
||||
const elicitationID: string | undefined = request.params.elicitationId
|
||||
if (elicitationID !== undefined) pending.set(elicitationID, formID)
|
||||
return yield* forms
|
||||
.ask({
|
||||
id: formID,
|
||||
sessionID,
|
||||
title: `${request.server} is requesting input`,
|
||||
metadata: {
|
||||
kind: "mcp-elicitation",
|
||||
server: request.server,
|
||||
...(elicitationID === undefined ? {} : { elicitationID }),
|
||||
message: request.params.message,
|
||||
},
|
||||
fields: [{ key: URL_FIELD_KEY, type: "external", url: request.params.url }],
|
||||
})
|
||||
.pipe(
|
||||
Effect.raceFirst(waitForAbort(request.signal)),
|
||||
Effect.ensuring(Effect.sync(() => elicitationID !== undefined && pending.delete(elicitationID))),
|
||||
Effect.map(
|
||||
(state): McpClient.ElicitationResult => ({
|
||||
action: state.status === "answered" ? "accept" : "cancel",
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
const params = request.params
|
||||
const [field, ...fields] = Object.entries(params.requestedSchema.properties).map(([key, property]) =>
|
||||
toField(key, property, params.requestedSchema.required?.includes(key) === true),
|
||||
)
|
||||
if (!field) return { action: "accept", content: {} }
|
||||
return yield* forms
|
||||
.ask({
|
||||
sessionID,
|
||||
title: `${request.server} is requesting input`,
|
||||
metadata: { kind: "mcp-elicitation", server: request.server, message: params.message },
|
||||
fields: [field, ...fields],
|
||||
})
|
||||
.pipe(
|
||||
Effect.raceFirst(waitForAbort(request.signal)),
|
||||
Effect.map((state): McpClient.ElicitationResult => {
|
||||
if (state.status !== "answered") return { action: "cancel" }
|
||||
return {
|
||||
action: "accept",
|
||||
content: Object.fromEntries(
|
||||
Object.entries(state.answer).map(
|
||||
([key, value]): [string, NonNullable<McpClient.ElicitationResult["content"]>[string]] =>
|
||||
typeof value === "object" ? [key, Array.from(value)] : [key, value],
|
||||
),
|
||||
),
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
complete: (request) =>
|
||||
Effect.gen(function* () {
|
||||
const formID = pending.get(request.elicitationID)
|
||||
if (!formID) return
|
||||
yield* forms.reply({ id: formID, answer: { [URL_FIELD_KEY]: true } }).pipe(Effect.ignore)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// Schema `optional` strips undefined-valued properties on encode, so fields can assign
|
||||
// optional properties directly instead of conditionally spreading them.
|
||||
function toField(key: string, property: ElicitationProperty, required: boolean): Form.Field {
|
||||
// Some servers emit machine titles like "string with format email"; prefer description/key over those.
|
||||
const machineTitle = /^(boolean|string|number|integer|array|object)(\s+with\b.*|\s+in\b.*)?$/i
|
||||
const title =
|
||||
property.title && !machineTitle.test(property.title.trim()) ? property.title : (property.description ?? key)
|
||||
const base = {
|
||||
key,
|
||||
title,
|
||||
description: property.description === title ? undefined : property.description,
|
||||
required: required || undefined,
|
||||
}
|
||||
switch (property.type) {
|
||||
case "boolean":
|
||||
return { ...base, type: "boolean", default: property.default }
|
||||
case "number":
|
||||
case "integer":
|
||||
return {
|
||||
...base,
|
||||
type: property.type,
|
||||
minimum: property.minimum,
|
||||
maximum: property.maximum,
|
||||
default: property.default,
|
||||
}
|
||||
case "array":
|
||||
return {
|
||||
...base,
|
||||
type: "multiselect",
|
||||
options:
|
||||
"anyOf" in property.items
|
||||
? property.items.anyOf.map((option) => ({ value: option.const, label: option.title }))
|
||||
: property.items.enum.map((value) => ({ value, label: value })),
|
||||
custom: false,
|
||||
minItems: property.minItems,
|
||||
maxItems: property.maxItems,
|
||||
default: property.default,
|
||||
}
|
||||
case "string": {
|
||||
const options =
|
||||
"oneOf" in property
|
||||
? property.oneOf.map((option) => ({ value: option.const, label: option.title }))
|
||||
: "enum" in property
|
||||
? property.enum.map((value, index) => ({
|
||||
value,
|
||||
label: ("enumNames" in property ? property.enumNames?.[index] : undefined) ?? value,
|
||||
}))
|
||||
: undefined
|
||||
return {
|
||||
...base,
|
||||
type: "string",
|
||||
format: "format" in property ? property.format : undefined,
|
||||
minLength: "minLength" in property ? property.minLength : undefined,
|
||||
maxLength: "maxLength" in property ? property.maxLength : undefined,
|
||||
default: property.default,
|
||||
options,
|
||||
custom: options ? false : undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ElicitationProperty = McpClient.ElicitationFormParams["requestedSchema"]["properties"][string]
|
||||
+366
-534
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,10 @@
|
||||
export * as McpInstructions from "./instructions.js"
|
||||
|
||||
import type { Session } from "@opencode/schema/session"
|
||||
import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Permission } from "../permission.js"
|
||||
import { McpTool } from "../tool/mcp.js"
|
||||
import { Mcp } from "./index.js"
|
||||
import type { McpSession } from "./session.js"
|
||||
import { Instructions } from "../instructions/index.js"
|
||||
|
||||
const Summary = Schema.Struct({
|
||||
@@ -56,18 +55,18 @@ const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary
|
||||
|
||||
export interface Interface {
|
||||
/** Lists server instructions reachable under the given ruleset; callers pass the merged agent and Session permissions. */
|
||||
readonly load: (permissions: Permission.Ruleset, sessionID?: Session.ID) => Effect.Effect<Instructions.List>
|
||||
readonly load: (
|
||||
permissions: Permission.Ruleset,
|
||||
view: Pick<McpSession.View, "instructions" | "tools" | "owned">,
|
||||
) => Effect.Effect<Instructions.List>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/McpInstructions") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
export const layer = Layer.succeed(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* Mcp.Service
|
||||
|
||||
return Service.of({
|
||||
load: Effect.fn("McpInstructions.load")(function* (permissions, sessionID) {
|
||||
Service.of({
|
||||
load: Effect.fn("McpInstructions.load")(function* (permissions, view) {
|
||||
const source = (value: ReadonlyArray<Summary> | Instructions.Removed) =>
|
||||
Instructions.make<ReadonlyArray<Summary>>({
|
||||
key: Instructions.Key.make("core/mcp-guidance"),
|
||||
@@ -79,20 +78,17 @@ export const layer = Layer.effect(
|
||||
removed: () => "MCP server instructions are no longer available.",
|
||||
},
|
||||
})
|
||||
const [instructions, tools] = yield* Effect.all([mcp.instructions(sessionID), mcp.tools(sessionID)], {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
const tools = [...view.tools, ...view.owned.map((owned) => owned.tool)]
|
||||
const canExecute = Permission.evaluate("execute", "*", permissions).effect !== "deny"
|
||||
// Instructions are useful only when this Session can reach at least one server tool.
|
||||
const visible = instructions
|
||||
const visible = view.instructions
|
||||
.flatMap((item) => {
|
||||
const owned = tools.filter((tool) => tool.server === item.server)
|
||||
const codemode = owned[0]?.codemode !== false
|
||||
if (codemode && !canExecute) return []
|
||||
if (
|
||||
!owned.some(
|
||||
(tool) =>
|
||||
Permission.evaluate(McpTool.name(tool.server, tool.name), "*", permissions).effect !== "deny",
|
||||
(tool) => Permission.evaluate(McpTool.name(tool.server, tool.name), "*", permissions).effect !== "deny",
|
||||
)
|
||||
)
|
||||
return []
|
||||
@@ -105,8 +101,7 @@ export const layer = Layer.effect(
|
||||
.toSorted((a, b) => a.server.localeCompare(b.server))
|
||||
return source(visible.length === 0 ? Instructions.removed : visible)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Mcp.node] })
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [] })
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
export * as McpSession from "./session.js"
|
||||
|
||||
import type { Session } from "@opencode/schema/session"
|
||||
import { SessionEvent } from "@opencode/schema/session-event"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import { Context, Effect, Latch, Layer, Stream } from "effect"
|
||||
import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Credential } from "../credential.js"
|
||||
import { KeyedMutex } from "../effect/keyed-mutex.js"
|
||||
import { Environment } from "../environment/index.js"
|
||||
import { Form } from "../form.js"
|
||||
import { Location } from "../location.js"
|
||||
import { SessionStore } from "../session/store.js"
|
||||
import { Mcp } from "./index.js"
|
||||
|
||||
type Owner = Extract<Mcp.Owner, { readonly type: "session" }>
|
||||
type Entry = Mcp.Entry<Owner>
|
||||
type Registry = { readonly servers: Map<Mcp.ServerName, Entry>; readonly locks: KeyedMutex.KeyedMutex<Mcp.ServerName> }
|
||||
|
||||
export interface OwnedTool {
|
||||
readonly tool: Mcp.Tool
|
||||
readonly call: (input: {
|
||||
readonly args?: Record<string, unknown>
|
||||
readonly sessionID: Session.ID
|
||||
}) => Effect.Effect<Mcp.ToolResult, Mcp.ToolCallError>
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP capabilities visible to one Session: Location servers plus the servers registered for the Session
|
||||
* or one of its ancestors, nearest owner first. A visible Session-scoped server shadows the Location
|
||||
* server of the same name.
|
||||
*/
|
||||
export interface View {
|
||||
readonly shadowed: ReadonlySet<string>
|
||||
readonly servers: ReadonlyArray<Mcp.ServerInfo>
|
||||
/** Tools of the visible Location servers. */
|
||||
readonly tools: ReadonlyArray<Mcp.Tool>
|
||||
readonly owned: ReadonlyArray<OwnedTool>
|
||||
readonly instructions: ReadonlyArray<Mcp.ServerInstructions>
|
||||
readonly resourceCatalog: Effect.Effect<Mcp.ResourceCatalog>
|
||||
readonly resources: (server: string) => Effect.Effect<Mcp.ResourceCatalog, Error>
|
||||
readonly readResource: (input: {
|
||||
readonly server: string
|
||||
readonly uri: string
|
||||
}) => Effect.Effect<Mcp.ResourceContent | undefined, Error>
|
||||
}
|
||||
|
||||
/** Process-local MCP servers owned by a Session, released on removal or when the Session is deleted or moved away. */
|
||||
export interface Interface {
|
||||
/** Re-adding an identical config is a no-op; a different config replaces only this Session's server. */
|
||||
readonly add: (sessionID: Session.ID, server: string, config: Mcp.ServerConfig) => Effect.Effect<void>
|
||||
readonly remove: (sessionID: Session.ID, server: string) => Effect.Effect<void, Mcp.NotFoundError>
|
||||
readonly view: (sessionID: Session.ID) => Effect.Effect<View>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/McpSession") {}
|
||||
|
||||
export const layer = (options?: Mcp.Options) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* Mcp.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const bus = yield* Bus.Service
|
||||
const location = yield* Location.Service
|
||||
const registries = new Map<Session.ID, Registry>()
|
||||
|
||||
const withRegistry = <A, E, R>(
|
||||
sessionID: Session.ID,
|
||||
name: Mcp.ServerName,
|
||||
effect: (servers: Map<Mcp.ServerName, Entry>) => Effect.Effect<A, E, R>,
|
||||
) =>
|
||||
Effect.suspend(() => {
|
||||
const registry = registries.get(sessionID) ?? { servers: new Map(), locks: KeyedMutex.makeUnsafe() }
|
||||
registries.set(sessionID, registry)
|
||||
return registry.locks
|
||||
.withLock(name)(effect(registry.servers))
|
||||
.pipe(
|
||||
// Forget the Session once its last server is gone and no operation still waits on its locks.
|
||||
Effect.ensuring(
|
||||
Effect.gen(function* () {
|
||||
if (registry.servers.size > 0 || (yield* registry.locks.size) > 0) return
|
||||
if (registries.get(sessionID) === registry) registries.delete(sessionID)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const runtime = yield* Mcp.makeRuntime<Owner>({
|
||||
options,
|
||||
lock: (name, owner) => (effect) => withRegistry(owner.id, name, () => effect),
|
||||
})
|
||||
|
||||
const remove = (sessionID: Session.ID, server: string) => {
|
||||
const name = Mcp.ServerName.make(server)
|
||||
return withRegistry(sessionID, name, (servers) =>
|
||||
Effect.gen(function* () {
|
||||
const entry = servers.get(name)
|
||||
if (!entry) return yield* new Mcp.NotFoundError({ server: name })
|
||||
yield* runtime.stop(name, entry)
|
||||
servers.delete(name)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const visible = Effect.fnUntraced(function* (sessionID: Session.ID) {
|
||||
const scoped = new Map<Mcp.ServerName, Entry>()
|
||||
if (registries.size === 0) return scoped
|
||||
let current: Session.ID | undefined = sessionID
|
||||
while (current) {
|
||||
for (const [name, entry] of registries.get(current)?.servers ?? [])
|
||||
if (!scoped.has(name)) scoped.set(name, entry)
|
||||
current = (yield* store.get(current))?.parentID
|
||||
}
|
||||
return scoped
|
||||
})
|
||||
|
||||
yield* bus.subscribe([SessionEvent.Deleted, SessionEvent.Moved]).pipe(
|
||||
Stream.filter(
|
||||
(event) =>
|
||||
event.type === "session.deleted" ||
|
||||
event.data.location.directory !== location.directory ||
|
||||
event.data.location.workspaceID !== location.workspaceID,
|
||||
),
|
||||
Stream.runForEach((event) =>
|
||||
Effect.forEach(
|
||||
Array.from(registries.get(event.data.sessionID)?.servers.keys() ?? []),
|
||||
(name) => remove(event.data.sessionID, name).pipe(Effect.ignore),
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
add: Effect.fn("McpSession.add")(function* (sessionID, server, serverConfig) {
|
||||
const name = Mcp.ServerName.make(server)
|
||||
const config = Mcp.cloneConfig(serverConfig)
|
||||
yield* withRegistry(sessionID, name, (servers) =>
|
||||
Effect.gen(function* () {
|
||||
const previous = servers.get(name)
|
||||
if (previous && isDeepStrictEqual(previous.config, config)) return
|
||||
if (previous) yield* runtime.stop(name, previous)
|
||||
const entry: Entry = {
|
||||
owner: { type: "session", id: sessionID },
|
||||
config,
|
||||
status: { status: "pending" },
|
||||
startup: Latch.makeUnsafe(),
|
||||
}
|
||||
servers.set(name, entry)
|
||||
// Session-scoped servers are not registered as OAuth integrations: integration IDs derive from
|
||||
// name and URL, so owners sharing one would share and prematurely dispose its registration.
|
||||
yield* runtime.open(name, entry)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
remove: Effect.fn("McpSession.remove")(remove),
|
||||
view: Effect.fn("McpSession.view")(function* (sessionID) {
|
||||
const scoped = yield* visible(sessionID)
|
||||
const local = yield* Effect.all({
|
||||
servers: mcp.servers(),
|
||||
tools: mcp.tools(),
|
||||
instructions: mcp.instructions(),
|
||||
})
|
||||
const shadowed = new Set<string>(scoped.keys())
|
||||
const owned = Array.from(scoped)
|
||||
return {
|
||||
shadowed,
|
||||
servers: [
|
||||
...local.servers.filter((server) => !shadowed.has(server.name)),
|
||||
...owned.map(([name, entry]): Mcp.ServerInfo => ({ name, status: entry.status })),
|
||||
].toSorted((a, b) => a.name.localeCompare(b.name)),
|
||||
tools: local.tools.filter((tool) => !shadowed.has(tool.server)),
|
||||
owned: owned
|
||||
.flatMap(([name, entry]) =>
|
||||
(entry.tools ?? []).map(
|
||||
(tool): OwnedTool => ({
|
||||
tool,
|
||||
call: (input) => runtime.callTool(name, entry, { ...input, name: tool.name }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.toSorted((a, b) => a.tool.server.localeCompare(b.tool.server) || a.tool.name.localeCompare(b.tool.name)),
|
||||
instructions: [
|
||||
...local.instructions.filter((item) => !shadowed.has(item.server)),
|
||||
...owned.flatMap(([server, entry]) =>
|
||||
entry.client?.instructions ? [{ server, instructions: entry.client.instructions }] : [],
|
||||
),
|
||||
].toSorted((a, b) => a.server.localeCompare(b.server)),
|
||||
resourceCatalog: Effect.all(
|
||||
[mcp.resourceCatalog(), ...owned.map(([name, entry]) => runtime.catalog(name, entry))],
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(
|
||||
Effect.map(([catalog, ...catalogs]) =>
|
||||
Mcp.mergeCatalogs([
|
||||
{
|
||||
resources: catalog.resources.filter((resource) => !shadowed.has(resource.server)),
|
||||
templates: catalog.templates.filter((template) => !shadowed.has(template.server)),
|
||||
},
|
||||
...catalogs,
|
||||
]),
|
||||
),
|
||||
),
|
||||
resources: (server) => {
|
||||
const name = Mcp.ServerName.make(server)
|
||||
const entry = scoped.get(name)
|
||||
return entry ? runtime.resources(name, entry) : mcp.resources({ server })
|
||||
},
|
||||
readResource: (input) => {
|
||||
const name = Mcp.ServerName.make(input.server)
|
||||
const entry = scoped.get(name)
|
||||
return entry ? runtime.readResource(name, entry, input.uri) : mcp.readResource(input)
|
||||
},
|
||||
}
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export function configured(options?: Mcp.Options) {
|
||||
return makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer(options),
|
||||
deps: [Mcp.node, SessionStore.node, Location.node, Environment.node, Bus.node, Form.node, Credential.node],
|
||||
})
|
||||
}
|
||||
|
||||
export const node = configured()
|
||||
@@ -53,6 +53,7 @@ import { Location } from "../location.js"
|
||||
import { ManagedPolicy } from "../managed-policy.js"
|
||||
import { ModelsDev } from "../models-dev.js"
|
||||
import { Mcp } from "../mcp/index.js"
|
||||
import { McpSession } from "../mcp/session.js"
|
||||
import { Npm } from "@opencode/util/npm"
|
||||
import { Permission } from "../permission.js"
|
||||
import { Reference } from "../reference.js"
|
||||
@@ -132,6 +133,7 @@ const services = [
|
||||
ManagedPolicy.Service,
|
||||
ModelsDev.Service,
|
||||
Mcp.Service,
|
||||
McpSession.Service,
|
||||
Npm.Service,
|
||||
Permission.Service,
|
||||
Form.Service,
|
||||
@@ -185,6 +187,7 @@ export const requirements = LayerNode.group([
|
||||
ManagedPolicy.node,
|
||||
ModelsDev.node,
|
||||
Mcp.node,
|
||||
McpSession.node,
|
||||
Npm.node,
|
||||
Permission.node,
|
||||
Form.node,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Instructions } from "../instructions/index.js"
|
||||
import { InstructionBuiltIns } from "../instructions/builtins.js"
|
||||
import { Location } from "../location.js"
|
||||
import { McpInstructions } from "../mcp/instructions.js"
|
||||
import { McpSession } from "../mcp/session.js"
|
||||
import { McpTool } from "../tool/mcp.js"
|
||||
import { ReferenceInstructions } from "../reference/instructions.js"
|
||||
import { SkillInstructions } from "../skill/instructions.js"
|
||||
@@ -82,6 +83,7 @@ const layer = Layer.effect(
|
||||
const entries = yield* InstructionEntry.Service
|
||||
const location = yield* Location.Service
|
||||
const mcpInstructions = yield* McpInstructions.Service
|
||||
const mcpSessions = yield* McpSession.Service
|
||||
const mcpTools = yield* McpTool.Service
|
||||
const models = yield* SessionRunnerModel.Service
|
||||
const request = yield* SessionModelRequest.Service
|
||||
@@ -129,14 +131,15 @@ const layer = Layer.effect(
|
||||
if (!agent.info) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id })
|
||||
// Session permissions narrow discovery the same way they narrow the tool snapshot.
|
||||
const permissions = Permission.merge(agent.info.permissions, session.permissions ?? [])
|
||||
const mcp = yield* mcpSessions.view(sessionID)
|
||||
const loaded = yield* Effect.all(
|
||||
{
|
||||
tools: mcpTools.overlay(sessionID).pipe(Effect.flatMap((overlay) => registry.snapshot(permissions, overlay))),
|
||||
tools: mcpTools.overlay(mcp).pipe(Effect.flatMap((overlay) => registry.snapshot(permissions, overlay))),
|
||||
builtins: builtins.load(),
|
||||
discovery: discovery.load(),
|
||||
skills: skillInstructions.load(permissions),
|
||||
references: referenceInstructions.load(),
|
||||
mcp: mcpInstructions.load(permissions, sessionID),
|
||||
mcp: mcpInstructions.load(permissions, mcp),
|
||||
entries: entries.load(sessionID),
|
||||
},
|
||||
{ concurrency: "unbounded" },
|
||||
@@ -197,6 +200,7 @@ export const node = makeLocationNode({
|
||||
InstructionEntry.node,
|
||||
Location.node,
|
||||
McpInstructions.node,
|
||||
McpSession.node,
|
||||
McpTool.node,
|
||||
ReferenceInstructions.node,
|
||||
SessionRunnerModel.node,
|
||||
|
||||
@@ -43,7 +43,7 @@ The service uses shared `State` to replay synchronous transforms in registration
|
||||
|
||||
MCP owns one stable tool transform that reads its latest discovered tools. Tool-list changes update that source and reload the tool state instead of re-registering at the end of the transform order. MCP refresh therefore preserves the precedence of later plugin overrides.
|
||||
|
||||
Session-scoped MCP servers never enter the registry. `McpTool.overlay(sessionID)` returns their tools plus the effective names of the Location MCP tools they shadow, and `Tool.Service.snapshot(permissions, overlay)` layers them over the registry for that snapshot only. Registry transforms, including plugin overrides, do not apply to overlay tools.
|
||||
Session-scoped MCP servers never enter the registry. `McpTool.overlay(view)` turns a Session's `McpSession.View` into its owned tools plus the effective names of the Location MCP tools whose servers the view shadows, and `Tool.Service.snapshot(permissions, overlay)` layers them over the registry for that snapshot only. Registry transforms, including plugin overrides, do not apply to overlay tools.
|
||||
|
||||
Type safety ends at registration. The registry validates model input and declared output at runtime and should not carry producer schema generics through storage or execution.
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ export * as McpTool from "./mcp.js"
|
||||
import { ToolFailure } from "@opencode/ai"
|
||||
import { McpEvent } from "@opencode/schema/mcp-event"
|
||||
import type { Session } from "@opencode/schema/session"
|
||||
import type { McpSession } from "../mcp/session.js"
|
||||
import { Context, Effect, Fiber, type JsonSchema, Layer, PubSub, Semaphore, Stream } from "effect"
|
||||
import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import { Bus } from "../bus.js"
|
||||
@@ -20,8 +21,8 @@ export const name = (server: string, tool: string) => `${namespace(server)}_${to
|
||||
export interface Interface {
|
||||
/** Wait for the initial MCP tool registration to settle. */
|
||||
readonly flush: Effect.Effect<void>
|
||||
/** Session-scoped server tools visible to the Session, hiding the Location server tools they shadow. */
|
||||
readonly overlay: (sessionID: Session.ID) => Effect.Effect<Tool.Overlay | undefined>
|
||||
/** Session-owned server tools from a Session's MCP view, hiding the Location server tools they shadow. */
|
||||
readonly overlay: (view: Pick<McpSession.View, "shadowed" | "owned">) => Effect.Effect<Tool.Overlay | undefined>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/McpTool") {}
|
||||
@@ -36,9 +37,16 @@ export const layer = Layer.effect(
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
let discovered: Mcp.Tool[] = []
|
||||
|
||||
// Keyed by the MCP tool object so unchanged tools keep their identity across snapshots.
|
||||
// Keyed by the MCP tool object so unchanged tools keep their identity across snapshots. A tool object
|
||||
// belongs to exactly one server entry, so the first call bound to it stays valid.
|
||||
const infos = new WeakMap<Mcp.Tool, Tool.Info>()
|
||||
const info = (tool: Mcp.Tool) => {
|
||||
const info = (
|
||||
tool: Mcp.Tool,
|
||||
call: (input: {
|
||||
readonly args: Record<string, unknown>
|
||||
readonly sessionID: Session.ID
|
||||
}) => Effect.Effect<Mcp.ToolResult, Mcp.NotFoundError | Mcp.ToolCallError>,
|
||||
) => {
|
||||
const cached = infos.get(tool)
|
||||
if (cached) return cached
|
||||
const created: Tool.Info = {
|
||||
@@ -62,14 +70,10 @@ export const layer = Layer.effect(
|
||||
id: context.id,
|
||||
},
|
||||
})
|
||||
const result = yield* mcp
|
||||
.callTool({
|
||||
server: tool.server,
|
||||
name: tool.name,
|
||||
const result = yield* call({
|
||||
args: (input ?? {}) as Record<string, unknown>,
|
||||
sessionID: context.sessionID,
|
||||
})
|
||||
.pipe(
|
||||
}).pipe(
|
||||
Effect.catchTags({
|
||||
"MCP.NotFoundError": (error) =>
|
||||
new ToolFailure({ message: `MCP server "${error.server}" is not available` }),
|
||||
@@ -127,7 +131,8 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
discovered = yield* mcp.tools()
|
||||
yield* tools.transform((editor) => {
|
||||
for (const tool of discovered) editor.add(info(tool))
|
||||
for (const tool of discovered)
|
||||
editor.add(info(tool, (input) => mcp.callTool({ ...input, server: tool.server, name: tool.name })))
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -154,16 +159,16 @@ export const layer = Layer.effect(
|
||||
)
|
||||
return Service.of({
|
||||
flush: Effect.asVoid(Fiber.await(initial)),
|
||||
overlay: Effect.fn("McpTool.overlay")(function* (sessionID) {
|
||||
const visible = yield* mcp.tools(sessionID)
|
||||
const owned = visible.filter((tool) => tool.sessionID !== undefined)
|
||||
const shown = new Set(
|
||||
visible.filter((tool) => tool.sessionID === undefined).map((tool) => name(tool.server, tool.name)),
|
||||
overlay: (view) =>
|
||||
lock.withPermit(
|
||||
Effect.sync(() => {
|
||||
const hidden = new Set(
|
||||
discovered.filter((tool) => view.shadowed.has(tool.server)).map((tool) => name(tool.server, tool.name)),
|
||||
)
|
||||
const hidden = new Set(discovered.map((tool) => name(tool.server, tool.name)).filter((id) => !shown.has(id)))
|
||||
if (owned.length === 0 && hidden.size === 0) return undefined
|
||||
return { tools: owned.map(info), hidden }
|
||||
if (view.owned.length === 0 && hidden.size === 0) return undefined
|
||||
return { tools: view.owned.map((owned) => info(owned.tool, owned.call)), hidden }
|
||||
}),
|
||||
),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -4,12 +4,13 @@ import { ToolFailure } from "@opencode/ai"
|
||||
import type { Context } from "@opencode/plugin/effect/plugin"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Mcp } from "../../mcp/index.js"
|
||||
import { McpSession } from "../../mcp/session.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tools.mcp-resources",
|
||||
effect: Effect.fn("McpResourceTools.Plugin")(function* (ctx: Context) {
|
||||
const mcp = yield* Mcp.Service
|
||||
const mcp = yield* McpSession.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
yield* ctx.tool
|
||||
@@ -30,11 +31,9 @@ export const Plugin = {
|
||||
}),
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const view = yield* mcp.view(context.sessionID)
|
||||
// Listing every server checks each server name so per-server rules still apply.
|
||||
const servers =
|
||||
input.server === undefined
|
||||
? (yield* mcp.servers(context.sessionID)).map((server) => server.name)
|
||||
: [input.server]
|
||||
const servers = input.server === undefined ? view.servers.map((server) => server.name) : [input.server]
|
||||
yield* permission.assert({
|
||||
action: "opencode_list_mcp_resources",
|
||||
resources: servers,
|
||||
@@ -44,8 +43,8 @@ export const Plugin = {
|
||||
agent: context.agent,
|
||||
source: { type: "tool", messageID: context.messageID, id: context.id },
|
||||
})
|
||||
if (input.server === undefined) return { output: yield* mcp.resourceCatalog(context.sessionID) }
|
||||
return { output: yield* mcp.resources({ server: input.server, sessionID: context.sessionID }) }
|
||||
if (input.server === undefined) return { output: yield* view.resourceCatalog }
|
||||
return { output: yield* view.resources(input.server) }
|
||||
}).pipe(Effect.mapError((error) => new ToolFailure({ message: error.message, error }))),
|
||||
})
|
||||
editor.add({
|
||||
@@ -73,7 +72,8 @@ export const Plugin = {
|
||||
agent: context.agent,
|
||||
source: { type: "tool", messageID: context.messageID, id: context.id },
|
||||
})
|
||||
const resource = yield* mcp.readResource({ ...input, sessionID: context.sessionID })
|
||||
const view = yield* mcp.view(context.sessionID)
|
||||
const resource = yield* view.readResource(input)
|
||||
if (!resource)
|
||||
return yield* new ToolFailure({
|
||||
message: `MCP server "${input.server}" is not connected or does not expose resources`,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { Effect } from "effect"
|
||||
import { Mcp } from "@opencode/core/mcp/index"
|
||||
import { McpInstructions } from "@opencode/core/mcp/instructions"
|
||||
import { McpTool } from "@opencode/core/tool/mcp"
|
||||
@@ -14,25 +13,28 @@ const schema = { type: "object" as const }
|
||||
const tool = (server: string, name = "search") =>
|
||||
({ server: Mcp.ServerName.make(server), name, inputSchema: schema }) satisfies Mcp.Tool
|
||||
|
||||
const layer = (catalog: () => Mcp.ServerInstructions[], tools: () => Mcp.Tool[]) =>
|
||||
AppNodeBuilder.build(McpInstructions.node, [
|
||||
Mcp.node.replace(
|
||||
Layer.mock(Mcp.Service, {
|
||||
instructions: () => Effect.succeed(catalog()),
|
||||
tools: () => Effect.succeed(tools()),
|
||||
}),
|
||||
),
|
||||
])
|
||||
const view = (instructions: Mcp.ServerInstructions[], tools: Mcp.Tool[]) => ({ instructions, tools, owned: [] })
|
||||
|
||||
describe("McpInstructions", () => {
|
||||
it.effect("renders instructions for servers with at least one permitted tool", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* McpInstructions.Service
|
||||
const generation = yield* service
|
||||
.load([
|
||||
.load(
|
||||
[
|
||||
{ action: McpTool.name("alpha", "restricted"), resource: "*", effect: "deny" },
|
||||
{ action: McpTool.name("hidden", "search"), resource: "*", effect: "deny" },
|
||||
])
|
||||
],
|
||||
view(
|
||||
[
|
||||
instructions("beta", "Beta instructions"),
|
||||
instructions("unused", "No tools"),
|
||||
instructions("hidden", "Denied tool"),
|
||||
instructions("alpha", "Alpha line one\nAlpha line two"),
|
||||
],
|
||||
[tool("alpha"), tool("alpha", "restricted"), tool("beta"), tool("hidden")],
|
||||
),
|
||||
)
|
||||
.pipe(Effect.flatMap(readInitial))
|
||||
|
||||
expect(generation.text).toBe(
|
||||
@@ -50,44 +52,31 @@ describe("McpInstructions", () => {
|
||||
"</mcp_instructions>",
|
||||
].join("\n"),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
layer(
|
||||
() => [
|
||||
instructions("beta", "Beta instructions"),
|
||||
instructions("unused", "No tools"),
|
||||
instructions("hidden", "Denied tool"),
|
||||
instructions("alpha", "Alpha line one\nAlpha line two"),
|
||||
],
|
||||
() => [tool("alpha"), tool("alpha", "restricted"), tool("beta"), tool("hidden")],
|
||||
),
|
||||
),
|
||||
),
|
||||
}).pipe(Effect.provide(McpInstructions.layer)),
|
||||
)
|
||||
|
||||
it.effect("omits instructions when the agent cannot use execute", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* McpInstructions.Service
|
||||
const generation = yield* service
|
||||
.load([{ action: "execute", resource: "*", effect: "deny" }])
|
||||
.load(
|
||||
[{ action: "execute", resource: "*", effect: "deny" }],
|
||||
view([instructions("alpha", "Alpha instructions")], [tool("alpha")]),
|
||||
)
|
||||
.pipe(Effect.flatMap(readInitial))
|
||||
|
||||
expect(generation.text).toBe("")
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
layer(
|
||||
() => [instructions("alpha", "Alpha instructions")],
|
||||
() => [tool("alpha")],
|
||||
),
|
||||
),
|
||||
),
|
||||
}).pipe(Effect.provide(McpInstructions.layer)),
|
||||
)
|
||||
|
||||
it.effect("keeps MCP instructions when Code Mode is disabled and execute is denied", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* McpInstructions.Service
|
||||
const generation = yield* service
|
||||
.load([{ action: "execute", resource: "*", effect: "deny" }])
|
||||
.load(
|
||||
[{ action: "execute", resource: "*", effect: "deny" }],
|
||||
view([instructions("alpha", "Alpha instructions")], [{ ...tool("alpha"), codemode: false }]),
|
||||
)
|
||||
.pipe(Effect.flatMap(readInitial))
|
||||
|
||||
expect(generation.text).toBe(
|
||||
@@ -99,31 +88,19 @@ describe("McpInstructions", () => {
|
||||
"</mcp_instructions>",
|
||||
].join("\n"),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
layer(
|
||||
() => [instructions("alpha", "Alpha instructions")],
|
||||
() => [
|
||||
{
|
||||
server: Mcp.ServerName.make("alpha"),
|
||||
name: "search",
|
||||
inputSchema: schema,
|
||||
codemode: false,
|
||||
} satisfies Mcp.Tool,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
}).pipe(Effect.provide(McpInstructions.layer)),
|
||||
)
|
||||
|
||||
it.effect("restates guidance when Code Mode is disabled for a server", () => {
|
||||
let tools: Mcp.Tool[] = [tool("alpha")]
|
||||
return Effect.gen(function* () {
|
||||
it.effect("restates guidance when Code Mode is disabled for a server", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* McpInstructions.Service
|
||||
const initialized = yield* service.load([]).pipe(Effect.flatMap(readInitial))
|
||||
const catalog = [instructions("alpha", "Alpha instructions")]
|
||||
const initialized = yield* service.load([], view(catalog, [tool("alpha")])).pipe(Effect.flatMap(readInitial))
|
||||
|
||||
tools = [{ ...tool("alpha"), codemode: false }]
|
||||
const changed = yield* readUpdate(yield* service.load([]), initialized)
|
||||
const changed = yield* readUpdate(
|
||||
yield* service.load([], view(catalog, [{ ...tool("alpha"), codemode: false }])),
|
||||
initialized,
|
||||
)
|
||||
expect(changed.text).toBe(
|
||||
[
|
||||
"The available MCP server instructions have changed. This list supersedes the previous one.",
|
||||
@@ -134,25 +111,20 @@ describe("McpInstructions", () => {
|
||||
"</mcp_instructions>",
|
||||
].join("\n"),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
layer(
|
||||
() => [instructions("alpha", "Alpha instructions")],
|
||||
() => tools,
|
||||
),
|
||||
),
|
||||
}).pipe(Effect.provide(McpInstructions.layer)),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("renders additions, changes, and removal", () => {
|
||||
let catalog = [instructions("alpha", "Alpha instructions")]
|
||||
const tools = [tool("alpha"), tool("beta")]
|
||||
return Effect.gen(function* () {
|
||||
it.effect("renders additions, changes, and removal", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* McpInstructions.Service
|
||||
const initialized = yield* service.load([]).pipe(Effect.flatMap(readInitial))
|
||||
const tools = [tool("alpha"), tool("beta")]
|
||||
const load = (catalog: Mcp.ServerInstructions[]) => service.load([], view(catalog, tools))
|
||||
const initialized = yield* load([instructions("alpha", "Alpha instructions")]).pipe(Effect.flatMap(readInitial))
|
||||
|
||||
catalog = [instructions("alpha", "Alpha instructions"), instructions("beta", "Beta instructions")]
|
||||
const added = yield* readUpdate(yield* service.load([]), initialized)
|
||||
const added = yield* readUpdate(
|
||||
yield* load([instructions("alpha", "Alpha instructions"), instructions("beta", "Beta instructions")]),
|
||||
initialized,
|
||||
)
|
||||
expect(added.text).toBe(
|
||||
[
|
||||
"New MCP server instructions are available in addition to those previously listed:",
|
||||
@@ -163,8 +135,10 @@ describe("McpInstructions", () => {
|
||||
].join("\n"),
|
||||
)
|
||||
|
||||
catalog = [instructions("alpha", "Updated alpha"), instructions("beta", "Beta instructions")]
|
||||
const changed = yield* readUpdate(yield* service.load([]), added)
|
||||
const changed = yield* readUpdate(
|
||||
yield* load([instructions("alpha", "Updated alpha"), instructions("beta", "Beta instructions")]),
|
||||
added,
|
||||
)
|
||||
expect(changed.text).toBe(
|
||||
[
|
||||
"The available MCP server instructions have changed. This list supersedes the previous one.",
|
||||
@@ -181,21 +155,12 @@ describe("McpInstructions", () => {
|
||||
].join("\n"),
|
||||
)
|
||||
|
||||
catalog = [instructions("beta", "Beta instructions")]
|
||||
const removed = yield* readUpdate(yield* service.load([]), changed)
|
||||
const removed = yield* readUpdate(yield* load([instructions("beta", "Beta instructions")]), changed)
|
||||
expect(removed.text).toBe("Instructions for the following MCP servers are no longer available: alpha.")
|
||||
|
||||
catalog = []
|
||||
expect((yield* readUpdate(yield* service.load([]), removed)).text).toBe(
|
||||
expect((yield* readUpdate(yield* load([]), removed)).text).toBe(
|
||||
"MCP server instructions are no longer available.",
|
||||
)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
layer(
|
||||
() => catalog,
|
||||
() => tools,
|
||||
),
|
||||
),
|
||||
}).pipe(Effect.provide(McpInstructions.layer)),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import path from "node:path"
|
||||
import { mkdir, rm } from "node:fs/promises"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Context, Effect, Layer, Schedule } from "effect"
|
||||
import { Context, Effect, Layer, Schedule, Stream } from "effect"
|
||||
import { ConfigMCP } from "@opencode/schema/config/mcp"
|
||||
import { McpEvent } from "@opencode/schema/mcp-event"
|
||||
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { Database } from "@opencode/core/database/database"
|
||||
@@ -10,6 +12,7 @@ import { Instance } from "@opencode/core/instance/service"
|
||||
import { Location } from "@opencode/core/location"
|
||||
import { LocationServiceMap } from "@opencode/core/location-services"
|
||||
import { Mcp } from "@opencode/core/mcp/index"
|
||||
import { McpSession } from "@opencode/core/mcp/session"
|
||||
import { Project } from "@opencode/core/project"
|
||||
import { AbsolutePath } from "@opencode/core/schema"
|
||||
import { Session } from "@opencode/core/session"
|
||||
@@ -56,43 +59,53 @@ const it = testEffect(
|
||||
),
|
||||
)
|
||||
|
||||
const fixture = Effect.gen(function* () {
|
||||
const fixture = (subdirectory?: string) =>
|
||||
Effect.gen(function* () {
|
||||
const temporary = yield* tmpdirScoped()
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make(temporary.path) })
|
||||
const directory = subdirectory ? path.join(temporary.path, subdirectory) : temporary.path
|
||||
if (subdirectory) yield* Effect.promise(() => mkdir(directory))
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make(directory) })
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const context = yield* Effect.acquireRelease(locations.contextEffect(location), () => locations.invalidate(location))
|
||||
const server = (identity: string) =>
|
||||
const context = yield* Effect.acquireRelease(locations.contextEffect(location), () =>
|
||||
locations.invalidate(location),
|
||||
)
|
||||
const pidFile = (identity: string) => path.join(temporary.path, `${identity}.pid`)
|
||||
return {
|
||||
root: AbsolutePath.make(temporary.path),
|
||||
directory,
|
||||
location,
|
||||
server: (identity: string) =>
|
||||
new ConfigMCP.Local({
|
||||
type: "local",
|
||||
command: [
|
||||
process.execPath,
|
||||
path.join(import.meta.dir, "fixture/mcp-identity.ts"),
|
||||
identity,
|
||||
path.join(temporary.path, `${identity}.pid`),
|
||||
pidFile(identity),
|
||||
],
|
||||
})
|
||||
const pid = (identity: string) =>
|
||||
Effect.promise(() => Bun.file(path.join(temporary.path, `${identity}.pid`)).text()).pipe(Effect.map(Number))
|
||||
return {
|
||||
location,
|
||||
server,
|
||||
pid,
|
||||
}),
|
||||
pid: (identity: string) => Effect.promise(() => Bun.file(pidFile(identity)).text()).pipe(Effect.map(Number)),
|
||||
mcp: Context.get(context, Mcp.Service),
|
||||
sessions: Context.get(context, McpSession.Service),
|
||||
mcpTools: Context.get(context, McpTool.Service),
|
||||
registry: Context.get(context, Tool.Service),
|
||||
}
|
||||
})
|
||||
|
||||
const whoami = (mcp: Mcp.Interface, sessionID?: Session.ID) =>
|
||||
mcp
|
||||
.callTool({ server: "ctx", name: "whoami", sessionID })
|
||||
.pipe(Effect.map((result) => result.content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("")))
|
||||
const text = (result: Mcp.ToolResult) =>
|
||||
result.content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("")
|
||||
|
||||
// The harness still reads the user's global config, so assertions only look at the fixture's server.
|
||||
const toolNames = (tools: ReadonlyArray<Mcp.Tool>) =>
|
||||
const names = (tools: ReadonlyArray<Mcp.Tool>) =>
|
||||
tools.filter((tool) => tool.server === "ctx").map((tool) => `${tool.server}.${tool.name}`)
|
||||
const serverNames = (servers: ReadonlyArray<Mcp.ServerInfo>) =>
|
||||
servers.map((server) => server.name).filter((name) => name === "ctx")
|
||||
const owned = (view: McpSession.View) => names(view.owned.map((item) => item.tool))
|
||||
|
||||
const whoami = (view: McpSession.View, sessionID: Session.ID) =>
|
||||
Effect.gen(function* () {
|
||||
const tool = view.owned.find((item) => item.tool.server === "ctx" && item.tool.name === "whoami")
|
||||
if (!tool) return yield* Effect.die(new Error("whoami is not owned by this view"))
|
||||
return text(yield* tool.call({ sessionID }))
|
||||
})
|
||||
|
||||
const running = (pid: number) => {
|
||||
try {
|
||||
@@ -111,37 +124,44 @@ const exited = (pid: number) =>
|
||||
describe("Session-scoped MCP servers", () => {
|
||||
it.live("are visible only to their Session tree and shadow Location servers there", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* fixture
|
||||
const test = yield* fixture()
|
||||
const sessions = yield* Session.Service
|
||||
const root = yield* sessions.create({ location: test.location })
|
||||
const child = yield* sessions.create({ parentID: root.id })
|
||||
const other = yield* sessions.create({ location: test.location })
|
||||
const bystander = yield* sessions.create({ location: test.location })
|
||||
const broken = yield* sessions.create({ location: test.location })
|
||||
|
||||
yield* test.mcp.add("ctx", test.server("root"), root.id)
|
||||
yield* test.sessions.add(root.id, "ctx", test.server("root"))
|
||||
|
||||
expect(toolNames(yield* test.mcp.tools(root.id))).toEqual(["ctx.only_root", "ctx.whoami"])
|
||||
expect(toolNames(yield* test.mcp.tools(child.id))).toEqual(["ctx.only_root", "ctx.whoami"])
|
||||
expect(toolNames(yield* test.mcp.tools(other.id))).toEqual([])
|
||||
expect(toolNames(yield* test.mcp.tools())).toEqual([])
|
||||
expect(serverNames(yield* test.mcp.servers())).toEqual([])
|
||||
expect(serverNames(yield* test.mcp.servers(child.id))).toEqual(["ctx"])
|
||||
expect(yield* whoami(test.mcp, child.id)).toBe("root")
|
||||
expect(yield* whoami(test.mcp, other.id).pipe(Effect.flip)).toBeInstanceOf(Mcp.NotFoundError)
|
||||
expect(owned(yield* test.sessions.view(root.id))).toEqual(["ctx.only_root", "ctx.whoami"])
|
||||
expect(owned(yield* test.sessions.view(child.id))).toEqual(["ctx.only_root", "ctx.whoami"])
|
||||
expect(owned(yield* test.sessions.view(other.id))).toEqual([])
|
||||
expect(names(yield* test.mcp.tools())).toEqual([])
|
||||
expect((yield* test.mcp.servers()).some((server) => server.name === "ctx")).toBe(false)
|
||||
expect((yield* test.sessions.view(child.id)).servers.some((server) => server.name === "ctx")).toBe(true)
|
||||
expect(yield* whoami(yield* test.sessions.view(child.id), child.id)).toBe("root")
|
||||
|
||||
yield* test.mcp.add("ctx", test.server("other"), other.id)
|
||||
yield* test.sessions.add(other.id, "ctx", test.server("other"))
|
||||
yield* test.sessions.add(broken.id, "ctx", new ConfigMCP.Local({ type: "local", command: ["false"] }))
|
||||
yield* test.mcp.add("ctx", test.server("location"))
|
||||
|
||||
expect(yield* whoami(test.mcp, root.id)).toBe("root")
|
||||
expect(yield* whoami(test.mcp, other.id)).toBe("other")
|
||||
expect(yield* whoami(test.mcp, bystander.id)).toBe("location")
|
||||
expect(yield* whoami(test.mcp)).toBe("location")
|
||||
expect(toolNames(yield* test.mcp.tools(root.id))).toEqual(["ctx.only_root", "ctx.whoami"])
|
||||
expect(toolNames(yield* test.mcp.tools())).toEqual(["ctx.only_location", "ctx.whoami"])
|
||||
expect(yield* whoami(yield* test.sessions.view(root.id), root.id)).toBe("root")
|
||||
expect(yield* whoami(yield* test.sessions.view(other.id), other.id)).toBe("other")
|
||||
expect(text(yield* test.mcp.callTool({ server: "ctx", name: "whoami", sessionID: bystander.id }))).toBe(
|
||||
"location",
|
||||
)
|
||||
const rootView = yield* test.sessions.view(root.id)
|
||||
expect(rootView.shadowed.has("ctx")).toBe(true)
|
||||
expect(names(rootView.tools)).toEqual([])
|
||||
const bystanderView = yield* test.sessions.view(bystander.id)
|
||||
expect(bystanderView.shadowed.size).toBe(0)
|
||||
expect(names(bystanderView.tools)).toEqual(["ctx.only_location", "ctx.whoami"])
|
||||
|
||||
yield* waitForCodeModeTool(test.registry, "ctx.only_location")
|
||||
const listed = (sessionID: Session.ID) =>
|
||||
test.mcpTools.overlay(sessionID).pipe(
|
||||
test.sessions.view(sessionID).pipe(
|
||||
Effect.flatMap(test.mcpTools.overlay),
|
||||
Effect.flatMap((overlay) => test.registry.snapshot(undefined, overlay)),
|
||||
Effect.map((snapshot) =>
|
||||
(snapshot.codeModeCatalog ? codeModeListings(snapshot.codeModeCatalog) : [])
|
||||
@@ -151,40 +171,73 @@ describe("Session-scoped MCP servers", () => {
|
||||
)
|
||||
expect(yield* listed(child.id)).toEqual(["ctx.only_root", "ctx.whoami"])
|
||||
expect(yield* listed(bystander.id)).toEqual(["ctx.only_location", "ctx.whoami"])
|
||||
expect(yield* listed(broken.id)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("releases servers on replacement, removal, and Session deletion", () =>
|
||||
it.live("releases servers on replacement, removal, and Session deletion without Location events", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* fixture
|
||||
const test = yield* fixture()
|
||||
const sessions = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const events: string[] = []
|
||||
yield* bus
|
||||
.subscribe([McpEvent.StatusChanged, McpEvent.ToolsChanged, McpEvent.ResourcesChanged, Mcp.PromptsChanged])
|
||||
.pipe(
|
||||
Stream.runForEach((event) => Effect.sync(() => events.push(`${event.type}:${event.data.server}`))),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const first = yield* sessions.create({ location: test.location })
|
||||
const second = yield* sessions.create({ location: test.location })
|
||||
|
||||
yield* test.mcp.add("ctx", test.server("first"), first.id)
|
||||
yield* test.mcp.add("ctx", test.server("second"), second.id)
|
||||
yield* test.sessions.add(first.id, "ctx", test.server("first"))
|
||||
yield* test.sessions.add(second.id, "ctx", test.server("second"))
|
||||
const firstPid = yield* test.pid("first")
|
||||
const secondPid = yield* test.pid("second")
|
||||
|
||||
yield* test.mcp.add("ctx", test.server("first"), first.id)
|
||||
yield* test.sessions.add(first.id, "ctx", test.server("first"))
|
||||
expect(yield* test.pid("first")).toBe(firstPid)
|
||||
expect(running(firstPid)).toBe(true)
|
||||
|
||||
yield* test.mcp.add("ctx", test.server("replaced"), first.id)
|
||||
yield* test.sessions.add(first.id, "ctx", test.server("replaced"))
|
||||
yield* exited(firstPid)
|
||||
expect(yield* whoami(test.mcp, first.id)).toBe("replaced")
|
||||
expect(yield* whoami(test.mcp, second.id)).toBe("second")
|
||||
expect(yield* whoami(yield* test.sessions.view(first.id), first.id)).toBe("replaced")
|
||||
expect(yield* whoami(yield* test.sessions.view(second.id), second.id)).toBe("second")
|
||||
expect(running(secondPid)).toBe(true)
|
||||
|
||||
const replacedPid = yield* test.pid("replaced")
|
||||
yield* test.mcp.remove("ctx", first.id)
|
||||
yield* test.sessions.remove(first.id, "ctx")
|
||||
yield* exited(replacedPid)
|
||||
expect(toolNames(yield* test.mcp.tools(first.id))).toEqual([])
|
||||
expect(yield* test.mcp.remove("ctx", first.id).pipe(Effect.flip)).toBeInstanceOf(Mcp.NotFoundError)
|
||||
expect(owned(yield* test.sessions.view(first.id))).toEqual([])
|
||||
expect(yield* test.sessions.remove(first.id, "ctx").pipe(Effect.flip)).toBeInstanceOf(Mcp.NotFoundError)
|
||||
|
||||
yield* sessions.remove(second.id)
|
||||
yield* exited(secondPid)
|
||||
expect(toolNames(yield* test.mcp.tools(second.id))).toEqual([])
|
||||
expect(owned(yield* test.sessions.view(second.id))).toEqual([])
|
||||
|
||||
yield* test.mcp.add("probe", new ConfigMCP.Local({ type: "local", command: ["unused"], disabled: true }))
|
||||
yield* Effect.suspend(() =>
|
||||
events.includes(`${McpEvent.StatusChanged.type}:probe`) ? Effect.void : Effect.fail("probe event missing"),
|
||||
).pipe(Effect.retry({ times: 100, schedule: Schedule.spaced("10 millis") }))
|
||||
expect(events.filter((event) => event.endsWith(":ctx"))).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("releases servers in the former Location when their Session moves", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* fixture("source")
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({ location: test.location })
|
||||
|
||||
yield* test.sessions.add(session.id, "ctx", test.server("moved"))
|
||||
const pid = yield* test.pid("moved")
|
||||
// A missing source directory applies the move immediately instead of at a step boundary.
|
||||
yield* Effect.promise(() => rm(test.directory, { recursive: true }))
|
||||
yield* sessions.move({ sessionID: session.id, directory: test.root })
|
||||
|
||||
expect((yield* sessions.get(session.id)).location.directory).toBe(test.root)
|
||||
yield* exited(pid)
|
||||
expect(owned(yield* test.sessions.view(session.id))).toEqual([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -25,6 +25,7 @@ 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 { McpSession } from "@opencode/core/mcp/session"
|
||||
import { McpClient } from "@opencode/core/mcp/client"
|
||||
import { McpStdio } from "@opencode/core/mcp/stdio"
|
||||
import { Permission } from "@opencode/core/permission"
|
||||
@@ -284,6 +285,7 @@ function resourceMcpLayer(
|
||||
yield* ConfigMcpPlugin.register(bus.subscribe())
|
||||
}),
|
||||
).pipe(
|
||||
Layer.provideMerge(McpSession.layer(options)),
|
||||
Layer.provideMerge(Mcp.layer(options)),
|
||||
Layer.provideMerge(Form.layer),
|
||||
Layer.provide(
|
||||
@@ -349,7 +351,7 @@ function resourceMcpLayer(
|
||||
},
|
||||
}),
|
||||
Layer.mock(Credential.Service, {}),
|
||||
Layer.mock(SessionStore.Service, {}),
|
||||
AppNodeBuilder.build(SessionStore.node),
|
||||
overrides?.environment ?? hostEnvironmentLayer,
|
||||
),
|
||||
),
|
||||
@@ -1991,17 +1993,8 @@ testEffect(Layer.empty).live("keeps MCP config snapshots stable during an in-fli
|
||||
|
||||
const shutdownIt = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
Bus.node,
|
||||
Integration.node,
|
||||
Credential.node,
|
||||
Form.node,
|
||||
Environment.node,
|
||||
Location.node,
|
||||
SessionStore.node,
|
||||
]),
|
||||
LayerNode.group([Bus.node, Integration.node, Credential.node, Form.node, Environment.node, Location.node]),
|
||||
[
|
||||
SessionStore.node.replace(Layer.mock(SessionStore.Service, {})),
|
||||
Location.node.replace(
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
import { response } from "../location"
|
||||
|
||||
const notFound = <A, R>(effect: Effect.Effect<A, Mcp.NotFoundError, R>) =>
|
||||
export const notFound = <A, R>(effect: Effect.Effect<A, Mcp.NotFoundError, R>) =>
|
||||
effect.pipe(Effect.mapError((error) => new McpServerNotFoundError({ server: error.server, message: error.message })))
|
||||
|
||||
export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { SessionTitle } from "@opencode/core/session/title"
|
||||
import { SessionTransfer } from "@opencode/core/session/transfer"
|
||||
import { InstructionEntry } from "@opencode/core/session/instruction-entry"
|
||||
import { Form } from "@opencode/core/form"
|
||||
import { Mcp } from "@opencode/core/mcp/index"
|
||||
import { McpSession } from "@opencode/core/mcp/session"
|
||||
import { DateTime, Effect, Stream } from "effect"
|
||||
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
FormNotFoundError,
|
||||
InvalidRequestError,
|
||||
InvalidCursorError,
|
||||
McpServerNotFoundError,
|
||||
MessageNotFoundError,
|
||||
ServiceUnavailableError,
|
||||
SessionBusyError,
|
||||
@@ -26,6 +25,7 @@ import {
|
||||
} from "@opencode/protocol/errors"
|
||||
import { AbsolutePath } from "@opencode/core/schema"
|
||||
import { failedMessageDecode, failedSnapshot, missingMessage, missingSession } from "./session-error"
|
||||
import { notFound } from "./mcp"
|
||||
|
||||
const DefaultSessionsLimit = 50
|
||||
|
||||
@@ -588,22 +588,16 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
.handle(
|
||||
"session.mcp.add",
|
||||
Effect.fn(function* (ctx) {
|
||||
const mcp = yield* Mcp.Service
|
||||
yield* mcp.add(ctx.params.server, ctx.payload.config, ctx.params.sessionID)
|
||||
const mcp = yield* McpSession.Service
|
||||
yield* mcp.add(ctx.params.sessionID, ctx.params.server, ctx.payload.config)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.mcp.remove",
|
||||
Effect.fn(function* (ctx) {
|
||||
const mcp = yield* Mcp.Service
|
||||
yield* mcp
|
||||
.remove(ctx.params.server, ctx.params.sessionID)
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(error) => new McpServerNotFoundError({ server: error.server, message: error.message }),
|
||||
),
|
||||
)
|
||||
const mcp = yield* McpSession.Service
|
||||
yield* notFound(mcp.remove(ctx.params.sessionID, ctx.params.server))
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -21,6 +21,7 @@ import { SessionTransfer } from "@opencode/core/session/transfer"
|
||||
import { ShellSelect } from "@opencode/core/shell/select"
|
||||
import { Job } from "@opencode/core/job"
|
||||
import { Mcp } from "@opencode/core/mcp/index"
|
||||
import { McpSession } from "@opencode/core/mcp/session"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { InstructionDiscovery } from "@opencode/core/instruction-discovery"
|
||||
import { LocationServiceMap } from "@opencode/core/location-service-map"
|
||||
@@ -112,6 +113,7 @@ function makeRoutes<AuthError, AuthServices>(
|
||||
overrides: LayerNode.Replacements,
|
||||
instances?: InstanceNode,
|
||||
) {
|
||||
const clientInfo = { name: options.app?.name ?? "opencode", version: options.app?.version ?? "unknown" }
|
||||
const standard: LayerNode.Replacements = [
|
||||
Database.node.replace(Database.configured(options.database)),
|
||||
PersistentPty.node.replace(PersistentPty.configured(options.pty)),
|
||||
@@ -130,14 +132,8 @@ function makeRoutes<AuthError, AuthServices>(
|
||||
),
|
||||
InstructionDiscovery.node.replace(InstructionDiscovery.configured({ project: options.config?.project })),
|
||||
ShellSelect.node.replace(ShellSelect.configured({ gitbash: options.windows?.gitbash })),
|
||||
Mcp.node.replace(
|
||||
Mcp.configured({
|
||||
clientInfo: {
|
||||
name: options.app?.name ?? "opencode",
|
||||
version: options.app?.version ?? "unknown",
|
||||
},
|
||||
}),
|
||||
),
|
||||
Mcp.node.replace(Mcp.configured({ clientInfo })),
|
||||
McpSession.node.replace(McpSession.configured({ clientInfo })),
|
||||
]
|
||||
const build = (overrides: LayerNode.Replacements) => {
|
||||
const replacements: LayerNode.Replacements = [
|
||||
|
||||
Reference in New Issue
Block a user