Harden MCP schema and wire Claude OAuth/token handling
This commit is contained in:
@@ -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