diff --git a/docs/user-docs/configuration.md b/docs/user-docs/configuration.md index 00512fa22..182e2f5d9 100644 --- a/docs/user-docs/configuration.md +++ b/docs/user-docs/configuration.md @@ -148,6 +148,7 @@ Recommended verification order: - Use absolute paths for local executables and scripts when possible. - For `stdio` servers, prefer setting required environment variables directly in the MCP config instead of relying on an interactive shell profile. +- GSD and `gsd-mcp-server` both hydrate supported tool keys saved in `~/.gsd/agent/auth.json`, so MCP configs can safely reference them through `${ENV_VAR}` placeholders without committing raw credentials. - If a server is team-shared and safe to commit, `.mcp.json` is usually the better home. - If a server depends on machine-local paths, personal services, or local-only secrets, prefer `.gsd/mcp.json`. diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index f1544b895..e6f03ddcd 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -234,6 +234,8 @@ Resolve a pending blocker in a session by sending a response to the blocked UI r | `GSD_CLI_PATH` | Absolute path to the GSD CLI binary. If not set, the server resolves `gsd` via `which`. | | `GSD_WORKFLOW_EXECUTORS_MODULE` | Optional absolute path or `file:` URL for the shared GSD workflow executor module used by workflow mutation tools. | +The server also hydrates supported tool credentials from `~/.gsd/agent/auth.json` on startup. Keys saved through `/gsd config` become available to the MCP server process automatically, and any explicitly-set environment variable still wins. + ## Architecture ``` diff --git a/packages/mcp-server/src/cli.ts b/packages/mcp-server/src/cli.ts index 141c4083f..14f341b1f 100644 --- a/packages/mcp-server/src/cli.ts +++ b/packages/mcp-server/src/cli.ts @@ -7,10 +7,13 @@ import { SessionManager } from './session-manager.js'; import { createMcpServer } from './server.js'; +import { loadStoredToolEnvKeys } from './tool-credentials.js'; const MCP_PKG = '@modelcontextprotocol/sdk'; async function main(): Promise { + loadStoredToolEnvKeys(); + const sessionManager = new SessionManager(); // Create the configured MCP server with session, interactive, read-only, diff --git a/packages/mcp-server/src/tool-credentials.test.ts b/packages/mcp-server/src/tool-credentials.test.ts new file mode 100644 index 000000000..bc06a8a13 --- /dev/null +++ b/packages/mcp-server/src/tool-credentials.test.ts @@ -0,0 +1,66 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { loadStoredToolEnvKeys, resolveAuthPath } from "./tool-credentials.js"; + +describe("tool credentials", () => { + it("hydrates supported keys from auth.json", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "gsd-mcp-auth-")); + const authPath = join(tempRoot, "auth.json"); + const env: NodeJS.ProcessEnv = {}; + + try { + writeFileSync(authPath, JSON.stringify({ + tavily: { type: "api_key", key: "tvly-secret" }, + context7: [{ type: "api_key", key: "ctx7-secret" }], + anthropic: { type: "api_key", key: "sk-ant-ignore" }, + })); + + const loaded = loadStoredToolEnvKeys({ authPath, env }); + assert.deepEqual(loaded.sort(), ["CONTEXT7_API_KEY", "TAVILY_API_KEY"]); + assert.equal(env.TAVILY_API_KEY, "tvly-secret"); + assert.equal(env.CONTEXT7_API_KEY, "ctx7-secret"); + assert.equal(env.ANTHROPIC_API_KEY, undefined); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("does not overwrite explicit environment variables", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "gsd-mcp-auth-")); + const authPath = join(tempRoot, "auth.json"); + const env: NodeJS.ProcessEnv = { + BRAVE_API_KEY: "already-set", + }; + + try { + writeFileSync(authPath, JSON.stringify({ + brave: { type: "api_key", key: "from-auth-json" }, + })); + + const loaded = loadStoredToolEnvKeys({ authPath, env }); + assert.deepEqual(loaded, []); + assert.equal(env.BRAVE_API_KEY, "already-set"); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("resolves auth.json from GSD_CODING_AGENT_DIR", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "gsd-mcp-agent-dir-")); + const agentDir = join(tempRoot, "agent"); + mkdirSync(agentDir, { recursive: true }); + + try { + assert.equal( + resolveAuthPath({ GSD_CODING_AGENT_DIR: agentDir }), + join(agentDir, "auth.json"), + ); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/mcp-server/src/tool-credentials.ts b/packages/mcp-server/src/tool-credentials.ts new file mode 100644 index 000000000..818b87d6b --- /dev/null +++ b/packages/mcp-server/src/tool-credentials.ts @@ -0,0 +1,79 @@ +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +type AuthCredential = + | { type?: unknown; key?: unknown } + | Array<{ type?: unknown; key?: unknown }>; + +type AuthStorageData = Record; + +const TOOL_ENV_KEYS = [ + ["brave", "BRAVE_API_KEY"], + ["brave_answers", "BRAVE_ANSWERS_KEY"], + ["context7", "CONTEXT7_API_KEY"], + ["jina", "JINA_API_KEY"], + ["tavily", "TAVILY_API_KEY"], + ["slack_bot", "SLACK_BOT_TOKEN"], + ["discord_bot", "DISCORD_BOT_TOKEN"], + ["telegram_bot", "TELEGRAM_BOT_TOKEN"], + ["groq", "GROQ_API_KEY"], + ["ollama-cloud", "OLLAMA_API_KEY"], + ["custom-openai", "CUSTOM_OPENAI_API_KEY"], +] as const; + +function expandHome(pathValue: string): string { + if (pathValue === "~") return homedir(); + if (pathValue.startsWith("~/")) return join(homedir(), pathValue.slice(2)); + return pathValue; +} + +function getStoredApiKey(data: AuthStorageData, providerId: string): string | undefined { + const raw = data[providerId]; + const credentials = Array.isArray(raw) ? raw : raw ? [raw] : []; + + for (const credential of credentials) { + if (credential?.type !== "api_key") continue; + if (typeof credential.key !== "string") continue; + if (credential.key.trim().length === 0) continue; + return credential.key; + } + + return undefined; +} + +export function resolveAuthPath(env: NodeJS.ProcessEnv = process.env): string { + const agentDir = env.GSD_CODING_AGENT_DIR?.trim(); + if (agentDir) return join(expandHome(agentDir), "auth.json"); + return join(homedir(), ".gsd", "agent", "auth.json"); +} + +export function loadStoredToolEnvKeys(options: { + env?: NodeJS.ProcessEnv; + authPath?: string; +} = {}): string[] { + const env = options.env ?? process.env; + const authPath = options.authPath ?? resolveAuthPath(env); + if (!existsSync(authPath)) return []; + + let parsed: AuthStorageData; + try { + const raw = readFileSync(authPath, "utf-8"); + const data = JSON.parse(raw) as unknown; + if (!data || typeof data !== "object" || Array.isArray(data)) return []; + parsed = data as AuthStorageData; + } catch { + return []; + } + + const loaded: string[] = []; + for (const [providerId, envVar] of TOOL_ENV_KEYS) { + if (env[envVar]) continue; + const key = getStoredApiKey(parsed, providerId); + if (!key) continue; + env[envVar] = key; + loaded.push(envVar); + } + + return loaded; +}