From c531117869b67a8f93abb10a349c497d4329dd7f Mon Sep 17 00:00:00 2001 From: roboomp Date: Sun, 21 Jun 2026 09:23:54 +0000 Subject: [PATCH] fix(mcp): normalized opencode command arrays Mapped OpenCode MCP array commands to stdio command plus args and accepted environment as the provider-native env key.\n\nAdded regression coverage for array command normalization, environment mapping, env fallback, and empty args omission.\n\nFixes #3180 --- packages/coding-agent/CHANGELOG.md | 4 + .../coding-agent/src/discovery/opencode.ts | 51 +++++++++- .../test/discovery/opencode.test.ts | 96 +++++++++++++++++++ 3 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 packages/coding-agent/test/discovery/opencode.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 001c32bcd..c6f69628d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed OpenCode MCP discovery so array commands are normalized into a stdio executable plus arguments, `environment` is accepted as the OpenCode env key, and argument lists are omitted when empty. ([#3180](https://github.com/can1357/oh-my-pi/issues/3180)) + ## [16.1.10] - 2026-06-21 ### Added diff --git a/packages/coding-agent/src/discovery/opencode.ts b/packages/coding-agent/src/discovery/opencode.ts index cc2054dbf..90dbd2434 100644 --- a/packages/coding-agent/src/discovery/opencode.ts +++ b/packages/coding-agent/src/discovery/opencode.ts @@ -91,15 +91,55 @@ async function loadContextFiles(ctx: LoadContext): Promise; + environment?: Record; url?: string; headers?: Record; enabled?: boolean; timeout?: number; } +function stringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined; + for (const item of value) { + if (typeof item !== "string") return undefined; + } + return value; +} + +function stringRecord(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + + const record: Record = {}; + for (const [key, item] of Object.entries(value)) { + if (typeof item !== "string") return undefined; + record[key] = item; + } + return record; +} + +function normalizeCommand( + commandValue: string | string[] | undefined, + argsValue: unknown, +): { command: string | undefined; args: string[] | undefined } { + const configuredArgs = stringArray(argsValue); + if (Array.isArray(commandValue)) { + const [command, ...commandArgs] = commandValue; + const args = configuredArgs ? [...commandArgs, ...configuredArgs] : commandArgs; + return { + command: typeof command === "string" ? command : undefined, + args: args.length > 0 ? args : undefined, + }; + } + + return { + command: typeof commandValue === "string" ? commandValue : undefined, + args: configuredArgs && configuredArgs.length > 0 ? configuredArgs : undefined, + }; +} + async function loadMCPServers(ctx: LoadContext): Promise> { const items: MCPServer[] = []; const warnings: string[] = []; @@ -161,11 +201,14 @@ function extractMCPServers( transport = "stdio"; } + const command = normalizeCommand(serverConfig.command, serverConfig.args); + const env = stringRecord(serverConfig.environment) ?? stringRecord(serverConfig.env); + items.push({ name, - command: serverConfig.command, - args: Array.isArray(serverConfig.args) ? (serverConfig.args as string[]) : undefined, - env: serverConfig.env && typeof serverConfig.env === "object" ? serverConfig.env : undefined, + command: command.command, + args: command.args, + env, url: typeof serverConfig.url === "string" ? serverConfig.url : undefined, headers: serverConfig.headers && typeof serverConfig.headers === "object" ? serverConfig.headers : undefined, enabled: serverConfig.enabled, diff --git a/packages/coding-agent/test/discovery/opencode.test.ts b/packages/coding-agent/test/discovery/opencode.test.ts new file mode 100644 index 000000000..fa85f7654 --- /dev/null +++ b/packages/coding-agent/test/discovery/opencode.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { type MCPServer, mcpCapability } from "@oh-my-pi/pi-coding-agent/capability/mcp"; +import { loadCapability } from "@oh-my-pi/pi-coding-agent/discovery"; + +async function loadOpenCodeMcpConfig(cwd: string): Promise { + const result = await loadCapability(mcpCapability.id, { + cwd, + providers: ["opencode"], + }); + return result.items; +} + +describe("OpenCode MCP discovery", () => { + let tempDir = ""; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-opencode-mcp-")); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + test("normalizes array commands and OpenCode environment fields", async () => { + await fs.writeFile( + path.join(tempDir, "opencode.json"), + JSON.stringify({ + mcp: { + sequentialthinking: { + type: "local", + command: ["npx", "-y", "@modelcontextprotocol/server-sequential-thinking"], + enabled: true, + }, + github: { + type: "local", + command: ["npx", "-y", "@modelcontextprotocol/server-github"], + environment: { + GITHUB_PERSONAL_ACCESS_TOKEN: "token", + }, + enabled: true, + }, + firecrawl: { + type: "local", + command: ["firecrawl-mcp"], + env: { + FIRECRAWL_API_KEY: "legacy-token", + }, + }, + }, + }), + ); + + const servers = await loadOpenCodeMcpConfig(tempDir); + const byName = Object.fromEntries(servers.map(server => [server.name, server])); + + expect(byName.sequentialthinking).toMatchObject({ + command: "npx", + args: ["-y", "@modelcontextprotocol/server-sequential-thinking"], + transport: "stdio", + }); + expect(byName.github).toMatchObject({ + command: "npx", + args: ["-y", "@modelcontextprotocol/server-github"], + env: { GITHUB_PERSONAL_ACCESS_TOKEN: "token" }, + transport: "stdio", + }); + expect(byName.firecrawl).toMatchObject({ + command: "firecrawl-mcp", + env: { FIRECRAWL_API_KEY: "legacy-token" }, + transport: "stdio", + }); + expect(byName.firecrawl?.args).toBeUndefined(); + }); + + test("omits empty args for scalar OpenCode commands", async () => { + await fs.writeFile( + path.join(tempDir, "opencode.json"), + JSON.stringify({ + mcp: { + plain: { + type: "local", + command: "server-bin", + }, + }, + }), + ); + + const [server] = await loadOpenCodeMcpConfig(tempDir); + + expect(server?.command).toBe("server-bin"); + expect(server?.args).toBeUndefined(); + }); +});