Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ Full MCP client, both transports:
- **stdio** — local servers, configured in `.klaatai/mcp.json`, built-in presets (filesystem, GitHub, Postgres, Puppeteer, Brave Search, Fetch, …)
- **Streamable HTTP** — remote servers via `"url"` config or `/mcp add <url>`; SSE and JSON responses, session management, and **OAuth 2.1** (discovery + dynamic client registration + PKCE browser flow) when the server requires auth — tokens cached in `~/.klaatai/mcp-oauth.json`

Manage live with `/mcp`.
Manage live with `/mcp`. On startup, KlaatCode can also **import MCP servers** from existing tool configs in the project (`.mcp.json`, `.claude.json`, `.cursor/mcp.json`) — native `.klaatai/mcp.json` always wins on name collisions. Disable with `"compat": { "importMcpConfigs": false }` in `config.json`.

### Git Integration

Expand Down Expand Up @@ -457,6 +457,8 @@ klaatcode serve --port 4200
| `attentionOrder` | `on` / `off` | Arrange old history so the most relevant turns sit where models attend |
| `maxSessionCost` | USD number | Hard session cost cap — pauses agent rounds when reached |
| `phaseBudgets` | `on` / `off` | Per-phase token budgets; pause a stuck explore phase before it burns the budget |
| `compat.importClaudeSkills` | `true` / `false` | Discover skills from Claude Code directories (default: on) |
| `compat.importMcpConfigs` | `true` / `false` | Import MCP servers from `.mcp.json`, `.claude.json`, `.cursor/mcp.json` (default: on) |

