diff --git a/src/features/mcp/opencode-mcp.test.ts b/src/features/mcp/opencode-mcp.test.ts index e47d56fcb..f2b357644 100644 --- a/src/features/mcp/opencode-mcp.test.ts +++ b/src/features/mcp/opencode-mcp.test.ts @@ -123,6 +123,58 @@ describe("OpencodeMcp", () => { }); }); + it.each( + [OpencodeMcp, Opencodev1Mcp, MimocodeMcp].flatMap((Mcp) => + [false, true].flatMap((global) => + (Mcp === MimocodeMcp ? [false] : [false, true]).flatMap((envelope) => + ["a.b", "a_b"].map((serverName) => ({ + name: Mcp.name, + Mcp, + global, + envelope, + serverName, + })), + ), + ), + ), + )( + "$name should ignore bare server prefixes (global=$global, envelope=$envelope, server=$serverName)", + ({ Mcp, global, envelope, serverName }) => { + const servers = { [serverName]: { type: "local", command: ["node"] } }; + const mcp = new Mcp({ + outputRoot: testDir, + relativeDirPath: ".", + relativeFilePath: "opencode.json", + global, + fileContent: JSON.stringify({ + mcp: envelope ? { servers } : servers, + tools: { + a_b_: false, + a_b_read: true, + a_b_delete: false, + orphan_read: false, + }, + }), + }); + const imported = mcp.toRulesyncMcp().getJson(); + + expect(imported).toEqual({ + $schema: RULESYNC_MCP_SCHEMA_URL, + mcpServers: { + [serverName]: { + type: "stdio", + command: "node", + enabledTools: ["read"], + disabledTools: ["delete"], + }, + }, + [Mcp === MimocodeMcp ? "mimocode" : Mcp === Opencodev1Mcp ? "opencodev1" : "opencode"]: { + mcpServers: { orphan: { disabledTools: ["read"] } }, + }, + }); + }, + ); + it("should attribute colliding sanitized server names to the first listed server", () => { const mcp = new OpencodeMcp({ relativeDirPath: ".", diff --git a/src/features/mcp/opencode-mcp.ts b/src/features/mcp/opencode-mcp.ts index 5e093ce58..396c58853 100644 --- a/src/features/mcp/opencode-mcp.ts +++ b/src/features/mcp/opencode-mcp.ts @@ -220,11 +220,17 @@ function convertFromOpencodeFormat( ): McpServers { const serverNames = Object.keys(opencodeMcp); // Pre-filter with findOpencodeToolOwner so sanitized and legacy raw prefixes - // both count as owned. orphanMcpToolFiltersToRulesync then applies its own - // raw `_` prefix check; that second pass is redundant for this - // caller but stays because Kilo still needs it. + // both count as owned. The owner lookup needs a nonempty tool suffix, so a + // bare sanitized prefix is dropped here as well; otherwise the orphan + // importer splits a_b_ into server a and tool b_ when the listed server is + // a.b. A bare raw prefix (a.b_) is left to the raw `_` check in + // orphanMcpToolFiltersToRulesync, which Kilo also relies on. const orphanTools = Object.fromEntries( - Object.entries(tools ?? {}).filter(([key]) => !findOpencodeToolOwner({ key, serverNames })), + Object.entries(tools ?? {}).filter( + ([key]) => + !findOpencodeToolOwner({ key, serverNames }) && + !serverNames.some((serverName) => key === opencodeMcpToolPermissionKey(serverName, "")), + ), ); return { ...orphanMcpToolFiltersToRulesync(opencodeMcp, orphanTools),