From f350647863c474803ef6efbd88c7d9a1fd8a22db Mon Sep 17 00:00:00 2001 From: Jeremy Date: Fri, 10 Apr 2026 17:31:07 -0500 Subject: [PATCH] fix(mcp-server): hydrate model credentials into env --- docs/user-docs/configuration.md | 2 +- packages/mcp-server/README.md | 2 +- packages/mcp-server/src/cli.ts | 4 +- .../mcp-server/src/tool-credentials.test.ts | 45 +++++++++++++++---- packages/mcp-server/src/tool-credentials.ts | 30 ++++++++++--- 5 files changed, 65 insertions(+), 18 deletions(-) diff --git a/docs/user-docs/configuration.md b/docs/user-docs/configuration.md index 182e2f5d9..b3e873e72 100644 --- a/docs/user-docs/configuration.md +++ b/docs/user-docs/configuration.md @@ -148,7 +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. +- GSD and `gsd-mcp-server` both hydrate supported model and 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 e6f03ddcd..642657dd7 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -234,7 +234,7 @@ 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. +The server also hydrates supported model-provider and tool credentials from `~/.gsd/agent/auth.json` on startup. Keys saved through `/gsd config` or `/gsd keys` 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 14f341b1f..e9b64d794 100644 --- a/packages/mcp-server/src/cli.ts +++ b/packages/mcp-server/src/cli.ts @@ -7,12 +7,12 @@ import { SessionManager } from './session-manager.js'; import { createMcpServer } from './server.js'; -import { loadStoredToolEnvKeys } from './tool-credentials.js'; +import { loadStoredCredentialEnvKeys } from './tool-credentials.js'; const MCP_PKG = '@modelcontextprotocol/sdk'; async function main(): Promise { - loadStoredToolEnvKeys(); + loadStoredCredentialEnvKeys(); const sessionManager = new SessionManager(); diff --git a/packages/mcp-server/src/tool-credentials.test.ts b/packages/mcp-server/src/tool-credentials.test.ts index bc06a8a13..b6838a29f 100644 --- a/packages/mcp-server/src/tool-credentials.test.ts +++ b/packages/mcp-server/src/tool-credentials.test.ts @@ -4,26 +4,33 @@ 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"; +import { loadStoredCredentialEnvKeys, resolveAuthPath } from "./tool-credentials.js"; describe("tool credentials", () => { - it("hydrates supported keys from auth.json", () => { + it("hydrates supported model and tool 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({ + anthropic: { type: "api_key", key: "sk-ant-secret" }, + openai: { type: "api_key", key: "sk-openai-secret" }, 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"]); + const loaded = loadStoredCredentialEnvKeys({ authPath, env }); + assert.deepEqual(loaded.sort(), [ + "ANTHROPIC_API_KEY", + "CONTEXT7_API_KEY", + "OPENAI_API_KEY", + "TAVILY_API_KEY", + ]); + assert.equal(env.ANTHROPIC_API_KEY, "sk-ant-secret"); + assert.equal(env.OPENAI_API_KEY, "sk-openai-secret"); 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 }); } @@ -39,11 +46,33 @@ describe("tool credentials", () => { try { writeFileSync(authPath, JSON.stringify({ brave: { type: "api_key", key: "from-auth-json" }, + anthropic: { type: "api_key", key: "sk-ant-from-auth-json" }, })); - const loaded = loadStoredToolEnvKeys({ authPath, env }); - assert.deepEqual(loaded, []); + const loaded = loadStoredCredentialEnvKeys({ authPath, env }); + assert.deepEqual(loaded, ["ANTHROPIC_API_KEY"]); assert.equal(env.BRAVE_API_KEY, "already-set"); + assert.equal(env.ANTHROPIC_API_KEY, "sk-ant-from-auth-json"); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("ignores oauth credentials because they are resolved through auth storage, not env hydration", () => { + const tempRoot = mkdtempSync(join(tmpdir(), "gsd-mcp-auth-")); + const authPath = join(tempRoot, "auth.json"); + const env: NodeJS.ProcessEnv = {}; + + try { + writeFileSync(authPath, JSON.stringify({ + openai: { type: "oauth", access: "oauth-access-token" }, + "google-gemini-cli": { type: "oauth", token: "ya29.oauth-token" }, + })); + + const loaded = loadStoredCredentialEnvKeys({ authPath, env }); + assert.deepEqual(loaded, []); + assert.equal(env.OPENAI_API_KEY, undefined); + assert.equal(env.GEMINI_API_KEY, undefined); } 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 index 818b87d6b..d19487437 100644 --- a/packages/mcp-server/src/tool-credentials.ts +++ b/packages/mcp-server/src/tool-credentials.ts @@ -8,7 +8,28 @@ type AuthCredential = type AuthStorageData = Record; -const TOOL_ENV_KEYS = [ +const AUTH_ENV_KEYS = [ + ["anthropic", "ANTHROPIC_API_KEY"], + ["openai", "OPENAI_API_KEY"], + ["github-copilot", "GITHUB_TOKEN"], + ["google", "GEMINI_API_KEY"], + ["groq", "GROQ_API_KEY"], + ["xai", "XAI_API_KEY"], + ["openrouter", "OPENROUTER_API_KEY"], + ["mistral", "MISTRAL_API_KEY"], + ["ollama-cloud", "OLLAMA_API_KEY"], + ["custom-openai", "CUSTOM_OPENAI_API_KEY"], + ["cerebras", "CEREBRAS_API_KEY"], + ["azure-openai-responses", "AZURE_OPENAI_API_KEY"], + ["vercel-ai-gateway", "AI_GATEWAY_API_KEY"], + ["zai", "ZAI_API_KEY"], + ["minimax", "MINIMAX_API_KEY"], + ["minimax-cn", "MINIMAX_CN_API_KEY"], + ["huggingface", "HF_TOKEN"], + ["opencode", "OPENCODE_API_KEY"], + ["opencode-go", "OPENCODE_API_KEY"], + ["kimi-coding", "KIMI_API_KEY"], + ["alibaba-coding-plan", "ALIBABA_API_KEY"], ["brave", "BRAVE_API_KEY"], ["brave_answers", "BRAVE_ANSWERS_KEY"], ["context7", "CONTEXT7_API_KEY"], @@ -17,9 +38,6 @@ const TOOL_ENV_KEYS = [ ["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 { @@ -48,7 +66,7 @@ export function resolveAuthPath(env: NodeJS.ProcessEnv = process.env): string { return join(homedir(), ".gsd", "agent", "auth.json"); } -export function loadStoredToolEnvKeys(options: { +export function loadStoredCredentialEnvKeys(options: { env?: NodeJS.ProcessEnv; authPath?: string; } = {}): string[] { @@ -67,7 +85,7 @@ export function loadStoredToolEnvKeys(options: { } const loaded: string[] = []; - for (const [providerId, envVar] of TOOL_ENV_KEYS) { + for (const [providerId, envVar] of AUTH_ENV_KEYS) { if (env[envVar]) continue; const key = getStoredApiKey(parsed, providerId); if (!key) continue;