Harden MCP schema and wire Claude OAuth/token handling
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
|
||||
@@ -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,28 +27,27 @@ function buildOptions(config = getConfig()): Options {
|
||||
};
|
||||
}
|
||||
|
||||
export async function runClaudePrompt(prompt: string): Promise<void> {
|
||||
const config = getConfig();
|
||||
const sessionContext = await createSessionContext("claude", {
|
||||
prompt,
|
||||
config,
|
||||
});
|
||||
type ClaudeAuthOptionOverrides = {
|
||||
apiKey?: string;
|
||||
authToken?: string;
|
||||
};
|
||||
|
||||
try {
|
||||
const finalResponse = await sessionContext.runInSession(async () => {
|
||||
const session = query({
|
||||
prompt: sessionContext.promptWithContext,
|
||||
options: {
|
||||
...buildOptions(config),
|
||||
...(sessionContext.mcp.claudeMcpServers ? { mcpServers: sessionContext.mcp.claudeMcpServers } : {}),
|
||||
cwd: sessionContext.runtimeInjection.workingDirectory,
|
||||
env: sessionContext.runtimeInjection.env,
|
||||
},
|
||||
});
|
||||
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 = "";
|
||||
|
||||
try {
|
||||
for await (const message of session) {
|
||||
if (message.type === "result" && message.subtype === "success") {
|
||||
result = message.result.trim();
|
||||
@@ -56,18 +60,63 @@ export async function runClaudePrompt(prompt: string): Promise<void> {
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
session.close();
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
console.log(finalResponse);
|
||||
try {
|
||||
const runtimeEnv = {
|
||||
...sessionContext.runtimeInjection.env,
|
||||
...buildClaudeAuthEnv(config.provider),
|
||||
};
|
||||
const authOptionOverrides = buildClaudeAuthOptionOverrides(config);
|
||||
|
||||
const finalResponse = await sessionContext.runInSession(async () => {
|
||||
const session = queryFn({
|
||||
prompt: sessionContext.promptWithContext,
|
||||
options: {
|
||||
...buildOptions(config),
|
||||
...authOptionOverrides,
|
||||
...(sessionContext.mcp.claudeMcpServers
|
||||
? { mcpServers: sessionContext.mcp.claudeMcpServers as Options["mcpServers"] }
|
||||
: {}),
|
||||
cwd: sessionContext.runtimeInjection.workingDirectory,
|
||||
env: runtimeEnv,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
return await readClaudeResult(session);
|
||||
} finally {
|
||||
session.close();
|
||||
}
|
||||
});
|
||||
|
||||
writeOutput(finalResponse);
|
||||
} finally {
|
||||
await sessionContext.close();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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, [
|
||||
|
||||
36
src/mcp.ts
36
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<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> = {};
|
||||
|
||||
@@ -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.`);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
171
src/mcp/types.ts
171
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<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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
90
tests/mcp-load.test.ts
Normal file
90
tests/mcp-load.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
217
tests/provider-adapters.test.ts
Normal file
217
tests/provider-adapters.test.ts
Normal file
@@ -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<SDKMessage> {
|
||||
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<string, unknown> | 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 <T>(run: () => Promise<T>) => run(),
|
||||
close: async () => {
|
||||
closed = true;
|
||||
},
|
||||
};
|
||||
|
||||
await runCodexPrompt("ignored", {
|
||||
config,
|
||||
createSessionContextFn: async () => sessionContext,
|
||||
createCodexClient: (input) => {
|
||||
capturedClientInput = input as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
}
|
||||
| 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 <T>(run: () => Promise<T>) => run(),
|
||||
close: async () => {
|
||||
closed = true;
|
||||
},
|
||||
};
|
||||
|
||||
const queryFn: ClaudeQueryFunction = ((input: {
|
||||
prompt: string;
|
||||
options?: Record<string, unknown>;
|
||||
}) => {
|
||||
queryInput = input;
|
||||
const stream = createMessageStream([
|
||||
{
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
result: " response text ",
|
||||
} as SDKMessage,
|
||||
]);
|
||||
|
||||
return {
|
||||
...stream,
|
||||
close: () => {},
|
||||
} as ReturnType<ClaudeQueryFunction>;
|
||||
}) 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<string, string | undefined> | 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)");
|
||||
});
|
||||
Reference in New Issue
Block a user