Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js"
import { AsyncLocalStorage } from "node:async_hooks"
import {
ListRootsRequestSchema,
type LoggingMessageNotification,
Expand Down Expand Up @@ -36,6 +38,48 @@ import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { McpBrowser } from "./browser"

const DEFAULT_TIMEOUT = 30_000

/** @internal Exported for testing */
export interface McpCallStore {
server: string
tool: string
sessionID: string
callID: string
headers: Record<string, string>
}

/** @internal Exported for testing */
export const McpCallContext = new AsyncLocalStorage<McpCallStore>()

function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
if (!headers) return {}
const out: Record<string, string> = {}
if (headers instanceof Headers) {
headers.forEach((value, key) => {
out[key.toLowerCase()] = value
})
return out
}
if (Array.isArray(headers)) {
for (const [k, v] of headers) out[k.toLowerCase()] = v
return out
}
for (const [k, v] of Object.entries(headers)) out[k.toLowerCase()] = v
return out
}

/** @internal Exported for testing */
export function makeMcpFetch(base: FetchLike = fetch): FetchLike {
return async (url, init) => {
const store = McpCallContext.getStore()
if (!store) return base(url, init)
const merged: Record<string, string> = {
...normalizeHeaders(init?.headers),
...normalizeHeaders(store.headers),
}
return base(url, { ...init, headers: merged })
}
}
const CLIENT_OPTIONS = {
capabilities: {
// https://github.com/anomalyco/opencode/issues/11948
Expand Down Expand Up @@ -159,6 +203,7 @@ export interface McpTool {
readonly def: MCPToolDef
readonly client: MCPClient
readonly timeout?: number
readonly server: string
}

export interface Interface {
Expand Down Expand Up @@ -272,13 +317,15 @@ const layer = Layer.effect(
transport: new StreamableHTTPClientTransport(url, {
authProvider,
requestInit: mcp.headers ? { headers: mcp.headers } : undefined,
fetch: makeMcpFetch(),
}),
},
{
name: "SSE",
transport: new SSEClientTransport(url, {
authProvider,
requestInit: mcp.headers ? { headers: mcp.headers } : undefined,
fetch: makeMcpFetch(),
}),
},
]
Expand Down Expand Up @@ -681,7 +728,7 @@ const layer = Layer.effect(
}
const timeout = requestTimeout(s, clientName, mcpConfig, defaultTimeout)
for (const def of listed) {
result[McpCatalog.toolName(clientName, def.name)] = { def, client, timeout }
result[McpCatalog.toolName(clientName, def.name)] = { def, client, timeout, server: clientName }
}
}
return result
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1237,6 +1237,7 @@ const layer = Layer.effect(
Effect.provideService(ToolRegistry.Service, registry),
Effect.provideService(MCP.Service, mcp),
Effect.provideService(Truncate.Service, truncate),
Effect.provideService(Config.Service, config),
Effect.provideService(RuntimeFlags.Service, flags),
)

Expand Down
57 changes: 55 additions & 2 deletions packages/opencode/src/session/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ import { ToolRegistry } from "@/tool/registry"
import { Truncate } from "@/tool/truncate"

import { Plugin } from "@/plugin"
import { Config } from "@/config/config"
import type { TaskPromptOps } from "@/tool/task"
import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai"
import { Effect } from "effect"
import { Cause, Effect } from "effect"
import { MessageV2 } from "./message-v2"
import { Session } from "./session"
import { SessionProcessor } from "./processor"
Expand Down Expand Up @@ -54,6 +55,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
const registry = yield* ToolRegistry.Service
const mcp = yield* MCP.Service
const truncate = yield* Truncate.Service
const config = yield* Config.Service
const flags = yield* RuntimeFlags.Service

const context = (args: Record<string, unknown>, options: ToolExecutionOptions): Tool.Context => ({
Expand Down Expand Up @@ -404,9 +406,60 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId },
{ args },
)
const meta = { server: entry.server, tool: entry.def.name }
let mcpHeaders: Record<string, string> | undefined
if (meta) {
const cfg = yield* config.get()
const serverCfg = cfg.mcp?.[meta.server]
const staticHeaders: Record<string, string> = {}
if (serverCfg && "headers" in serverCfg && serverCfg.headers) {
for (const [k, v] of Object.entries(serverCfg.headers)) {
staticHeaders[k.toLowerCase()] = v
}
}
const output = { headers: staticHeaders }
yield* plugin
.trigger(
"mcp.call.before",
{
server: meta.server,
tool: meta.tool,
sessionID: ctx.sessionID,
callID: opts.toolCallId,
},
output,
)
.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("mcp.call.before plugin failed", {
server: meta.server,
tool: meta.tool,
sessionID: ctx.sessionID,
callID: opts.toolCallId,
error: Cause.pretty(cause),
}),
),
)
mcpHeaders = output.headers
}

