refactor(core): isolate session MCP registry behind a session view

This commit is contained in:
Shoubhit Dash
2026-09-30 20:15:17 +05:30
parent 704ef1c693
commit 6b61e05776
18 changed files with 1041 additions and 837 deletions
+8 -29
View File
@@ -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([{}, {}, {}, {}, {}, {}])
})
})
+2
View File
@@ -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,
+145
View File
@@ -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]
File diff suppressed because it is too large Load Diff
+41 -46
View File
@@ -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,57 +55,53 @@ 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) {
const source = (value: ReadonlyArray<Summary> | Instructions.Removed) =>
Instructions.make<ReadonlyArray<Summary>>({
key: Instructions.Key.make("core/mcp-guidance"),
codec: Schema.toCodecJson(Schema.Array(Summary)),
read: Effect.succeed(value),
render: {
initial: render,
changed: update,
removed: () => "MCP server instructions are no longer available.",
},
})
const [instructions, tools] = yield* Effect.all([mcp.instructions(sessionID), mcp.tools(sessionID)], {
concurrency: "unbounded",
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"),
codec: Schema.toCodecJson(Schema.Array(Summary)),
read: Effect.succeed(value),
render: {
initial: render,
changed: update,
removed: () => "MCP server instructions are no longer available.",
},
})
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
.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",
)
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 = 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",
)
return []
return [
codemode
? { server: item.server, instructions: item.instructions }
: { server: item.server, instructions: item.instructions, codemode: false as const },
]
})
.toSorted((a, b) => a.server.localeCompare(b.server))
return source(visible.length === 0 ? Instructions.removed : visible)
}),
})
)
return []
return [
codemode
? { server: item.server, instructions: item.instructions }
: { server: item.server, instructions: item.instructions, codemode: false as const },
]
})
.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: [] })
+229
View File
@@ -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()
+3
View File
@@ -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,
+6 -2
View File
@@ -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,
+1 -1
View File
@@ -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.
+34 -29
View File
@@ -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,20 +70,16 @@ export const layer = Layer.effect(
id: context.id,
},
})
const result = yield* mcp
.callTool({
server: tool.server,
name: tool.name,
args: (input ?? {}) as Record<string, unknown>,
sessionID: context.sessionID,
})
.pipe(
Effect.catchTags({
"MCP.NotFoundError": (error) =>
new ToolFailure({ message: `MCP server "${error.server}" is not available` }),
"MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }),
}),
)
const result = yield* call({
args: (input ?? {}) as Record<string, unknown>,
sessionID: context.sessionID,
}).pipe(
Effect.catchTags({
"MCP.NotFoundError": (error) =>
new ToolFailure({ message: `MCP server "${error.server}" is not available` }),
"MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }),
}),
)
if (result.isError)
return yield* new ToolFailure({
message:
@@ -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)),
)
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 }
}),
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)),
)
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`,
+55 -90
View File
@@ -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([
{ action: McpTool.name("alpha", "restricted"), resource: "*", effect: "deny" },
{ action: McpTool.name("hidden", "search"), resource: "*", effect: "deny" },
])
.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)),
)
})
+117 -64
View File
@@ -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 temporary = yield* tmpdirScoped()
const location = Location.Ref.make({ directory: AbsolutePath.make(temporary.path) })
const locations = yield* LocationServiceMap.Service
const context = yield* Effect.acquireRelease(locations.contextEffect(location), () => locations.invalidate(location))
const 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`),
],
})
const pid = (identity: string) =>
Effect.promise(() => Bun.file(path.join(temporary.path, `${identity}.pid`)).text()).pipe(Effect.map(Number))
return {
location,
server,
pid,
mcp: Context.get(context, Mcp.Service),
mcpTools: Context.get(context, McpTool.Service),
registry: Context.get(context, Tool.Service),
}
})
const fixture = (subdirectory?: string) =>
Effect.gen(function* () {
const temporary = yield* tmpdirScoped()
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 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,
pidFile(identity),
],
}),
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([])
}),
)
})
+4 -11
View File
@@ -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,
+1 -1
View File
@@ -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) =>
+6 -12
View File
@@ -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()
}),
)
+4 -8
View File
@@ -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 = [