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
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,9 @@ OPENROUTER_API_KEY=... npm run cli -- --workspace /path/to/project --chat

# Interactive chat with a specific model
OPENROUTER_API_KEY=... npm run cli -- --workspace /path/to/project --chat --modelId openrouter/anthropic/claude-3.7-sonnet

# Interactive chat with MCP servers from a JSON config file
OPENROUTER_API_KEY=... npm run cli -- --workspace /path/to/project --chat --mcp-config ./mcp-servers.json
```

This starts an interactive streaming chat session in your terminal with access to the workspace and tools.
Expand All @@ -152,6 +155,27 @@ Keep `NODE_OPTIONS="--experimental-ffi"` scoped to the Node.js OpenTUI launch
command rather than exporting it globally. It is not needed for Bun, plain chat,
the HTTP service or ACP, and older Node.js versions may reject the flag.

The MCP config file can contain a server array or an object with `mcpServers`
as either an array or a map keyed by server name. Keyed entries inherit their
key as the server `id` and `name` unless those fields are explicitly set.
For example, create `mcp-servers.json`:

```json
{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/project"]
}
}
}
```

Use `--mcp-config <path>` with `--chat`; relative paths are resolved from the
current directory. Local server processes inherit the CLI environment, so set
required credentials in the shell before launching. Keep config files containing
secrets out of source control.

#### Classic terminal chat (readline)

To use the old terminal chat instead of OpenTUI, explicitly select `plain`:
Expand Down
14 changes: 9 additions & 5 deletions api/core/agent/tools/mcpTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { z } from "zod";
import Ajv from "ajv";
import type { JSONSchema7 } from "json-schema";
import os from "os";
import { delimiter as pathDelimiter } from "node:path";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import {
StdioClientTransport,
Expand Down Expand Up @@ -134,11 +135,12 @@ export const summarizeMcpToolError = (result: {
return summarizeStructuredError(result.structuredContent) || summarizeMcpContent(result.content);
};

const buildAugmentedPath = (): string => {
export const buildAugmentedPath = (
basePath = process.env.PATH || "",
): string => {
const home = process.env.HOME || os.homedir() || "";
const currentPath = process.env.PATH || "";
const pathParts = [
currentPath,
basePath,
Comment thread
osimuka marked this conversation as resolved.
"/opt/homebrew/bin",
"/usr/local/bin",
"/usr/bin",
Expand All @@ -147,7 +149,9 @@ const buildAugmentedPath = (): string => {
home ? `${home}/.cargo/bin` : "",
].filter(Boolean);

return Array.from(new Set(pathParts.join(":").split(":"))).join(":");
return Array.from(
new Set(pathParts.join(pathDelimiter).split(pathDelimiter)),
).join(pathDelimiter);
};

type CommandCandidate = {
Expand Down Expand Up @@ -208,7 +212,7 @@ const createTransportParams = (
...process.env,
...server.env,
} as Record<string, string>;
env.PATH = buildAugmentedPath();
env.PATH = buildAugmentedPath(env.PATH);

return {
command: server.command!.trim(),
Expand Down
250 changes: 242 additions & 8 deletions api/core/library/mcpSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,34 @@ export type McpServerConfig = {
};

const isSafeEnvKey = (key: string): boolean => /^[A-Za-z_][A-Za-z0-9_]*$/.test(key);
const isValidHttpHeaderName = (name: string): boolean =>
/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(name);
const isValidHttpHeaderValue = (value: unknown): value is string =>
typeof value === "string" &&
value.length <= MAX_HEADER_LENGTH &&
/^[\t\x20-\x7E\x80-\xFF]*$/.test(value);

const isValidHttpHeaders = (
headers: unknown,
): headers is Record<string, string> => {
if (!headers || typeof headers !== "object" || Array.isArray(headers)) {
return false;
}

const entries = Object.entries(headers);
return (
entries.length <= MAX_HEADERS &&
entries.every(
([name, value]) =>
name.length <= MAX_HEADER_LENGTH &&
isValidHttpHeaderName(name) &&
isValidHttpHeaderValue(value),
)
);
};

const hasValidHttpHeaders = (headers: unknown): boolean =>
headers === undefined || isValidHttpHeaders(headers);

const splitCommandString = (value: string): string[] => {
const parts: string[] = [];
Expand Down Expand Up @@ -79,16 +107,10 @@ const sanitizeEnv = (env: unknown): Record<string, string> => {
};

const sanitizeHeaders = (headers: unknown): Record<string, string> => {
if (!headers || typeof headers !== "object" || Array.isArray(headers)) return {};
if (!isValidHttpHeaders(headers)) return {};

return Object.fromEntries(
Object.entries(headers)
.map(([key, value]) => [
safeString(key, MAX_HEADER_LENGTH),
safeString(value, MAX_HEADER_LENGTH),
] as const)
.filter(([key, value]) => key.length > 0 && value.length > 0)
.slice(0, MAX_HEADERS),
Object.entries(headers).slice(0, MAX_HEADERS),
);
};

Expand Down Expand Up @@ -157,6 +179,7 @@ const normalizeMcpServerDraft = (
if (!input || typeof input !== "object" || Array.isArray(input)) return null;

const raw = input as Record<string, unknown>;
if (!hasValidHttpHeaders(raw.headers)) return null;
const id = safeString(raw.id, 120) || getDefaultId(index);
const hasUrlInput = typeof raw.url === "string" && raw.url.trim().length > 0;
const url = sanitizeUrl(raw.url);
Expand Down Expand Up @@ -213,6 +236,7 @@ export const sanitizeMcpServer = (input: unknown, index = 0): McpServerConfig |
if (!input || typeof input !== "object" || Array.isArray(input)) return null;

const raw = input as Record<string, unknown>;
if (!hasValidHttpHeaders(raw.headers)) return null;
const hasUrlInput = typeof raw.url === "string" && raw.url.trim().length > 0;
const url = sanitizeUrl(raw.url);
// A non-empty url that fails sanitization is malformed/unsupported, not
Expand Down Expand Up @@ -264,6 +288,216 @@ export const sanitizeMcpServers = (
.slice(0, maxServers);
};

export const parseMcpServerConfigFile = (contents: string): McpServerConfig[] => {
let parsed: unknown;
try {
parsed = JSON.parse(contents);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`MCP config is not valid JSON: ${message}`);
}

let servers: unknown[];
if (Array.isArray(parsed)) {
servers = parsed;
} else if (parsed && typeof parsed === "object") {
const configuredServers = (parsed as Record<string, unknown>).mcpServers;
if (Array.isArray(configuredServers)) {
servers = configuredServers;
} else if (configuredServers && typeof configuredServers === "object") {
servers = Object.entries(configuredServers).map(([key, value]) => {
if (!key.trim() || key !== key.trim() || key.length > MAX_NAME_LENGTH) {
throw new Error(`MCP config has an invalid server key: "${key}"`);
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
return value;
}
const server = value as Record<string, unknown>;
return {
...server,
id: server.id ?? key,
name: server.name ?? key,
};
});
} else {
throw new Error(
'MCP config must be an array or an object with a "mcpServers" array or object',
);
}
} else {
throw new Error(
'MCP config must be an array or an object with a "mcpServers" array or object',
);
}
if (servers.length > MAX_MCP_SERVERS) {
throw new Error(`MCP config supports at most ${MAX_MCP_SERVERS} servers`);
}

const validatedServers: Array<{
config: unknown;
args?: string[];
env?: Record<string, string>;
headers?: Record<string, string>;
}> = servers.map((server) => {
if (server && typeof server === "object" && !Array.isArray(server)) {
const config = server as Record<string, unknown>;
const validatedConfig = { ...config };
if ("enabled" in config && typeof config.enabled !== "boolean") {
throw new Error('MCP config server "enabled" value must be a boolean');
}
if ("url" in config) {
if (
typeof config.url !== "string" ||
!config.url.trim()
) {
throw new Error(
'MCP config server "url" must be a non-empty string',
);
}
if (config.url.length > MAX_URL_LENGTH) {
throw new Error(
`MCP config server "url" exceeds ${MAX_URL_LENGTH} characters`,
);
}
}
if ("command" in config) {
if (typeof config.command !== "string" || !config.command.trim()) {
throw new Error(
'MCP config server "command" must be a non-empty string',
);
}
if (config.command !== config.command.trim()) {
throw new Error(
'MCP config server "command" cannot have surrounding whitespace',
);
}
if (config.command.length > MAX_COMMAND_LENGTH) {
throw new Error(
`MCP config server "command" exceeds ${MAX_COMMAND_LENGTH} characters`,
);
}
}

let args: string[] | undefined;
if ("args" in config) {
if (!Array.isArray(config.args)) {
throw new Error(
'MCP config server "args" must be an array of strings',
);
}
if (config.args.length > MAX_ARGS) {
throw new Error(
`MCP config server "args" supports at most ${MAX_ARGS} values`,
);
}
args = config.args.map((argument) => {
if (typeof argument !== "string") {
throw new Error(
'MCP config server "args" must contain only strings',
);
}
if (argument.length > MAX_ARG_LENGTH) {
throw new Error(
`MCP config server arguments cannot exceed ${MAX_ARG_LENGTH} characters`,
);
}
return argument;
});
validatedConfig.args = args;
}

let env: Record<string, string> | undefined;
if ("env" in config) {
Comment thread
osimuka marked this conversation as resolved.
if (
!config.env ||
typeof config.env !== "object" ||
Array.isArray(config.env)
) {
throw new Error('MCP config server "env" must be an object of strings');
}
const entries = Object.entries(config.env);
if (entries.length > MAX_ENV_VARS) {
throw new Error(
`MCP config server "env" supports at most ${MAX_ENV_VARS} values`,
);
}
env = Object.fromEntries(
entries.map(([key, value]) => {
if (
key.length > MAX_ENV_KEY_LENGTH ||
!isSafeEnvKey(key) ||
typeof value !== "string" ||
value.length > MAX_ENV_VALUE_LENGTH
) {
throw new Error(
'MCP config server "env" contains an invalid key or value',
);
}
return [key, value];
}),
);
validatedConfig.env = env;
}

let headers: Record<string, string> | undefined;
if ("headers" in config) {
if (
!config.headers ||
typeof config.headers !== "object" ||
Array.isArray(config.headers)
) {
throw new Error(
'MCP config server "headers" must be an object of strings',
);
}
const entries = Object.entries(config.headers);
if (entries.length > MAX_HEADERS) {
throw new Error(
`MCP config server "headers" supports at most ${MAX_HEADERS} values`,
);
}
headers = Object.fromEntries(
entries.map(([key, value]) => {
if (
key.length > MAX_HEADER_LENGTH ||
!isValidHttpHeaderName(key) ||
!isValidHttpHeaderValue(value)
) {
throw new Error(
'MCP config server "headers" contains an invalid key or value',
);
}
return [key, value];
}),
);
validatedConfig.headers = headers;
}

return { config: validatedConfig, args, env, headers };
}
return { config: server };
});

const sanitizedServers = sanitizeMcpServers(
validatedServers.map(({ config }) => config),
);
if (sanitizedServers.length !== servers.length) {
throw new Error("MCP config contains an invalid server entry");
}
return sanitizedServers.map((server, index) => ({
...server,
...(validatedServers[index].args !== undefined
? { args: validatedServers[index].args }
: {}),
...(validatedServers[index].env !== undefined
? { env: validatedServers[index].env }
: {}),
...(validatedServers[index].headers !== undefined
? { headers: validatedServers[index].headers }
: {}),
}));
};

export const toStableMcpFingerprint = (servers: McpServerConfig[]): string => {
const normalized = servers
.map((server) => ({
Expand Down
Loading
Loading