Merge PR #3182: fix(mcp): normalize OpenCode MCP command arrays (@roboomp)

This commit is contained in:
can1357
2026-06-21 16:28:19 +02:00
3 changed files with 147 additions and 4 deletions
+4
View File
@@ -10,6 +10,10 @@
- Fixed session-title generation silently falling back to the online `smol` model (and billing whatever provider held the resolved API key — OpenRouter in the reporter's case) when the user had explicitly configured a **local** `providers.tinyModel`: `generateSessionTitle` raced local against online with a 10s timeout and fired the online request immediately whenever the local worker returned `null` (unknown key, model not downloaded, transformers.js failure). Now an explicit local-model choice is honored end-to-end — on local failure the session is left untitled with a `logger.warn` instead of billing the smol fallback ([#3187](https://github.com/can1357/oh-my-pi/issues/3187))
### 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
@@ -91,15 +91,55 @@ async function loadContextFiles(ctx: LoadContext): Promise<LoadResult<ContextFil
/** OpenCode MCP server config (from opencode.json "mcp" key) */
interface OpenCodeMCPConfig {
type?: "local" | "remote";
command?: string;
command?: string | string[];
args?: string[];
env?: Record<string, string>;
environment?: Record<string, string>;
url?: string;
headers?: Record<string, string>;
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<string, string> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const record: Record<string, string> = {};
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<LoadResult<MCPServer>> {
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,
@@ -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<MCPServer[]> {
const result = await loadCapability<MCPServer>(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();
});
});