Merge PR #3182: fix(mcp): normalize OpenCode MCP command arrays (@roboomp)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user