From 62e2491cde43acdc0406a24e81c3f71eeb602f5e Mon Sep 17 00:00:00 2001 From: Josh Rzemien Date: Mon, 23 Feb 2026 14:48:01 -0500 Subject: [PATCH] Harden MCP schema and wire Claude OAuth/token handling --- .env.example | 1 + README.md | 1 + src/config.ts | 43 ++++++- src/examples/claude.ts | 107 +++++++++++----- src/examples/codex.ts | 44 ++++++- src/examples/session-context.ts | 12 +- src/mcp.ts | 36 ++++-- src/mcp/converters.ts | 18 ++- src/mcp/handlers.ts | 4 +- src/mcp/types.ts | 171 ++++++++++++++++++++++++- tests/config.test.ts | 33 ++++- tests/mcp-converters.test.ts | 49 ++++++++ tests/mcp-load.test.ts | 90 +++++++++++++ tests/provider-adapters.test.ts | 217 ++++++++++++++++++++++++++++++++ 14 files changed, 770 insertions(+), 56 deletions(-) create mode 100644 tests/mcp-load.test.ts create mode 100644 tests/provider-adapters.test.ts diff --git a/.env.example b/.env.example index f55f722..bc7c462 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,7 @@ CODEX_SKIP_GIT_CHECK=true MCP_CONFIG_PATH=./mcp.config.json # Anthropic Claude Agent SDK +CLAUDE_CODE_OAUTH_TOKEN= ANTHROPIC_API_KEY= CLAUDE_MODEL= CLAUDE_CODE_PATH= diff --git a/README.md b/README.md index ea1f536..bc80d3a 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,7 @@ Actors can emit events in `ActorExecutionResult.events`. Pipeline status also em - `OPENAI_API_KEY` - `OPENAI_BASE_URL` - `CODEX_SKIP_GIT_CHECK` +- `CLAUDE_CODE_OAUTH_TOKEN` (preferred for Claude auth) - `ANTHROPIC_API_KEY` - `CLAUDE_MODEL` - `CLAUDE_CODE_PATH` diff --git a/src/config.ts b/src/config.ts index a9f28aa..96c4f58 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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(value: T): Readonly { return Object.freeze(value); } +export function resolveAnthropicToken( + provider: Pick, +): 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, +): Record { + 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 { 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 { - const config = getConfig(); - const sessionContext = await createSessionContext("claude", { +type ClaudeAuthOptionOverrides = { + apiKey?: string; + authToken?: string; +}; + +function buildClaudeAuthOptionOverrides( + config: Readonly, +): 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, +): Promise { + 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; + createSessionContextFn?: typeof createSessionContext; + queryFn?: typeof query; + writeOutput?: (output: string) => void; +}; + +export async function runClaudePrompt( + prompt: string, + dependencies: RunClaudePromptDependencies = {}, +): Promise { + 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(); } diff --git a/src/examples/codex.ts b/src/examples/codex.ts index d12d4d9..132514b 100644 --- a/src/examples/codex.ts +++ b/src/examples/codex.ts @@ -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 { - 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; + createSessionContextFn?: typeof createSessionContext; + createCodexClient?: (input: ConstructorParameters[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 { + const config = dependencies.config ?? getConfig(); + const createSessionContextFn = dependencies.createSessionContextFn ?? createSessionContext; + const createCodexClient = + dependencies.createCodexClient ?? + ((input: ConstructorParameters[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 { 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 { }); 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(); } diff --git a/src/examples/session-context.ts b/src/examples/session-context.ts index 32c9db6..0c398f6 100644 --- a/src/examples/session-context.ts +++ b/src/examples/session-context.ts @@ -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, [ diff --git a/src/mcp.ts b/src/mcp.ts index f1b4742..ccb59e0 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -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 { - 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; 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 = {}; const claudeServers: NonNullable = {}; const resolvedHandlers: Record = {}; diff --git a/src/mcp/converters.ts b/src/mcp/converters.ts index bf98594..7b7c588 100644 --- a/src/mcp/converters.ts +++ b/src/mcp/converters.ts @@ -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.`); diff --git a/src/mcp/handlers.ts b/src/mcp/handlers.ts index eec678c..a8e57c4 100644 --- a/src/mcp/handlers.ts +++ b/src/mcp/handlers.ts @@ -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 = { diff --git a/src/mcp/types.ts b/src/mcp/types.ts index f5de180..ea6e4be 100644 --- a/src/mcp/types.ts +++ b/src/mcp/types.ts @@ -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; @@ -36,7 +43,7 @@ export type SharedMcpConfigFile = { mcp_servers?: Record; }; claude?: { - mcpServers?: Record; + mcpServers?: Record; }; handlerSettings?: Record>; }; @@ -48,8 +55,164 @@ export type McpLoadContext = { export type LoadedMcpConfig = { codexConfig?: NonNullable; - claudeMcpServers?: Record; + claudeMcpServers?: Record; sourcePath?: string; resolvedHandlers?: Record; }; +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 = 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 = z.record( + z.string().min(1), + codexConfigValueSchema, +); + +export const sharedMcpServerSchema: z.ZodType = 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 = 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 = 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); +} diff --git a/tests/config.test.ts b/tests/config.test.ts index 04dcdd2..1b2970c 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { loadConfig } from "../src/config.js"; +import { buildClaudeAuthEnv, loadConfig, resolveAnthropicToken } from "../src/config.js"; test("loads defaults and freezes config", () => { const config = loadConfig({}); @@ -28,3 +28,34 @@ test("validates security violation mode", () => { /invalid_union|Invalid input/i, ); }); + +test("prefers CLAUDE_CODE_OAUTH_TOKEN over ANTHROPIC_API_KEY", () => { + const config = loadConfig({ + CLAUDE_CODE_OAUTH_TOKEN: "oauth-token", + ANTHROPIC_API_KEY: "api-key", + }); + + assert.equal(config.provider.anthropicOauthToken, "oauth-token"); + assert.equal(config.provider.anthropicApiKey, "api-key"); + assert.equal(config.provider.anthropicToken, "oauth-token"); + assert.equal(resolveAnthropicToken(config.provider), "oauth-token"); + + const authEnv = buildClaudeAuthEnv(config.provider); + assert.equal(authEnv.CLAUDE_CODE_OAUTH_TOKEN, "oauth-token"); + assert.equal(authEnv.ANTHROPIC_API_KEY, undefined); +}); + +test("falls back to ANTHROPIC_API_KEY when oauth token is absent", () => { + const config = loadConfig({ + ANTHROPIC_API_KEY: "api-key", + }); + + assert.equal(config.provider.anthropicOauthToken, undefined); + assert.equal(config.provider.anthropicApiKey, "api-key"); + assert.equal(config.provider.anthropicToken, "api-key"); + assert.equal(resolveAnthropicToken(config.provider), "api-key"); + + const authEnv = buildClaudeAuthEnv(config.provider); + assert.equal(authEnv.CLAUDE_CODE_OAUTH_TOKEN, undefined); + assert.equal(authEnv.ANTHROPIC_API_KEY, "api-key"); +}); diff --git a/tests/mcp-converters.test.ts b/tests/mcp-converters.test.ts index 2070967..e6bdc4a 100644 --- a/tests/mcp-converters.test.ts +++ b/tests/mcp-converters.test.ts @@ -6,6 +6,7 @@ import { toClaudeServerConfig, toCodexServerConfig, } from "../src/mcp/converters.js"; +import { parseMcpConfig } from "../src/mcp/types.js"; test("infers stdio transport when url is absent", () => { const transport = inferTransport({ @@ -84,3 +85,51 @@ test("throws for claude http server without url", () => { /requires "url" for http transport/, ); }); + +test("maps sdk transport for claude conversion", () => { + const claudeConfig = toClaudeServerConfig("local-sdk", { + type: "sdk", + name: "local-sdk-server", + }); + + assert.deepEqual(claudeConfig, { + type: "sdk", + name: "local-sdk-server", + }); +}); + +test("throws when sdk transport is converted for codex", () => { + assert.throws( + () => toCodexServerConfig("sdk-server", { type: "sdk", name: "sdk-server" }), + /not supported by Codex/, + ); +}); + +test("accepts sdk transport in strict MCP schema", () => { + const parsed = parseMcpConfig({ + servers: { + "sdk-server": { + type: "sdk", + name: "sdk-server", + }, + }, + }); + + assert.equal(parsed.servers?.["sdk-server"]?.type, "sdk"); +}); + +test("rejects unknown fields in strict MCP schema", () => { + assert.throws( + () => + parseMcpConfig({ + servers: { + broken: { + type: "stdio", + command: "node", + extra: "nope", + }, + }, + }), + /unrecognized key/i, + ); +}); diff --git a/tests/mcp-load.test.ts b/tests/mcp-load.test.ts new file mode 100644 index 0000000..e61bc22 --- /dev/null +++ b/tests/mcp-load.test.ts @@ -0,0 +1,90 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { loadConfig } from "../src/config.js"; +import { loadMcpConfigFromEnv } from "../src/mcp.js"; + +test("warns when Claude ignores codex-specific shared MCP fields", async () => { + const root = await mkdtemp(resolve(tmpdir(), "ai-ops-mcp-load-")); + const mcpConfigPath = resolve(root, "mcp.config.json"); + + await writeFile( + mcpConfigPath, + JSON.stringify( + { + servers: { + "local-tools": { + type: "stdio", + command: "node", + args: ["server.js"], + enabled_tools: ["read_file"], + startup_timeout_sec: 15, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + const warnings: string[] = []; + const config = loadConfig({ MCP_CONFIG_PATH: mcpConfigPath }); + + const loaded = loadMcpConfigFromEnv( + { + providerHint: "claude", + }, + { + config, + warn: (message) => warnings.push(message), + }, + ); + + assert.ok(loaded.claudeMcpServers); + assert.equal(warnings.length, 2); + assert.match(warnings[0] ?? "", /enabled_tools/); + assert.match(warnings[1] ?? "", /timeouts/); +}); + +test("does not warn about Claude-only limitations when provider hint is codex", async () => { + const root = await mkdtemp(resolve(tmpdir(), "ai-ops-mcp-load-codex-")); + const mcpConfigPath = resolve(root, "mcp.config.json"); + + await writeFile( + mcpConfigPath, + JSON.stringify( + { + servers: { + "local-tools": { + type: "stdio", + command: "node", + args: ["server.js"], + enabled_tools: ["read_file"], + startup_timeout_sec: 15, + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + const warnings: string[] = []; + const config = loadConfig({ MCP_CONFIG_PATH: mcpConfigPath }); + + loadMcpConfigFromEnv( + { + providerHint: "codex", + }, + { + config, + warn: (message) => warnings.push(message), + }, + ); + + assert.equal(warnings.length, 0); +}); diff --git a/tests/provider-adapters.test.ts b/tests/provider-adapters.test.ts new file mode 100644 index 0000000..d8ca4d4 --- /dev/null +++ b/tests/provider-adapters.test.ts @@ -0,0 +1,217 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; +import { loadConfig } from "../src/config.js"; +import { + parseCodexFinalResponse, + runCodexPrompt, +} from "../src/examples/codex.js"; +import { + readClaudeResult, + runClaudePrompt, +} from "../src/examples/claude.js"; +import type { SessionContext } from "../src/examples/session-context.js"; + +type ClaudeQueryFunction = (typeof import("@anthropic-ai/claude-agent-sdk"))["query"]; + +function createMessageStream( + messages: SDKMessage[], +): AsyncIterable { + return { + async *[Symbol.asyncIterator]() { + for (const message of messages) { + yield message; + } + }, + }; +} + +test("runCodexPrompt wires client options and parses final output", async () => { + const config = loadConfig({ + CODEX_API_KEY: "codex-token", + OPENAI_BASE_URL: "https://api.example.com/v1", + }); + + let capturedClientInput: Record | undefined; + let capturedThreadInput: + | { + workingDirectory: string; + skipGitRepoCheck: boolean; + } + | undefined; + let capturedPrompt = ""; + let closed = false; + let output = ""; + + const sessionContext: SessionContext = { + provider: "codex", + sessionId: "session-codex", + mcp: { + codexConfig: { + mcp_servers: { + local: { + command: "node", + args: ["server.js"], + }, + }, + }, + }, + promptWithContext: "prompt with context", + runtimeInjection: { + workingDirectory: "/tmp/worktree", + env: { + PATH: "/usr/bin", + }, + discoveryFilePath: "/tmp/worktree/.agent-context/resources.json", + }, + runInSession: async (run: () => Promise) => run(), + close: async () => { + closed = true; + }, + }; + + await runCodexPrompt("ignored", { + config, + createSessionContextFn: async () => sessionContext, + createCodexClient: (input) => { + capturedClientInput = input as Record; + return { + startThread: (threadInput) => { + capturedThreadInput = threadInput; + return { + run: async (prompt) => { + capturedPrompt = prompt; + return { + finalResponse: " completed ", + }; + }, + }; + }, + }; + }, + writeOutput: (line) => { + output = line; + }, + }); + + assert.equal(capturedClientInput?.["apiKey"], "codex-token"); + assert.equal(capturedClientInput?.["baseUrl"], "https://api.example.com/v1"); + assert.deepEqual(capturedClientInput?.["config"], sessionContext.mcp.codexConfig); + assert.deepEqual(capturedClientInput?.["env"], sessionContext.runtimeInjection.env); + assert.deepEqual(capturedThreadInput, { + workingDirectory: "/tmp/worktree", + skipGitRepoCheck: true, + }); + assert.equal(capturedPrompt, "prompt with context"); + assert.equal(output, "completed"); + assert.equal(closed, true); +}); + +test("runClaudePrompt wires auth env, stream parsing, and output", async () => { + const config = loadConfig({ + CLAUDE_CODE_OAUTH_TOKEN: "oauth-token", + ANTHROPIC_API_KEY: "legacy-api-key", + CLAUDE_MODEL: "claude-sonnet-4-6", + CLAUDE_CODE_PATH: "/usr/local/bin/claude", + }); + + let closed = false; + let output = ""; + let queryInput: + | { + prompt: string; + options?: Record; + } + | undefined; + + const sessionContext: SessionContext = { + provider: "claude", + sessionId: "session-claude", + mcp: { + claudeMcpServers: { + local: { + type: "stdio", + command: "node", + args: ["server.js"], + }, + }, + }, + promptWithContext: "augmented prompt", + runtimeInjection: { + workingDirectory: "/tmp/claude-worktree", + env: { + PATH: "/usr/bin", + ANTHROPIC_API_KEY: "ambient-api-key", + }, + discoveryFilePath: "/tmp/claude-worktree/.agent-context/resources.json", + }, + runInSession: async (run: () => Promise) => run(), + close: async () => { + closed = true; + }, + }; + + const queryFn: ClaudeQueryFunction = ((input: { + prompt: string; + options?: Record; + }) => { + queryInput = input; + const stream = createMessageStream([ + { + type: "result", + subtype: "success", + result: " response text ", + } as SDKMessage, + ]); + + return { + ...stream, + close: () => {}, + } as ReturnType; + }) as ClaudeQueryFunction; + + await runClaudePrompt("ignored", { + config, + createSessionContextFn: async () => sessionContext, + queryFn, + writeOutput: (line) => { + output = line; + }, + }); + + assert.equal(queryInput?.prompt, "augmented prompt"); + assert.equal(queryInput?.options?.model, "claude-sonnet-4-6"); + assert.equal(queryInput?.options?.pathToClaudeCodeExecutable, "/usr/local/bin/claude"); + assert.equal(queryInput?.options?.cwd, "/tmp/claude-worktree"); + assert.equal(queryInput?.options?.authToken, "oauth-token"); + assert.deepEqual(queryInput?.options?.mcpServers, sessionContext.mcp.claudeMcpServers); + + const env = queryInput?.options?.env as Record | undefined; + assert.equal(env?.CLAUDE_CODE_OAUTH_TOKEN, "oauth-token"); + assert.equal(env?.ANTHROPIC_API_KEY, undefined); + assert.equal(output, "response text"); + assert.equal(closed, true); +}); + +test("readClaudeResult throws on non-success result events", async () => { + const stream = createMessageStream([ + { + type: "result", + subtype: "error_during_execution", + errors: ["first", "second"], + } as SDKMessage, + ]); + + await assert.rejects( + () => readClaudeResult(stream), + /Claude query failed \(error_during_execution\): first; second/, + ); +}); + +test("parseCodexFinalResponse keeps fallback text for empty responses", () => { + const response = parseCodexFinalResponse({ + finalResponse: " ", + }); + + assert.equal(response, "(No response text returned)"); +});