Merge pull request #3951 from jeremymcs/fix/mcp-server-credential-hydration

This commit is contained in:
Jeremy McSpadden 2026-04-10 17:52:40 -05:00 committed by GitHub
commit da7e78ce9e
5 changed files with 198 additions and 0 deletions

View file

@ -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 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`.

View file

@ -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 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
```

View file

@ -7,10 +7,13 @@
import { SessionManager } from './session-manager.js';
import { createMcpServer } from './server.js';
import { loadStoredCredentialEnvKeys } from './tool-credentials.js';
const MCP_PKG = '@modelcontextprotocol/sdk';
async function main(): Promise<void> {
loadStoredCredentialEnvKeys();
const sessionManager = new SessionManager();
// Create the configured MCP server with session, interactive, read-only,

View file

@ -0,0 +1,95 @@
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 { loadStoredCredentialEnvKeys, resolveAuthPath } from "./tool-credentials.js";
describe("tool credentials", () => {
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" }],
}));
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");
} 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" },
anthropic: { type: "api_key", key: "sk-ant-from-auth-json" },
}));
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 });
}
});
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 });
}
});
});

View file

@ -0,0 +1,97 @@
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<string, AuthCredential>;
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"],
["jina", "JINA_API_KEY"],
["tavily", "TAVILY_API_KEY"],
["slack_bot", "SLACK_BOT_TOKEN"],
["discord_bot", "DISCORD_BOT_TOKEN"],
["telegram_bot", "TELEGRAM_BOT_TOKEN"],
] 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 loadStoredCredentialEnvKeys(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 AUTH_ENV_KEYS) {
if (env[envVar]) continue;
const key = getStoredApiKey(parsed, providerId);
if (!key) continue;
env[envVar] = key;
loaded.push(envVar);
}
return loaded;
}