Harden MCP schema and wire Claude OAuth/token handling

This commit is contained in:
2026-02-23 14:48:01 -05:00
parent ef2a25b5fb
commit 62e2491cde
14 changed files with 770 additions and 56 deletions

View File

@@ -7,7 +7,9 @@ export type ProviderRuntimeConfig = {
openAiApiKey?: string;
openAiBaseUrl?: string;
codexSkipGitCheck: boolean;
anthropicOauthToken?: string;
anthropicApiKey?: string;
anthropicToken?: string;
claudeModel?: string;
claudeCodePath?: string;
};
@@ -208,12 +210,49 @@ function deepFreeze<T>(value: T): Readonly<T> {
return Object.freeze(value);
}
export function resolveAnthropicToken(
provider: Pick<ProviderRuntimeConfig, "anthropicOauthToken" | "anthropicApiKey" | "anthropicToken">,
): string | undefined {
const oauthToken = provider.anthropicOauthToken?.trim();
if (oauthToken) {
return oauthToken;
}
const configuredToken = provider.anthropicToken?.trim();
if (configuredToken) {
return configuredToken;
}
const apiKey = provider.anthropicApiKey?.trim();
return apiKey || undefined;
}
export function buildClaudeAuthEnv(
provider: Pick<ProviderRuntimeConfig, "anthropicOauthToken" | "anthropicApiKey" | "anthropicToken">,
): Record<string, string | undefined> {
const oauthToken = provider.anthropicOauthToken?.trim();
if (oauthToken) {
return {
CLAUDE_CODE_OAUTH_TOKEN: oauthToken,
ANTHROPIC_API_KEY: undefined,
};
}
const token = resolveAnthropicToken(provider);
return {
CLAUDE_CODE_OAUTH_TOKEN: undefined,
ANTHROPIC_API_KEY: token,
};
}
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Readonly<AppConfig> {
const rawViolationHandling = readStringWithFallback(
env,
"AGENT_SECURITY_VIOLATION_MODE",
DEFAULT_SECURITY.violationHandling,
);
const anthropicOauthToken = readOptionalString(env, "CLAUDE_CODE_OAUTH_TOKEN");
const anthropicApiKey = readOptionalString(env, "ANTHROPIC_API_KEY");
const config: AppConfig = {
provider: {
@@ -221,7 +260,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Readonly<AppCo
openAiApiKey: readOptionalString(env, "OPENAI_API_KEY"),
openAiBaseUrl: readOptionalString(env, "OPENAI_BASE_URL"),
codexSkipGitCheck: readBooleanWithFallback(env, "CODEX_SKIP_GIT_CHECK", true),
anthropicApiKey: readOptionalString(env, "ANTHROPIC_API_KEY"),
anthropicOauthToken,
anthropicApiKey,
anthropicToken: anthropicOauthToken ?? anthropicApiKey,
claudeModel: readOptionalString(env, "CLAUDE_MODEL"),
claudeCodePath: readOptionalString(env, "CLAUDE_CODE_PATH"),
},

View File

@@ -1,7 +1,12 @@
import "dotenv/config";
import { query, type Options } from "@anthropic-ai/claude-agent-sdk";
import { query, type Options, type SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import { pathToFileURL } from "node:url";
import { getConfig } from "../config.js";
import {
buildClaudeAuthEnv,
getConfig,
resolveAnthropicToken,
type AppConfig,
} from "../config.js";
import { createSessionContext } from "./session-context.js";
function requiredPrompt(argv: string[]): string {
@@ -22,52 +27,96 @@ function buildOptions(config = getConfig()): Options {
};
}
export async function runClaudePrompt(prompt: string): Promise<void> {
const config = getConfig();
const sessionContext = await createSessionContext("claude", {
type ClaudeAuthOptionOverrides = {
apiKey?: string;
authToken?: string;
};
function buildClaudeAuthOptionOverrides(
config: Readonly<AppConfig>,
): ClaudeAuthOptionOverrides {
if (config.provider.anthropicOauthToken) {
return { authToken: config.provider.anthropicOauthToken };
}
const token = resolveAnthropicToken(config.provider);
return token ? { apiKey: token } : {};
}
export async function readClaudeResult(
session: AsyncIterable<SDKMessage>,
): Promise<string> {
let result = "";
for await (const message of session) {
if (message.type === "result" && message.subtype === "success") {
result = message.result.trim();
}
if (message.type === "result" && message.subtype !== "success") {
const detail = message.errors.join("; ");
throw new Error(
`Claude query failed (${message.subtype})${detail ? `: ${detail}` : ""}`,
);
}
}
if (!result) {
throw new Error("Claude run completed without a final result.");
}
return result;
}
type RunClaudePromptDependencies = {
config?: Readonly<AppConfig>;
createSessionContextFn?: typeof createSessionContext;
queryFn?: typeof query;
writeOutput?: (output: string) => void;
};
export async function runClaudePrompt(
prompt: string,
dependencies: RunClaudePromptDependencies = {},
): Promise<void> {
const config = dependencies.config ?? getConfig();
const createSessionContextFn = dependencies.createSessionContextFn ?? createSessionContext;
const queryFn = dependencies.queryFn ?? query;
const writeOutput = dependencies.writeOutput ?? ((output: string) => console.log(output));
const sessionContext = await createSessionContextFn("claude", {
prompt,
config,
});
try {
const runtimeEnv = {
...sessionContext.runtimeInjection.env,
...buildClaudeAuthEnv(config.provider),
};
const authOptionOverrides = buildClaudeAuthOptionOverrides(config);
const finalResponse = await sessionContext.runInSession(async () => {
const session = query({
const session = queryFn({
prompt: sessionContext.promptWithContext,
options: {
...buildOptions(config),
...(sessionContext.mcp.claudeMcpServers ? { mcpServers: sessionContext.mcp.claudeMcpServers } : {}),
...authOptionOverrides,
...(sessionContext.mcp.claudeMcpServers
? { mcpServers: sessionContext.mcp.claudeMcpServers as Options["mcpServers"] }
: {}),
cwd: sessionContext.runtimeInjection.workingDirectory,
env: sessionContext.runtimeInjection.env,
env: runtimeEnv,
},
});
let result = "";
try {
for await (const message of session) {
if (message.type === "result" && message.subtype === "success") {
result = message.result.trim();
}
if (message.type === "result" && message.subtype !== "success") {
const detail = message.errors.join("; ");
throw new Error(
`Claude query failed (${message.subtype})${detail ? `: ${detail}` : ""}`,
);
}
}
return await readClaudeResult(session);
} finally {
session.close();
}
if (!result) {
throw new Error("Claude run completed without a final result.");
}
return result;
});
console.log(finalResponse);
writeOutput(finalResponse);
} finally {
await sessionContext.close();
}

View File

@@ -1,7 +1,7 @@
import "dotenv/config";
import { Codex } from "@openai/codex-sdk";
import { pathToFileURL } from "node:url";
import { getConfig } from "../config.js";
import { getConfig, type AppConfig } from "../config.js";
import { createSessionContext } from "./session-context.js";
function requiredPrompt(argv: string[]): string {
@@ -12,9 +12,41 @@ function requiredPrompt(argv: string[]): string {
return prompt;
}
export async function runCodexPrompt(prompt: string): Promise<void> {
const config = getConfig();
const sessionContext = await createSessionContext("codex", {
type CodexThread = {
run: (prompt: string) => Promise<{
finalResponse: string;
}>;
};
type CodexClient = {
startThread: (options: {
workingDirectory: string;
skipGitRepoCheck: boolean;
}) => CodexThread;
};
type RunCodexPromptDependencies = {
config?: Readonly<AppConfig>;
createSessionContextFn?: typeof createSessionContext;
createCodexClient?: (input: ConstructorParameters<typeof Codex>[0]) => CodexClient;
writeOutput?: (output: string) => void;
};
export function parseCodexFinalResponse(turn: { finalResponse: string }): string {
return turn.finalResponse.trim() || "(No response text returned)";
}
export async function runCodexPrompt(
prompt: string,
dependencies: RunCodexPromptDependencies = {},
): Promise<void> {
const config = dependencies.config ?? getConfig();
const createSessionContextFn = dependencies.createSessionContextFn ?? createSessionContext;
const createCodexClient =
dependencies.createCodexClient ??
((input: ConstructorParameters<typeof Codex>[0]) => new Codex(input));
const writeOutput = dependencies.writeOutput ?? ((output: string) => console.log(output));
const sessionContext = await createSessionContextFn("codex", {
prompt,
config,
});
@@ -22,7 +54,7 @@ export async function runCodexPrompt(prompt: string): Promise<void> {
try {
const apiKey = config.provider.codexApiKey ?? config.provider.openAiApiKey;
const codex = new Codex({
const codex = createCodexClient({
...(apiKey ? { apiKey } : {}),
...(config.provider.openAiBaseUrl ? { baseUrl: config.provider.openAiBaseUrl } : {}),
...(sessionContext.mcp.codexConfig ? { config: sessionContext.mcp.codexConfig } : {}),
@@ -35,7 +67,7 @@ export async function runCodexPrompt(prompt: string): Promise<void> {
});
const turn = await sessionContext.runInSession(() => thread.run(sessionContext.promptWithContext));
console.log(turn.finalResponse.trim() || "(No response text returned)");
writeOutput(parseCodexFinalResponse(turn));
} finally {
await sessionContext.close();
}

View File

@@ -1,4 +1,4 @@
import { getConfig, type AppConfig } from "../config.js";
import { buildClaudeAuthEnv, getConfig, type AppConfig } from "../config.js";
import type { AgentSession } from "../agents/manager.js";
import type { ProvisionedResources } from "../agents/provisioning.js";
import {
@@ -60,9 +60,17 @@ export async function createSessionContext(
resources: [{ kind: "git-worktree" }, { kind: "port-range" }],
});
const providerAuthEnv =
provider === "claude"
? buildClaudeAuthEnv(config.provider)
: {};
const runtimeInjection = await provisionedResources.buildRuntimeInjection({
discoveryFileRelativePath: config.discovery.fileRelativePath,
baseEnv: process.env,
baseEnv: {
...process.env,
...providerAuthEnv,
},
});
const promptWithContext = provisionedResources.composePrompt(input.prompt, [

View File

@@ -20,12 +20,9 @@ import type {
McpLoadContext,
SharedMcpConfigFile,
} from "./mcp/types.js";
import { parseMcpConfig } from "./mcp/types.js";
import type { ToolClearancePolicy } from "./security/schemas.js";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readConfigFile(configPath: string): {
config?: SharedMcpConfigFile;
sourcePath?: string;
@@ -41,17 +38,32 @@ function readConfigFile(configPath: string): {
}
const rawText = readFileSync(resolvedPath, "utf8");
const parsed = JSON.parse(rawText) as unknown;
if (!isRecord(parsed)) {
throw new Error(`MCP config file must contain a JSON object: ${resolvedPath}`);
}
const parsed = parseMcpConfig(JSON.parse(rawText) as unknown);
return {
config: normalizeSharedMcpConfigFile(parsed as SharedMcpConfigFile),
config: normalizeSharedMcpConfigFile(parsed),
sourcePath: resolvedPath,
};
}
function warnClaudeUnsupportedSharedFields(
config: SharedMcpConfigFile,
warn: (message: string) => void,
): void {
for (const [serverName, server] of Object.entries(config.servers ?? {})) {
if (server.enabled_tools) {
warn(
`[WARN] MCP field 'enabled_tools' is not supported by the Claude adapter and will be ignored (server: "${serverName}").`,
);
}
if (server.startup_timeout_sec !== undefined || server.tool_timeout_sec !== undefined) {
warn(
`[WARN] MCP field 'timeouts' is not supported by the Claude adapter and will be ignored (server: "${serverName}").`,
);
}
}
}
const defaultMcpRegistry = createDefaultMcpRegistry();
export function getDefaultMcpRegistry(): McpRegistry {
@@ -64,16 +76,22 @@ export function loadMcpConfigFromEnv(
config?: Readonly<AppConfig>;
registry?: McpRegistry;
toolClearance?: ToolClearancePolicy;
warn?: (message: string) => void;
},
): LoadedMcpConfig {
const runtimeConfig = options?.config ?? getConfig();
const registry = options?.registry ?? defaultMcpRegistry;
const warn = options?.warn ?? ((message: string) => console.warn(message));
const { config, sourcePath } = readConfigFile(runtimeConfig.mcp.configPath);
if (!config) {
return {};
}
if (context.providerHint === "claude" || context.providerHint === "both") {
warnClaudeUnsupportedSharedFields(config, warn);
}
const codexServers: NonNullable<CodexOptions["config"]> = {};
const claudeServers: NonNullable<LoadedMcpConfig["claudeMcpServers"]> = {};
const resolvedHandlers: Record<string, string> = {};

View File

@@ -1,5 +1,5 @@
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
import type {
ClaudeMcpServer,
CodexConfigObject,
SharedMcpConfigFile,
SharedMcpServer,
@@ -49,6 +49,10 @@ export function toCodexServerConfig(serverName: string, server: SharedMcpServer)
const type = inferTransport(server);
const headers = mergeHeaders(server);
if (type === "sdk") {
throw new Error(`Shared MCP server "${serverName}" uses "sdk" transport, which is not supported by Codex.`);
}
if (type === "stdio" && !server.command) {
throw new Error(`Shared MCP server "${serverName}" requires "command" for stdio transport.`);
}
@@ -85,10 +89,20 @@ export function toCodexServerConfig(serverName: string, server: SharedMcpServer)
return config;
}
export function toClaudeServerConfig(serverName: string, server: SharedMcpServer): McpServerConfig {
export function toClaudeServerConfig(serverName: string, server: SharedMcpServer): ClaudeMcpServer {
const type = inferTransport(server);
const headers = mergeHeaders(server);
if (type === "sdk") {
if (!server.name) {
throw new Error(`Shared MCP server "${serverName}" requires "name" for sdk transport.`);
}
return {
type: "sdk",
name: server.name,
};
}
if (type === "stdio") {
if (!server.command) {
throw new Error(`Shared MCP server "${serverName}" requires "command" for stdio transport.`);

View File

@@ -1,10 +1,10 @@
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
import {
inferTransport,
toClaudeServerConfig,
toCodexServerConfig,
} from "./converters.js";
import type {
ClaudeMcpServer,
CodexConfigObject,
McpLoadContext,
SharedMcpConfigFile,
@@ -33,7 +33,7 @@ export type McpHandlerInput = {
export type McpHandlerResult = {
enabled?: boolean;
codex?: CodexConfigObject;
claude?: McpServerConfig;
claude?: ClaudeMcpServer;
};
export type McpServerHandler = {

View File

@@ -1,15 +1,22 @@
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
import type {
McpServerConfig,
McpServerConfigForProcessTransport,
} from "@anthropic-ai/claude-agent-sdk";
import type { CodexOptions } from "@openai/codex-sdk";
import { z } from "zod";
export type Transport = "stdio" | "http" | "sse";
export type Transport = "stdio" | "http" | "sse" | "sdk";
export type CodexConfigValue = string | number | boolean | CodexConfigValue[] | CodexConfigObject;
export type CodexConfigObject = {
[key: string]: CodexConfigValue;
};
export type ClaudeMcpServer = McpServerConfigForProcessTransport | McpServerConfig;
export type SharedMcpServer = {
type?: Transport;
name?: string;
command?: string;
args?: string[];
env?: Record<string, string>;
@@ -36,7 +43,7 @@ export type SharedMcpConfigFile = {
mcp_servers?: Record<string, CodexConfigObject>;
};
claude?: {
mcpServers?: Record<string, McpServerConfig>;
mcpServers?: Record<string, ClaudeMcpServer>;
};
handlerSettings?: Record<string, Record<string, unknown>>;
};
@@ -48,8 +55,164 @@ export type McpLoadContext = {
export type LoadedMcpConfig = {
codexConfig?: NonNullable<CodexOptions["config"]>;
claudeMcpServers?: Record<string, McpServerConfig>;
claudeMcpServers?: Record<string, ClaudeMcpServer>;
sourcePath?: string;
resolvedHandlers?: Record<string, string>;
};
const nonEmptyStringSchema = z.string().trim().min(1);
const nonNegativeFiniteNumberSchema = z.number().finite().min(0);
const stringRecordSchema = z.record(z.string().min(1), nonEmptyStringSchema);
const stringArraySchema = z.array(nonEmptyStringSchema);
function isHttpLikeUrl(value: string): boolean {
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
}
const httpLikeUrlSchema = nonEmptyStringSchema.refine(
(value) => isHttpLikeUrl(value),
'Expected an absolute "http" or "https" URL.',
);
const codexConfigValueSchema: z.ZodType<CodexConfigValue> = z.lazy(() =>
z.union([
z.string(),
z.number().finite(),
z.boolean(),
z.array(codexConfigValueSchema),
z.record(z.string().min(1), codexConfigValueSchema),
]),
);
const codexConfigObjectSchema: z.ZodType<CodexConfigObject> = z.record(
z.string().min(1),
codexConfigValueSchema,
);
export const sharedMcpServerSchema: z.ZodType<SharedMcpServer> = z
.object({
type: z.enum(["stdio", "http", "sse", "sdk"]).optional(),
name: nonEmptyStringSchema.optional(),
command: nonEmptyStringSchema.optional(),
args: stringArraySchema.optional(),
env: stringRecordSchema.optional(),
cwd: nonEmptyStringSchema.optional(),
url: httpLikeUrlSchema.optional(),
headers: stringRecordSchema.optional(),
enabled: z.boolean().optional(),
required: z.boolean().optional(),
enabled_tools: stringArraySchema.optional(),
disabled_tools: stringArraySchema.optional(),
startup_timeout_sec: nonNegativeFiniteNumberSchema.optional(),
tool_timeout_sec: nonNegativeFiniteNumberSchema.optional(),
bearer_token_env_var: nonEmptyStringSchema.optional(),
http_headers: stringRecordSchema.optional(),
env_http_headers: stringRecordSchema.optional(),
env_vars: stringArraySchema.optional(),
handler: nonEmptyStringSchema.optional(),
handlerOptions: z.record(z.string().min(1), z.unknown()).optional(),
})
.strict()
.superRefine((server, context) => {
const inferredType = server.type ?? (server.url ? "http" : "stdio");
if (inferredType === "stdio" && !server.command) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["command"],
message: '"command" is required when MCP transport is "stdio".',
});
}
if ((inferredType === "http" || inferredType === "sse") && !server.url) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["url"],
message: `"url" is required when MCP transport is "${inferredType}".`,
});
}
if (inferredType === "sdk" && !server.name) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["name"],
message: '"name" is required when MCP transport is "sdk".',
});
}
if ((inferredType === "http" || inferredType === "sse") && server.command) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["command"],
message: `"command" is only valid for "stdio" transport.`,
});
}
if (inferredType === "sdk" && (server.command || server.url)) {
context.addIssue({
code: z.ZodIssueCode.custom,
message: '"sdk" transport cannot declare "command" or "url".',
});
}
});
const claudeMcpServerSchema: z.ZodType<McpServerConfigForProcessTransport> = z.union([
z
.object({
type: z.literal("stdio"),
command: nonEmptyStringSchema,
args: stringArraySchema.optional(),
env: stringRecordSchema.optional(),
})
.strict(),
z
.object({
type: z.literal("http"),
url: httpLikeUrlSchema,
headers: stringRecordSchema.optional(),
})
.strict(),
z
.object({
type: z.literal("sse"),
url: httpLikeUrlSchema,
headers: stringRecordSchema.optional(),
})
.strict(),
z
.object({
type: z.literal("sdk"),
name: nonEmptyStringSchema,
})
.strict(),
]);
export const McpConfigSchema: z.ZodType<SharedMcpConfigFile> = z
.object({
servers: z.record(z.string().min(1), sharedMcpServerSchema).optional(),
codex: z
.object({
mcp_servers: z.record(z.string().min(1), codexConfigObjectSchema).optional(),
})
.strict()
.optional(),
claude: z
.object({
mcpServers: z.record(z.string().min(1), claudeMcpServerSchema).optional(),
})
.strict()
.optional(),
handlerSettings: z
.record(z.string().min(1), z.record(z.string().min(1), z.unknown()))
.optional(),
})
.strict();
export function parseMcpConfig(input: unknown): SharedMcpConfigFile {
return McpConfigSchema.parse(input);
}