Harden MCP schema and wire Claude OAuth/token handling
This commit is contained in:
@@ -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,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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user