From 7e7ad377364d2d6c9f63d79cb8481298abaf0756 Mon Sep 17 00:00:00 2001 From: Grant Martin <69131375+Grantmartin2002@users.noreply.github.com> Date: Thu, 11 Jun 2026 16:05:45 -0500 Subject: [PATCH] feat(opencode): support cwd on local MCP servers (#30676) Co-authored-by: Aiden Cline --- packages/core/src/config/mcp.ts | 3 +++ packages/core/src/v1/config/mcp.ts | 3 +++ packages/core/src/v1/config/migrate.ts | 9 +++++++- packages/opencode/src/mcp/index.ts | 4 +++- packages/opencode/test/mcp/lifecycle.test.ts | 23 +++++++++++++++++-- packages/web/src/content/docs/mcp-servers.mdx | 15 ++++++------ 6 files changed, 46 insertions(+), 11 deletions(-) diff --git a/packages/core/src/config/mcp.ts b/packages/core/src/config/mcp.ts index fce853815..54998e185 100644 --- a/packages/core/src/config/mcp.ts +++ b/packages/core/src/config/mcp.ts @@ -6,6 +6,9 @@ import { PositiveInt } from "../schema" export class Local extends Schema.Class("ConfigV2.MCP.Local")({ type: Schema.Literal("local"), command: Schema.String.pipe(Schema.Array), + cwd: Schema.String.pipe(Schema.optional).annotate({ + description: "Working directory for the MCP server process. Relative paths resolve from the workspace directory.", + }), environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), disabled: Schema.Boolean.pipe(Schema.optional), timeout: PositiveInt.pipe(Schema.optional), diff --git a/packages/core/src/v1/config/mcp.ts b/packages/core/src/v1/config/mcp.ts index 2e224780b..0a2aeff12 100644 --- a/packages/core/src/v1/config/mcp.ts +++ b/packages/core/src/v1/config/mcp.ts @@ -8,6 +8,9 @@ export const Local = Schema.Struct({ command: Schema.mutable(Schema.Array(Schema.String)).annotate({ description: "Command and arguments to run the MCP server", }), + cwd: Schema.optional(Schema.String).annotate({ + description: "Working directory for the MCP server process. Relative paths resolve from the workspace directory.", + }), environment: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({ description: "Environment variables to set when running the MCP server", }), diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index 3b4f13868..c474cac51 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -139,7 +139,14 @@ function mcp(info: typeof ConfigV1.Info.Type) { function migrateMcp(info: ConfigMCPV1.Info) { const disabled = info.enabled === undefined ? undefined : !info.enabled if (info.type === "local") - return { type: info.type, command: info.command, environment: info.environment, disabled, timeout: info.timeout } + return { + type: info.type, + command: info.command, + cwd: info.cwd, + environment: info.environment, + disabled, + timeout: info.timeout, + } return { type: info.type, url: info.url, diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 75da81234..9a4beab15 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -1,3 +1,4 @@ +import path from "node:path" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { type Tool } from "ai" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" @@ -304,7 +305,8 @@ export const layer = Layer.effect( mcp: ConfigMCPV1.Info & { type: "local" }, ) { const [cmd, ...args] = mcp.command - const cwd = yield* InstanceState.directory + const baseDir = yield* InstanceState.directory + const cwd = mcp.cwd ? path.resolve(baseDir, mcp.cwd) : baseDir const transport = new StdioClientTransport({ stderr: "pipe", command: cmd, diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 0aef477d1..763f6a319 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -1,8 +1,10 @@ +import path from "node:path" import { expect, mock, beforeEach } from "bun:test" import { ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js" import { Cause, Effect, Exit } from "effect" import type { MCP as MCPNS } from "../../src/mcp/index" import { testEffect } from "../lib/effect" +import { TestInstance } from "../fixture/fixture" // --- Mock infrastructure --- @@ -48,6 +50,8 @@ let connectError = "Mock transport cannot connect" let clientCreateCount = 0 // Tracks how many times transport.close() is called across all mock transports let transportCloseCount = 0 +// Captures the opts passed to each MockStdioTransport, keyed by lastCreatedClientName +const stdioOptsByName = new Map() function getOrCreateClientState(name?: string): MockClientState { const key = name ?? "default" @@ -82,8 +86,9 @@ function getOrCreateClientState(name?: string): MockClientState { class MockStdioTransport { stderr: null = null pid = 12345 - // oxlint-disable-next-line no-useless-constructor - constructor(_opts: any) {} + constructor(opts: any) { + if (lastCreatedClientName) stdioOptsByName.set(lastCreatedClientName, opts) + } async start() { if (connectShouldHang) return new Promise(() => {}) // never resolves if (connectShouldFail) throw new Error(connectError) @@ -246,6 +251,20 @@ function statusName(status: Record | MCPNS.Status, server: return status[server]?.status } +it.instance( + "local mcp cwd resolves relative paths against instance directory", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + const { directory } = yield* TestInstance + lastCreatedClientName = "rel-cwd" + yield* mcp.add("rel-cwd", { type: "local", command: ["echo", "test"], cwd: "plugins/sub" }) + expect(stdioOptsByName.get("rel-cwd")?.cwd).toBe(path.resolve(directory, "plugins/sub")) + }), + ), + { config: { mcp: {} } }, +) + // ======================================================================== // Test: tools() are cached after connect // ======================================================================== diff --git a/packages/web/src/content/docs/mcp-servers.mdx b/packages/web/src/content/docs/mcp-servers.mdx index 1b3006b1c..215938ec3 100644 --- a/packages/web/src/content/docs/mcp-servers.mdx +++ b/packages/web/src/content/docs/mcp-servers.mdx @@ -116,13 +116,14 @@ use the mcp_everything tool to add the number 3 and 4 Here are all the options for configuring a local MCP server. -| Option | Type | Required | Description | -| ------------- | ------- | -------- | ----------------------------------------------------------------------------------- | -| `type` | String | Y | Type of MCP server connection, must be `"local"`. | -| `command` | Array | Y | Command and arguments to run the MCP server. | -| `environment` | Object | | Environment variables to set when running the server. | -| `enabled` | Boolean | | Enable or disable the MCP server on startup. | -| `timeout` | Number | | Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds). | +| Option | Type | Required | Description | +| ------------- | ------- | -------- | ---------------------------------------------------------------------------------------- | +| `type` | String | Y | Type of MCP server connection, must be `"local"`. | +| `command` | Array | Y | Command and arguments to run the MCP server. | +| `cwd` | String | | Working directory for the MCP server process. Relative paths resolve from the workspace. | +| `environment` | Object | | Environment variables to set when running the server. | +| `enabled` | Boolean | | Enable or disable the MCP server on startup. | +| `timeout` | Number | | Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds). | ---