Full reference, incl. every config key: [klaatai.com/docs/configuration](https://klaatai.com/docs/configuration).

Expand Down
2 changes: 2 additions & 0 deletions src/auth/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ export interface Config {
compat?: {
/** Discover skills from ~/.claude/skills and .claude/skills (default: true). */
importClaudeSkills?: boolean;
/** Import MCP servers from .mcp.json / .claude.json / .cursor/mcp.json (default: true). */
importMcpConfigs?: boolean;
};
}

Expand Down
64 changes: 37 additions & 27 deletions src/mcp/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,15 @@
*/

import { spawn, type ChildProcess } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import type { ToolDefinition } from "../api/client.js";
import { storedMcpToken, refreshMcpToken, authorizeMcpServer } from "./oauth.js";
import {
loadImportedMcpServers,
mergeNativeMcpConfig,
type MCPLoadOptions,
} from "./import.js";

// ─── Config ───────────────────────────────────────────────────────────────────

Expand All @@ -57,46 +61,48 @@ export interface MCPServerConfig {
description?: string;
}

/** MCP server config plus optional source label (for imported servers). */
export interface MCPServerEntry extends MCPServerConfig {
/** Config file the server was loaded from, e.g. ".cursor/mcp.json". */
source?: string;
}

export interface MCPConfig {
servers: Record<string, MCPServerConfig>;
servers: Record<string, MCPServerEntry>;
}

export type { MCPLoadOptions } from "./import.js";

/**
* Load MCP config by merging project-level and user-level configs.
* Project-level takes precedence for server names that appear in both.
* Load MCP config by merging external imports and KlaatCode native configs.
* Precedence (highest wins): .klaatai/mcp.json > ~/.klaatai/mcp.json >
* .mcp.json > .claude.json > .cursor/mcp.json.
*/
export function loadMCPConfig(projectRoot: string): MCPConfig {
const paths = [
join(homedir(), ".klaatai", "mcp.json"), // user-level (loaded first, lower priority)
join(projectRoot, ".klaatai", "mcp.json"), // project-level (higher priority)
];

const merged: MCPConfig = { servers: {} };
for (const p of paths) {
if (existsSync(p)) {
try {
const raw = readFileSync(p, "utf-8");
const cfg = JSON.parse(raw) as Partial<MCPConfig>;
if (cfg.servers && typeof cfg.servers === "object") {
Object.assign(merged.servers, cfg.servers);
}
} catch { /* ignore malformed JSON */ }
}
}
export function loadMCPConfig(projectRoot: string, opts?: MCPLoadOptions): MCPConfig {
const home = opts?.homeDir ?? homedir();
const loadOpts = {
projectRoot,
homeDir: home,
importMcpConfigs: opts?.importMcpConfigs,
onLog: opts?.onLog,
};

const merged: Record<string, MCPServerEntry> = loadImportedMcpServers(loadOpts);
mergeNativeMcpConfig(merged, projectRoot, home, loadOpts.onLog);

// Always inject process.cwd() as the allowed directory for the filesystem
// MCP server, regardless of what path was saved in mcp.json. This ensures
// Klaat Code always scopes the filesystem server to the current project
// directory rather than wherever the config was first written (e.g. HOME).
for (const cfg of Object.values(merged.servers)) {
for (const cfg of Object.values(merged)) {
const args = cfg.args ?? [];
const fsIdx = args.findIndex(a => a.includes("server-filesystem"));
const fsIdx = args.findIndex((a: string) => a.includes("server-filesystem"));
if (fsIdx !== -1) {
cfg.args = [...args.slice(0, fsIdx + 1), process.cwd()];
}
}

return merged;
return { servers: merged };
}

// ─── JSON-RPC types ───────────────────────────────────────────────────────────
Expand Down Expand Up @@ -156,6 +162,8 @@ interface PendingCall {

export class MCPServerClient {
readonly name: string;
/** Config file this server was loaded from (undefined for runtime /mcp add). */
readonly source?: string;
private _config: MCPServerConfig;
private _proc: ChildProcess | null = null;
private _buffer: string = "";
Expand All @@ -168,8 +176,9 @@ export class MCPServerClient {
status: MCPStatus = "idle";
statusMessage: string = "";

constructor(name: string, config: MCPServerConfig, onStatusChange?: () => void) {
constructor(name: string, config: MCPServerConfig, onStatusChange?: () => void, source?: string) {
this.name = name;
this.source = source;
this._config = config;
this._onStatusChange = onStatusChange;
}
Expand Down Expand Up @@ -517,7 +526,8 @@ export class MCPManager {
*/
connect(config: MCPConfig): void {
for (const [name, serverCfg] of Object.entries(config.servers)) {
const client = new MCPServerClient(name, serverCfg, this._onStatusChange);
const { source, ...cfg } = serverCfg;
const client = new MCPServerClient(name, cfg, this._onStatusChange, source);
this._servers.set(name, client);
// Fire-and-forget: errors set client.status = "error"
void client.connect();
Expand Down
188 changes: 188 additions & 0 deletions src/mcp/import.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { loadMCPConfig } from "./client.js";
import {
expandMcpEnvRefs,
mapExternalMcpServer,
parseExternalMcpFile,
} from "./import.js";

describe("expandMcpEnvRefs", () => {
const ctx = {
projectRoot: "/workspace/proj",
homeDir: "/home/user",
env: { API_KEY: "secret", EMPTY: "" },
};

test("expands ${VAR} and ${VAR:-default}", () => {
expect(expandMcpEnvRefs("key=${API_KEY}", ctx)).toBe("key=secret");
expect(expandMcpEnvRefs("key=${MISSING:-fallback}", ctx)).toBe("key=fallback");
expect(expandMcpEnvRefs("key=${EMPTY:-fallback}", ctx)).toBe("key=fallback");
});

test("expands Cursor-style refs", () => {
expect(expandMcpEnvRefs("${workspaceFolder}/server.py", ctx)).toBe("/workspace/proj/server.py");
expect(expandMcpEnvRefs("${userHome}/.config", ctx)).toBe("/home/user/.config");
expect(expandMcpEnvRefs("${env:API_KEY}", ctx)).toBe("secret");
});
});

describe("parseExternalMcpFile", () => {
const ctx = {
projectRoot: "/workspace/proj",
homeDir: "/home/user",
env: { TOKEN: "tok" },
};

test("maps stdio Claude entry", () => {
const servers = parseExternalMcpFile({
mcpServers: {
playwright: {
type: "stdio",
command: "npx",
args: ["-y", "@playwright/mcp@latest"],
},
},
}, ctx);
expect(servers["playwright"]).toEqual({
command: "npx",
args: ["-y", "@playwright/mcp@latest"],
});
});

test("maps remote http entry with env expansion", () => {
const servers = parseExternalMcpFile({
mcpServers: {
linear: {
type: "http",
url: "https://mcp.example.com",
headers: { Authorization: "Bearer ${TOKEN}" },
},
},
}, ctx);
expect(servers["linear"]).toEqual({
url: "https://mcp.example.com",
headers: { Authorization: "Bearer tok" },
});
});

test("maps Cursor remote entry without type", () => {
const servers = parseExternalMcpFile({
mcpServers: {
remote: {
url: "https://api.example.com/mcp",
headers: { Authorization: "Bearer ${env:TOKEN}" },
},
},
}, ctx);
expect(servers["remote"]?.url).toBe("https://api.example.com/mcp");
});
});

describe("loadMCPConfig imports", () => {
let projectRoot = "";
let homeDir = "";

beforeEach(() => {
projectRoot = mkdtempSync(join(tmpdir(), "klaat-mcp-project-"));
homeDir = mkdtempSync(join(tmpdir(), "klaat-mcp-home-"));
});

afterEach(() => {
rmSync(projectRoot, { recursive: true, force: true });
rmSync(homeDir, { recursive: true, force: true });
});

test("imports from .cursor/mcp.json", () => {
mkdirSync(join(projectRoot, ".cursor"), { recursive: true });
writeFileSync(join(projectRoot, ".cursor", "mcp.json"), JSON.stringify({
mcpServers: {
linear: { url: "https://mcp.linear.app/mcp" },
},
}));

const logs: string[] = [];
const cfg = loadMCPConfig(projectRoot, { homeDir, onLog: m => logs.push(m) });
expect(cfg.servers["linear"]?.url).toBe("https://mcp.linear.app/mcp");
expect(cfg.servers["linear"]?.source).toBe(".cursor/mcp.json");
expect(logs).toContain('mcp: imported "linear" from .cursor/mcp.json');
});

test("merges imports with precedence: .mcp.json over .cursor/mcp.json", () => {
mkdirSync(join(projectRoot, ".cursor"), { recursive: true });
writeFileSync(join(projectRoot, ".cursor", "mcp.json"), JSON.stringify({
mcpServers: {
shared: { command: "cursor-cmd" },
cursorOnly: { command: "cursor-only" },
},
}));
writeFileSync(join(projectRoot, ".mcp.json"), JSON.stringify({
mcpServers: {
shared: { command: "mcp-cmd" },
mcpOnly: { command: "mcp-only" },
},
}));

const logs: string[] = [];
const cfg = loadMCPConfig(projectRoot, { homeDir, onLog: m => logs.push(m) });
expect(cfg.servers["shared"]?.command).toBe("mcp-cmd");
expect(cfg.servers["cursorOnly"]?.command).toBe("cursor-only");
expect(cfg.servers["mcpOnly"]?.command).toBe("mcp-only");
expect(logs.some(l => l.includes('overridden by .mcp.json'))).toBe(true);
});

test("native KlaatCode config overrides imports", () => {
writeFileSync(join(projectRoot, ".mcp.json"), JSON.stringify({
mcpServers: { imported: { command: "imported-cmd" } },
}));
mkdirSync(join(projectRoot, ".klaatai"), { recursive: true });
writeFileSync(join(projectRoot, ".klaatai", "mcp.json"), JSON.stringify({
servers: { imported: { command: "native-cmd" } },
}));

const logs: string[] = [];
const cfg = loadMCPConfig(projectRoot, { homeDir, onLog: m => logs.push(m) });
expect(cfg.servers["imported"]?.command).toBe("native-cmd");
expect(cfg.servers["imported"]?.source).toBe(".klaatai/mcp.json");
expect(logs.some(l => l.includes('overridden by .klaatai/mcp.json'))).toBe(true);
});

test("importMcpConfigs: false skips external files", () => {
writeFileSync(join(projectRoot, ".mcp.json"), JSON.stringify({
mcpServers: { imported: { command: "imported-cmd" } },
}));

const cfg = loadMCPConfig(projectRoot, { homeDir, importMcpConfigs: false });
expect(cfg.servers["imported"]).toBeUndefined();
});

test("parses .claude.json mcpServers", () => {
writeFileSync(join(projectRoot, ".claude.json"), JSON.stringify({
mcpServers: {
gh: {
type: "stdio",
command: "npx",
args: ["-y", "@modelcontextprotocol/server-github"],
},
},
}));

const cfg = loadMCPConfig(projectRoot, { homeDir });
expect(cfg.servers["gh"]?.command).toBe("npx");
expect(cfg.servers["gh"]?.source).toBe(".claude.json");
});
});

describe("mapExternalMcpServer", () => {
const ctx = {
projectRoot: "/proj",
homeDir: "/home",
env: {},
};

test("returns null when stdio entry has no command", () => {
expect(mapExternalMcpServer({ type: "stdio" }, ctx)).toBeNull();
});
});
Loading
Loading