const result: Awaited<ReturnType<NonNullable<typeof execute>>> = yield* Effect.gen(function* () {
yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] })
return yield* Effect.promise(() => execute(args, opts))
return yield* Effect.promise(() => {
if (mcpHeaders) {
return MCP.McpCallContext.run(
{
server: meta!.server,
tool: meta!.tool,
sessionID: ctx.sessionID,
callID: opts.toolCallId,
headers: mcpHeaders,
},
() => execute(args, opts),
)
}
return execute(args, opts)
})
}).pipe(
Effect.withSpan("Tool.execute", {
attributes: {
Expand Down
151 changes: 151 additions & 0 deletions packages/opencode/test/mcp/call-before-integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Npm } from "@opencode-ai/core/npm"
import path from "path"
import { pathToFileURL } from "url"
import { Account } from "../../src/account/account"
import { Auth } from "../../src/auth"
import { RuntimeFlags } from "../../src/effect/runtime-flags"
import { Plugin } from "../../src/plugin/index"
import { McpCallContext, makeMcpFetch } from "../../src/mcp/index"
import { TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { AccountTest } from "../fake/account"
import { AuthTest } from "../fake/auth"
import { NpmTest } from "../fake/npm"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"

const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Plugin.node, CrossSpawnSpawner.node]), [
[Auth.node, AuthTest.empty],
[Account.node, AccountTest.empty],
[Npm.node, NpmTest.noop],
[RuntimeFlags.node, RuntimeFlags.layer({ disableDefaultPlugins: true })],
]),
)

function withProject<A, E, R>(source: string, self: Effect.Effect<A, E, R>) {
return Effect.gen(function* () {
const test = yield* TestInstance
const file = path.join(test.directory, "plugin.ts")
yield* Effect.all(
[
Effect.promise(() => Bun.write(file, source)),
Effect.promise(() =>
Bun.write(
path.join(test.directory, "opencode.json"),
JSON.stringify(
{
$schema: "https://opencode.ai/config.json",
plugin: [pathToFileURL(file).href],
},
null,
2,
),
),
),
],
{ discard: true, concurrency: 2 },
)
return yield* self
})
}

const stubFetch = (calls: Array<RequestInit | undefined>) => async (_url: string | URL, init?: RequestInit) => {
calls.push(init)
return new Response("ok")
}

describe("mcp.call.before integration", () => {
it.instance("plugin-supplied headers reach the transport fetch wrapper", () =>
withProject(
[
"export default async () => ({",
' "mcp.call.before": async (input, output) => {',
' output.headers["x-session-id"] = input.sessionID',
" },",
"})",
"",
].join("\n"),
Effect.gen(function* () {
const plugin = yield* Plugin.Service

// Static config-style starting headers (already lowercased)
const output = { headers: { authorization: "Bearer T" } as Record<string, string> }

yield* plugin.trigger(
"mcp.call.before",
{ server: "metrics", tool: "query", sessionID: "sess-1", callID: "call-1" },
output,
)

expect(output.headers["authorization"]).toBe("Bearer T")
expect(output.headers["x-session-id"]).toBe("sess-1")

// Drive the resolved headers through makeMcpFetch, like the prompt loop does
const fetchCalls: Array<RequestInit | undefined> = []
const wrapped = makeMcpFetch(stubFetch(fetchCalls) as never)

yield* Effect.promise(() =>
McpCallContext.run(
{ server: "metrics", tool: "query", sessionID: "sess-1", callID: "call-1", headers: output.headers },
() => wrapped("https://example.com/", { headers: { "x-static-header": "preset" } }),
),
)

expect(fetchCalls.length).toBe(1)
expect(fetchCalls[0]?.headers).toEqual({
authorization: "Bearer T",
"x-session-id": "sess-1",
"x-static-header": "preset",
})
}),
),
)

it.instance("headers mutated before a plugin throw still reach the transport fetch wrapper", () =>
withProject(
[
"export default async () => ({",
' "mcp.call.before": async (_input, output) => {',
' output.headers["x-from-plugin"] = "before-throw"',
' throw new Error("boom")',
" },",
"})",
"",
].join("\n"),
Effect.gen(function* () {
const plugin = yield* Plugin.Service

const output = { headers: { authorization: "Bearer T" } as Record<string, string> }

// catchCause so a throwing plugin doesn't abort the tool call
yield* plugin
.trigger("mcp.call.before", { server: "metrics", tool: "query", sessionID: "s", callID: "c" }, output)
.pipe(Effect.catchCause(() => Effect.succeed(output)))

// Whatever the plugin wrote before throwing must still be present
expect(output.headers["x-from-plugin"]).toBe("before-throw")

const fetchCalls: Array<RequestInit | undefined> = []
const wrapped = makeMcpFetch(stubFetch(fetchCalls) as never)

yield* Effect.promise(() =>
McpCallContext.run(
{ server: "metrics", tool: "query", sessionID: "s", callID: "c", headers: output.headers },
() => wrapped("https://example.com/", { headers: { "x-static": "yes" } }),
),
)

expect(fetchCalls.length).toBe(1)
expect(fetchCalls[0]?.headers).toEqual({
authorization: "Bearer T",
"x-from-plugin": "before-throw",
"x-static": "yes",
})
}),
),
)
})
Loading
Loading