Harden MCP schema and wire Claude OAuth/token handling

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

View File

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

View File

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

View File

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