diff --git a/README.md b/README.md index f4d188a71..9bfa55d90 100644 --- a/README.md +++ b/README.md @@ -238,9 +238,10 @@ Full Model Context Protocol support with external tool integration: - Stdio and HTTP transports for connecting to MCP servers - **OAuth support**: Explicit `clientId` and `callbackPort` in MCP server config, manual OAuth callbacks via slash commands - **Browser server filtering**: Automatically filters browser-type MCP servers to prevent conflicts with built-in browser tool +- **Automatic Exa filtering**: Extracts Exa API keys and prefers the native Exa integration +- **Config schema + setup guide**: [`docs/mcp-config.md`](./docs/mcp-config.md) and [`packages/coding-agent/src/config/mcp-schema.json`](./packages/coding-agent/src/config/mcp-schema.json) - Plugin CLI (`omp plugin install/enable/configure/doctor`) - Hot-loadable plugins from `~/.omp/plugins/` with npm/bun integration -- Automatic Exa MCP server filtering with API key extraction - `disabledServers` works on both project-level and user-level third-party servers ### + Web Search & Fetch diff --git a/docs/mcp-config.md b/docs/mcp-config.md new file mode 100644 index 000000000..c0fed4efa --- /dev/null +++ b/docs/mcp-config.md @@ -0,0 +1,449 @@ +# MCP configuration in OMP + +This guide explains how to add, edit, and validate MCP servers for the OMP coding agent. + +Source of truth in code: + +- Runtime config types: `packages/coding-agent/src/mcp/types.ts` +- Config writer: `packages/coding-agent/src/mcp/config-writer.ts` +- Loader + validation: `packages/coding-agent/src/mcp/config.ts` +- Standalone `mcp.json` discovery: `packages/coding-agent/src/discovery/mcp-json.ts` +- Schema: `packages/coding-agent/src/config/mcp-schema.json` + +## Preferred config locations + +OMP can discover MCP servers from multiple tools (`.claude/`, `.cursor/`, `.vscode/`, `opencode.json`, and more), but for OMP-native configuration you should usually use one of these files: + +- Project: `.omp/mcp.json` +- User: `~/.omp/mcp.json` + +OMP also accepts fallback standalone files in the project root: + +- `mcp.json` +- `.mcp.json` + +Use `.omp/mcp.json` when you want OMP to own the configuration. Use root `mcp.json` / `.mcp.json` only when you want a portable fallback file that other MCP clients may also read. + +## Add a schema reference + +Add this line at the top of the file for editor autocomplete and validation: + +```json +{ + "$schema": "https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/config/mcp-schema.json", + "mcpServers": {} +} +``` + +OMP now writes this automatically when `/mcp add`, `/mcp enable`, `/mcp disable`, `/mcp reauth`, or other config-writing flows create or update an OMP-managed MCP file. + +## File shape + +OMP supports this top-level structure: + +```json +{ + "$schema": "https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/config/mcp-schema.json", + "mcpServers": { + "server-name": { + "type": "stdio", + "command": "npx", + "args": ["-y", "some-mcp-server"] + } + }, + "disabledServers": ["server-name"] +} +``` + +Top-level keys: + +- `$schema` — optional JSON Schema URL for tooling +- `mcpServers` — map of server name to server config +- `disabledServers` — user-level denylist used to turn off discovered servers by name + +Server names must match `^[a-zA-Z0-9_.-]{1,100}$`. + +## Supported server fields + +Shared fields for every transport: + +- `enabled?: boolean` — skip this server when `false` +- `timeout?: number` — connection timeout in milliseconds +- `auth?: { ... }` — auth metadata used by OMP for OAuth/API-key flows +- `oauth?: { ... }` — explicit OAuth client settings used during auth/reauth + +### `stdio` transport + +`stdio` is the default when `type` is omitted. + +Required: + +- `command: string` + +Optional: + +- `type?: "stdio"` +- `args?: string[]` +- `env?: Record` +- `cwd?: string` + +Example: + +```json +{ + "$schema": "https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/config/mcp-schema.json", + "mcpServers": { + "filesystem": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + "/Users/alice/projects", + "/Users/alice/Documents" + ] + } + } +} +``` + +This follows the official Filesystem MCP server package (`@modelcontextprotocol/server-filesystem`). + +### `http` transport + +Required: + +- `type: "http"` +- `url: string` + +Optional: + +- `headers?: Record` + +Example: + +```json +{ + "$schema": "https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/config/mcp-schema.json", + "mcpServers": { + "github": { + "type": "http", + "url": "https://api.githubcopilot.com/mcp/" + } + } +} +``` + +This matches GitHub's hosted GitHub MCP server endpoint. + +### `sse` transport + +Required: + +- `type: "sse"` +- `url: string` + +Optional: + +- `headers?: Record` + +Example: + +```json +{ + "$schema": "https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/config/mcp-schema.json", + "mcpServers": { + "legacy-remote": { + "type": "sse", + "url": "https://example.com/mcp/sse" + } + } +} +``` + +`sse` is still supported for compatibility, but the MCP spec now prefers Streamable HTTP (`type: "http"`) for new servers. + +## Auth fields + +OMP understands two auth-related objects. + +### `auth` + +```json +{ + "type": "oauth" | "apikey", + "credentialId": "optional-stored-credential-id", + "tokenUrl": "optional-token-endpoint", + "clientId": "optional-client-id", + "clientSecret": "optional-client-secret" +} +``` + +Use this when OMP should remember how to rehydrate credentials for a server. + +### `oauth` + +```json +{ + "clientId": "...", + "clientSecret": "...", + "redirectUri": "...", + "callbackPort": 3334, + "callbackPath": "/oauth/callback" +} +``` + +Use this when the MCP server requires explicit OAuth client settings. + +Slack is the clearest current example. Slack's MCP server is hosted at `https://mcp.slack.com/mcp`, uses Streamable HTTP, and requires confidential OAuth with your Slack app's client credentials. + +Example: + +```json +{ + "$schema": "https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/config/mcp-schema.json", + "mcpServers": { + "slack": { + "type": "http", + "url": "https://mcp.slack.com/mcp", + "oauth": { + "clientId": "YOUR_SLACK_CLIENT_ID", + "clientSecret": "YOUR_SLACK_CLIENT_SECRET" + }, + "auth": { + "type": "oauth", + "tokenUrl": "https://slack.com/api/oauth.v2.user.access", + "clientId": "YOUR_SLACK_CLIENT_ID", + "clientSecret": "YOUR_SLACK_CLIENT_SECRET" + } + } + } +} +``` + +Relevant Slack endpoints from Slack's docs: + +- MCP endpoint: `https://mcp.slack.com/mcp` +- Authorization endpoint: `https://slack.com/oauth/v2_user/authorize` +- Token endpoint: `https://slack.com/api/oauth.v2.user.access` + +## Common copy-paste examples + +### Filesystem server via stdio + +```json +{ + "$schema": "https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/config/mcp-schema.json", + "mcpServers": { + "filesystem": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + "/absolute/path/one", + "/absolute/path/two" + ] + } + } +} +``` + +### GitHub hosted server via HTTP + +```json +{ + "$schema": "https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/config/mcp-schema.json", + "mcpServers": { + "github": { + "type": "http", + "url": "https://api.githubcopilot.com/mcp/" + } + } +} +``` + +### GitHub local server via Docker + +```json +{ + "$schema": "https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/config/mcp-schema.json", + "mcpServers": { + "github": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-e", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "ghcr.io/github/github-mcp-server" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "GITHUB_PERSONAL_ACCESS_TOKEN" + } + } + } +} +``` + +This matches GitHub's official local Docker image `ghcr.io/github/github-mcp-server`. + +### Slack hosted server via OAuth + +```json +{ + "$schema": "https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/config/mcp-schema.json", + "mcpServers": { + "slack": { + "type": "http", + "url": "https://mcp.slack.com/mcp", + "oauth": { + "clientId": "YOUR_SLACK_CLIENT_ID", + "clientSecret": "YOUR_SLACK_CLIENT_SECRET" + }, + "auth": { + "type": "oauth", + "tokenUrl": "https://slack.com/api/oauth.v2.user.access", + "clientId": "YOUR_SLACK_CLIENT_ID", + "clientSecret": "YOUR_SLACK_CLIENT_SECRET" + } + } + } +} +``` + +## Secrets and variable resolution + +This is the part that usually trips people up. + +### In `.omp/mcp.json` and `~/.omp/mcp.json` + +Before OMP launches a server or makes an HTTP request, it resolves `env` and `headers` values like this: + +1. If a value starts with `!`, OMP runs it as a shell command and uses trimmed stdout. +2. Otherwise OMP first checks whether the value matches an environment variable name. +3. If that environment variable is not set, OMP uses the string literally. + +Examples: + +```json +{ + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "GITHUB_PERSONAL_ACCESS_TOKEN" + }, + "headers": { + "X-MCP-Insiders": "true" + } +} +``` + +That means this is valid and convenient for local secrets: + +- `"GITHUB_PERSONAL_ACCESS_TOKEN": "GITHUB_PERSONAL_ACCESS_TOKEN"` → copy from the current shell environment +- `"Authorization": "Bearer hardcoded-token"` → use the literal value +- `"Authorization": "!printf 'Bearer %s' \"$GITHUB_TOKEN\""` → build the header from a command + +### In root `mcp.json` and `.mcp.json` + +The standalone fallback loader also expands `${VAR}` and `${VAR:-default}` inside strings during discovery. + +Example: + +```json +{ + "mcpServers": { + "github": { + "type": "http", + "url": "https://api.githubcopilot.com/mcp/", + "headers": { + "Authorization": "Bearer ${GITHUB_TOKEN}" + } + } + } +} +``` + +If you want the least surprising OMP behavior, prefer `.omp/mcp.json` and use explicit env/header values. + +## `disabledServers` + +`disabledServers` is mainly useful in the user config file (`~/.omp/mcp.json`) when a server is discovered from some other source and you want OMP to ignore it without editing that other tool's config. + +Example: + +```json +{ + "$schema": "https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/config/mcp-schema.json", + "disabledServers": ["github", "slack"] +} +``` + +## `/mcp add` vs editing JSON directly + +Use `/mcp add` when you want guided setup. + +Use direct JSON editing when: + +- you need a transport or auth option the wizard does not prompt for yet +- you want to paste a server definition from another MCP client +- you want schema-backed validation in your editor + +After editing, use: + +- `/mcp reload` to rediscover and reconnect servers in the current session +- `/mcp list` to see which config file a server came from +- `/mcp test ` to test a single server + +## Validation rules OMP enforces + +From `validateServerConfig()` in `packages/coding-agent/src/mcp/config.ts`: + +- `stdio` requires `command` +- `http` and `sse` require `url` +- a server cannot set both `command` and `url` +- unknown `type` values are rejected + +Practical implications: + +- Omitting `type` means `stdio` +- If you paste a remote server config and forget `"type": "http"`, OMP will treat it as `stdio` and complain that `command` is missing +- `sse` remains valid for compatibility, but new hosted servers should usually be configured as `http` + +## Discovery and precedence + +OMP does not merge duplicate server definitions across files. Discovery providers are prioritized, and the higher-priority definition wins. + +In practice: + +- prefer `.omp/mcp.json` or `~/.omp/mcp.json` when you want an OMP-specific override +- keep server names unique across tools when possible +- use `disabledServers` in the user config when a third-party config keeps reintroducing a server you do not want + +## Troubleshooting + +### `Server "name": stdio server requires "command" field` + +You probably omitted `type: "http"` on a remote server. + +### `Server "name": both "command" and "url" are set` + +Pick one transport. OMP treats `command` as stdio and `url` as http/sse. + +### `/mcp add` worked but the server still does not connect + +The JSON is valid, but the server may still be unreachable. Use `/mcp test ` and check whether: + +- the binary or Docker image exists +- required environment variables are set +- the remote URL is reachable +- the OAuth or API token is valid + +### The server exists in another tool's config but not in OMP + +Run `/mcp list`. OMP discovers many third-party MCP files, but project-level loading can also be disabled via the `mcp.enableProjectConfig` setting. + +## References + +- MCP transport spec: https://modelcontextprotocol.io/specification/2025-03-26/basic/transports +- Filesystem server package: https://www.npmjs.com/package/@modelcontextprotocol/server-filesystem +- GitHub MCP server: https://github.com/github/github-mcp-server +- Slack MCP server docs: https://docs.slack.dev/ai/slack-mcp-server/ diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 5f181e5da..9adfe438d 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -8,5 +8,7 @@ For installation, setup, provider configuration, model roles, slash commands, an Package-specific references: - [CHANGELOG](./CHANGELOG.md) -- [Documentation](./docs/) +- [MCP configuration guide](../../docs/mcp-config.md) +- [MCP runtime lifecycle](../../docs/mcp-runtime-lifecycle.md) +- [MCP server/tool authoring](../../docs/mcp-server-tool-authoring.md) - [DEVELOPMENT](./DEVELOPMENT.md) diff --git a/packages/coding-agent/src/capability/mcp.ts b/packages/coding-agent/src/capability/mcp.ts index f0d2b97c3..9f16c8a09 100644 --- a/packages/coding-agent/src/capability/mcp.ts +++ b/packages/coding-agent/src/capability/mcp.ts @@ -23,6 +23,8 @@ export interface MCPServer { args?: string[]; /** Environment variables */ env?: Record; + /** Working directory for stdio transport */ + cwd?: string; /** URL (for HTTP/SSE transport) */ url?: string; /** HTTP headers (for HTTP transport) */ diff --git a/packages/coding-agent/src/config/mcp-schema.json b/packages/coding-agent/src/config/mcp-schema.json new file mode 100644 index 000000000..4708a9c25 --- /dev/null +++ b/packages/coding-agent/src/config/mcp-schema.json @@ -0,0 +1,230 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/config/mcp-schema.json", + "title": "OMP MCP configuration", + "description": "Schema for mcp.json, .mcp.json, .omp/mcp.json, and ~/.omp/mcp.json used by the OMP coding agent.", + "type": "object", + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "description": "Optional schema reference for editor autocomplete and validation." + }, + "mcpServers": { + "type": "object", + "description": "Map of MCP server name to server configuration.", + "propertyNames": { + "pattern": "^[a-zA-Z0-9_.-]{1,100}$" + }, + "additionalProperties": { + "$ref": "#/$defs/serverConfig" + } + }, + "disabledServers": { + "type": "array", + "description": "User-level denylist for disabling discovered servers by name.", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + } + }, + "$defs": { + "stringMap": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "authConfig": { + "type": "object", + "additionalProperties": false, + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": ["oauth", "apikey"], + "description": "Auth strategy understood by OMP." + }, + "credentialId": { + "type": "string", + "description": "Stored OAuth credential id from agent auth storage." + }, + "tokenUrl": { + "type": "string", + "description": "Token endpoint persisted for refresh." + }, + "clientId": { + "type": "string", + "description": "OAuth client id persisted for refresh." + }, + "clientSecret": { + "type": "string", + "description": "OAuth client secret persisted for refresh." + } + } + }, + "oauthConfig": { + "type": "object", + "additionalProperties": false, + "properties": { + "clientId": { + "type": "string" + }, + "clientSecret": { + "type": "string" + }, + "redirectUri": { + "type": "string" + }, + "callbackPort": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "callbackPath": { + "type": "string" + } + }, + "description": "Explicit OAuth client settings for servers that need them during /mcp reauth or initial connect." + }, + "serverBase": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether OMP should try to connect this server." + }, + "timeout": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Connection timeout in milliseconds." + }, + "auth": { + "$ref": "#/$defs/authConfig" + }, + "oauth": { + "$ref": "#/$defs/oauthConfig" + } + } + }, + "stdioServer": { + "allOf": [ + { + "$ref": "#/$defs/serverBase" + }, + { + "type": "object", + "additionalProperties": false, + "required": ["command"], + "properties": { + "type": { + "type": "string", + "enum": ["stdio"], + "description": "Default transport when omitted." + }, + "command": { + "type": "string", + "minLength": 1, + "description": "Executable to spawn." + }, + "args": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Arguments passed to the stdio server process." + }, + "env": { + "$ref": "#/$defs/stringMap", + "description": "Environment variables passed to the stdio process." + }, + "cwd": { + "type": "string", + "description": "Working directory used when spawning the stdio process." + } + }, + "not": { + "required": ["url"] + } + } + ] + }, + "httpServer": { + "allOf": [ + { + "$ref": "#/$defs/serverBase" + }, + { + "type": "object", + "additionalProperties": false, + "required": ["type", "url"], + "properties": { + "type": { + "type": "string", + "enum": ["http"], + "description": "Streamable HTTP transport." + }, + "url": { + "type": "string", + "minLength": 1, + "description": "MCP endpoint URL." + }, + "headers": { + "$ref": "#/$defs/stringMap", + "description": "HTTP headers sent with MCP requests." + } + }, + "not": { + "required": ["command"] + } + } + ] + }, + "sseServer": { + "allOf": [ + { + "$ref": "#/$defs/serverBase" + }, + { + "type": "object", + "additionalProperties": false, + "required": ["type", "url"], + "properties": { + "type": { + "type": "string", + "enum": ["sse"], + "description": "Legacy SSE transport kept for compatibility. Prefer http for new configs." + }, + "url": { + "type": "string", + "minLength": 1, + "description": "Legacy SSE endpoint URL." + }, + "headers": { + "$ref": "#/$defs/stringMap", + "description": "HTTP headers sent with the SSE transport." + } + }, + "not": { + "required": ["command"] + } + } + ] + }, + "serverConfig": { + "oneOf": [ + { + "$ref": "#/$defs/stdioServer" + }, + { + "$ref": "#/$defs/httpServer" + }, + { + "$ref": "#/$defs/sseServer" + } + ] + } + } +} diff --git a/packages/coding-agent/src/discovery/builtin.ts b/packages/coding-agent/src/discovery/builtin.ts index 745142da9..131b1018b 100644 --- a/packages/coding-agent/src/discovery/builtin.ts +++ b/packages/coding-agent/src/discovery/builtin.ts @@ -158,6 +158,7 @@ async function loadMCPServers(ctx: LoadContext): Promise> command: serverConfig.command as string | undefined, args: serverConfig.args as string[] | undefined, env: serverConfig.env as Record | undefined, + cwd: serverConfig.cwd as string | undefined, url: serverConfig.url as string | undefined, headers: serverConfig.headers as Record | undefined, auth: serverConfig.auth as diff --git a/packages/coding-agent/src/discovery/mcp-json.ts b/packages/coding-agent/src/discovery/mcp-json.ts index 5b27f594a..906d2fa9c 100644 --- a/packages/coding-agent/src/discovery/mcp-json.ts +++ b/packages/coding-agent/src/discovery/mcp-json.ts @@ -29,6 +29,7 @@ interface MCPConfigFile { command?: string; args?: string[]; env?: Record; + cwd?: string; url?: string; headers?: Record; auth?: { @@ -88,6 +89,7 @@ function transformMCPConfig(config: MCPConfigFile, source: SourceMeta): MCPServe command: serverConfig.command, args: serverConfig.args, env: serverConfig.env, + cwd: serverConfig.cwd, url: serverConfig.url, headers: serverConfig.headers, auth: serverConfig.auth, @@ -100,6 +102,7 @@ function transformMCPConfig(config: MCPConfigFile, source: SourceMeta): MCPServe if (server.command) server.command = expandEnvVarsDeep(server.command); if (server.args) server.args = expandEnvVarsDeep(server.args); if (server.env) server.env = expandEnvVarsDeep(server.env); + if (server.cwd) server.cwd = expandEnvVarsDeep(server.cwd); if (server.url) server.url = expandEnvVarsDeep(server.url); if (server.headers) server.headers = expandEnvVarsDeep(server.headers); if (server.auth) server.auth = expandEnvVarsDeep(server.auth); diff --git a/packages/coding-agent/src/mcp/config-writer.ts b/packages/coding-agent/src/mcp/config-writer.ts index c2f3d4d48..4d528badf 100644 --- a/packages/coding-agent/src/mcp/config-writer.ts +++ b/packages/coding-agent/src/mcp/config-writer.ts @@ -9,7 +9,14 @@ import { isEnoent } from "@oh-my-pi/pi-utils"; import { invalidate as invalidateFsCache } from "../capability/fs"; import { validateServerConfig } from "./config"; -import type { MCPConfigFile, MCPServerConfig } from "./types"; +import { MCP_CONFIG_SCHEMA_URL, type MCPConfigFile, type MCPServerConfig } from "./types"; + +function withSchema(config: MCPConfigFile): MCPConfigFile { + return { + $schema: config.$schema ?? MCP_CONFIG_SCHEMA_URL, + ...config, + }; +} /** * Read an MCP config file. @@ -40,7 +47,7 @@ export async function writeMCPConfigFile(filePath: string, config: MCPConfigFile // Write to temp file first (atomic write) const tmpPath = `${filePath}.tmp`; - const content = JSON.stringify(config, null, 2); + const content = JSON.stringify(withSchema(config), null, 2); await fs.promises.writeFile(tmpPath, content, { encoding: "utf-8", mode: 0o600 }); // Rename to final path (atomic on most systems) diff --git a/packages/coding-agent/src/mcp/config.ts b/packages/coding-agent/src/mcp/config.ts index 3de0bf1e3..c3fff09b6 100644 --- a/packages/coding-agent/src/mcp/config.ts +++ b/packages/coding-agent/src/mcp/config.ts @@ -53,6 +53,7 @@ function convertToLegacyConfig(server: MCPServer): MCPServerConfig { }; if (server.args) config.args = server.args; if (server.env) config.env = server.env; + if (server.cwd) config.cwd = server.cwd; return config; } diff --git a/packages/coding-agent/src/mcp/types.ts b/packages/coding-agent/src/mcp/types.ts index 01a126153..909bf8216 100644 --- a/packages/coding-agent/src/mcp/types.ts +++ b/packages/coding-agent/src/mcp/types.ts @@ -100,8 +100,12 @@ export interface MCPSseServerConfig extends MCPServerConfigBase { export type MCPServerConfig = MCPStdioServerConfig | MCPHttpServerConfig | MCPSseServerConfig; -/** Root .mcp.json file structure */ +export const MCP_CONFIG_SCHEMA_URL = + "https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/config/mcp-schema.json"; + +/** Root mcp.json/.mcp.json file structure */ export interface MCPConfigFile { + $schema?: string; mcpServers?: Record; disabledServers?: string[]; }