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,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");
});

View File

@@ -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
View 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);
});

View 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)");
});