diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 562ae4f1..cd3bc115 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1938,6 +1938,10 @@ a qualified profile in `services/agents-api/internal/engine`, registration and an independently verified deployment. It does not add engine-name branches to API handlers, persistence, dispatch or scheduling. +Agents Core is pre-release. Replace superseded internal interfaces and execution +paths cleanly; do not retain version fallbacks or compatibility shims. Preserve +the pinned official public protocol, valid data and still-used infrastructure. + The small static profile catalog owns engine-specific public admission and value limits. Profile callbacks are pure and use existing public/protocol types; they cannot query business data, decrypt credentials or control native processes. @@ -1952,7 +1956,7 @@ parity or equate accepted parameters with applied native behavior. Claude hosted functions compose the existing SDK function bridge with the native workspace sandbox. Only declared function tools and the verified native tool inventory are available. The bundle advertises this combination separately from -basic workspace execution; older bundles cannot receive function preparations. +basic workspace execution; function preparations require that verified combination. External hosted MCP remains unqualified. Function callbacks do not change file, credential, history, subagent or network authority. @@ -2179,8 +2183,19 @@ status/configuration or control responses. Diagnostic projections must whitelist safe fields. This does not permit filtering actual model/tool output to hide a leak. The bounded adapter profile currently requires connected servers, reserves the `functions` label, accepts alphanumeric/underscore/hyphen server labels and -alphanumeric/underscore/hyphen/dot selected tool names, and excludes required startup -and remote environments. Public Claude static-bearer HTTPS MCP reuses the shared +alphanumeric/underscore/hyphen/dot selected tool names, and excludes remote +environments. Required startup is separately qualified by `mcp_http_required`. +All HTTP MCP queries use native SDK startup and an empty input iterator to +confirm initialization hooks. Required declarations additionally check connected +server status before the initial prompt is released exactly once. Pending, failed, +missing or ambiguous required status rejects before input; native startup timeouts are retained without +an adapter retry loop. Normal system/init still verifies Session identity and the +complete inventory before input readiness/tool authority. Optional servers retain +their existing inventory checks without a new pre-input connection requirement. +A Runtime must advertise the concrete required-initialization capability; there +is no fallback to an older execution path. + +Public Claude static-bearer HTTPS MCP reuses the shared Vault attachment, frozen selection and scoped decryption path. Selection and final preclaim require the existing bearer capability; shared authentication dispatch uses capability/placement checks rather than a Codex-name restriction. Missing keys diff --git a/apps/parsar-daemon/internal/agent/claudesdk/mcp.go b/apps/parsar-daemon/internal/agent/claudesdk/mcp.go index 21543903..35180325 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/mcp.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/mcp.go @@ -29,8 +29,7 @@ func validateMCP(req proto.PromptRequestPayload) error { endpoint, err := url.Parse(server.ServerURL) if !mcpLabel.MatchString(server.ServerLabel) || server.ServerLabel == "functions" || labels[server.ServerLabel] || err != nil || (endpoint.Scheme != "http" && endpoint.Scheme != "https") || endpoint.Hostname() == "" || endpoint.User != nil || - strings.ContainsAny(server.ServerURL, "?#") || endpoint.Opaque != "" || - server.Required { + strings.ContainsAny(server.ServerURL, "?#") || endpoint.Opaque != "" { return fmt.Errorf("claudesdk: unsupported HTTP MCP declaration") } if server.BearerToken != nil && (endpoint.Scheme != "https" || !agent.ValidMCPHTTPBearerToken(*server.BearerToken)) { @@ -54,6 +53,7 @@ type mcpHTTPServer struct { ServerLabel string `json:"server_label"` ServerURL string `json:"server_url"` AllowedTools *[]string `json:"allowed_tools"` + Required bool `json:"required,omitempty"` BearerTokenEnvVar string `json:"bearer_token_env_var,omitempty"` } @@ -64,7 +64,7 @@ func prepareMCPHTTP(declarations *[]proto.MCPHTTPServer) (*[]mcpHTTPServer, []st servers := make([]mcpHTTPServer, len(*declarations)) var env []string for i, declaration := range *declarations { - server := mcpHTTPServer{ServerLabel: declaration.ServerLabel, ServerURL: declaration.ServerURL} + server := mcpHTTPServer{ServerLabel: declaration.ServerLabel, ServerURL: declaration.ServerURL, Required: declaration.Required} if declaration.AllowedTools != nil { tools := append([]string{}, (*declaration.AllowedTools)...) server.AllowedTools = &tools diff --git a/apps/parsar-daemon/internal/agent/claudesdk/mcp_test.go b/apps/parsar-daemon/internal/agent/claudesdk/mcp_test.go index f69c4e68..2653e440 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/mcp_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/mcp_test.go @@ -47,7 +47,7 @@ func TestHTTPMCPDeclaration(t *testing.T) { req.DisableExecutionEnvironment = false } start, _, err := prepare(config, req) - valid := mode == "unrestricted" || mode == "selected" || mode == "empty" || mode == "nil-slice" || mode == "auth" + valid := mode == "unrestricted" || mode == "selected" || mode == "empty" || mode == "nil-slice" || mode == "auth" || mode == "required" if (err == nil) != valid { t.Fatalf("unexpected admission: %v", err) } @@ -55,6 +55,9 @@ func TestHTTPMCPDeclaration(t *testing.T) { return } raw, _ := json.Marshal(start) + if mode == "required" && !strings.Contains(string(raw), `"required":true`) { + t.Fatal("required initialization was discarded") + } if (mode == "empty" || mode == "nil-slice") && !strings.Contains(string(raw), `"allowed_tools":[]`) { t.Fatal("empty selection widened") } diff --git a/apps/parsar-daemon/internal/agent/claudesdk/readiness.go b/apps/parsar-daemon/internal/agent/claudesdk/readiness.go index d1deb39d..30be6bd2 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/readiness.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/readiness.go @@ -33,6 +33,10 @@ func (info RuntimeInfo) SupportsHTTPMCPBearer() bool { return info.SupportsHTTPMCP() && slices.Contains(info.Features, "mcp_http_bearer_auth") } +func (info RuntimeInfo) SupportsHTTPMCPRequired() bool { + return info.SupportsHTTPMCP() && slices.Contains(info.Features, "mcp_http_required") +} + func (info RuntimeInfo) supportsWorkspace() bool { return slices.Contains(info.Features, "workspace_tools") } diff --git a/apps/parsar-daemon/internal/agent/claudesdk/readiness_test.go b/apps/parsar-daemon/internal/agent/claudesdk/readiness_test.go index 0b64ee31..7301aa6d 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/readiness_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/readiness_test.go @@ -17,6 +17,19 @@ import ( const readyReport = `{"type":"runtime_ready","protocol":1,"node":"22.22.2","sdk":"0.3.269","mcp":"1.30.0","native":"2.1.269 (Claude Code)"}` +func TestRequiredMCPNeedsQualifiedRuntime(t *testing.T) { + root := t.TempDir() + t.Setenv("PARSAR_HOME", root) + config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "main.js"), StateDir: filepath.Join(root, "state"), Env: []string{ + "GO_CLAUDE_READINESS_HELPER=1", "READINESS_MODE=ready-http-mcp", "GORACE=atexit_sleep_ms=0", + }} + req := proto.PromptRequestPayload{RunID: "run", Prompt: "hello", DisableExecutionEnvironment: true, + AgentOptions: map[string]any{"model": "fixture"}, MCPHTTPServers: &[]proto.MCPHTTPServer{{ServerLabel: "fixture", ServerURL: "https://example.invalid/mcp", Required: true}}} + if _, err := NewFactory(config)(t.Context(), req, make(chan proto.Envelope, 1)); err == nil || err.Error() != "claudesdk: packaged runtime does not support required HTTP MCP" { + t.Fatalf("unqualified runtime executed required MCP: %v", err) + } +} + func TestHTTPMCPRejectsOldPackagedRuntime(t *testing.T) { root := t.TempDir() t.Setenv("PARSAR_HOME", root) diff --git a/apps/parsar-daemon/internal/agent/claudesdk/session.go b/apps/parsar-daemon/internal/agent/claudesdk/session.go index 1a372ca0..d184e0bb 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/session.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/session.go @@ -55,6 +55,9 @@ func NewFactory(config Config) agent.Factory { return nil, fmt.Errorf("claudesdk: packaged runtime does not support HTTP MCP") } for _, server := range *start.MCPHTTPServers { + if server.Required && !info.SupportsHTTPMCPRequired() { + return nil, fmt.Errorf("claudesdk: packaged runtime does not support required HTTP MCP") + } if server.BearerTokenEnvVar != "" && !info.SupportsHTTPMCPBearer() { return nil, fmt.Errorf("claudesdk: packaged runtime does not support authenticated HTTP MCP") } diff --git a/apps/parsar-daemon/internal/cli/claude_sdk.go b/apps/parsar-daemon/internal/cli/claude_sdk.go index c17aa75f..442f4d6d 100644 --- a/apps/parsar-daemon/internal/cli/claude_sdk.go +++ b/apps/parsar-daemon/internal/cli/claude_sdk.go @@ -99,8 +99,10 @@ func discoverClaudeSDK(rc *runContext, profile string, check func(context.Contex out.Info.Available, out.Info.Version = true, info.SDK out.Info.Capabilities.MCPHTTPTools = info.SupportsHTTPMCP() out.Info.Capabilities.MCPHTTPBearerAuth = info.SupportsHTTPMCPBearer() + out.Info.Capabilities.MCPHTTPRequired = info.SupportsHTTPMCPRequired() if out.Config.Workspace != nil { out.Info.Capabilities.MCPHTTPTools, out.Info.Capabilities.MCPHTTPBearerAuth = false, false + out.Info.Capabilities.MCPHTTPRequired = false } fmt.Fprintf(rc.stdout, "Claude SDK preflight ok (SDK %s, %s)\n", info.SDK, info.Native) return out diff --git a/apps/parsar-daemon/internal/cli/claude_sdk_test.go b/apps/parsar-daemon/internal/cli/claude_sdk_test.go index ec866a40..b238280f 100644 --- a/apps/parsar-daemon/internal/cli/claude_sdk_test.go +++ b/apps/parsar-daemon/internal/cli/claude_sdk_test.go @@ -5,6 +5,7 @@ import ( "errors" "os" "path/filepath" + "slices" "strings" "testing" @@ -132,13 +133,13 @@ func TestClaudeSDKMCPFeatureDiscovery(t *testing.T) { t.Fatal(err) } t.Setenv(claudeSDKNodeEnv, node) - for _, features := range [][]string{nil, {"mcp_http_tools"}, {"mcp_http_bearer_auth"}, {"mcp_http_tools", "mcp_http_bearer_auth"}} { + for _, features := range [][]string{nil, {"mcp_http_tools"}, {"mcp_http_bearer_auth"}, {"mcp_http_tools", "mcp_http_bearer_auth"}, {"mcp_http_required"}, {"mcp_http_tools", "mcp_http_required"}} { out := discoverClaudeSDK(&runContext{stdout: &strings.Builder{}, stderr: &strings.Builder{}}, "default", func(context.Context, claudesdk.Config) (claudesdk.RuntimeInfo, error) { info := claudesdk.RuntimeInfo{SDK: "0.3.269", Native: "2.1.269 (Claude Code)", Features: features} return info, nil }) supported := len(features) > 0 && features[0] == "mcp_http_tools" - if out == nil || !out.Info.Available || out.Info.Capabilities.MCPHTTPTools != supported || out.Info.Capabilities.MCPHTTPBearerAuth != (len(features) == 2) || out.Info.Capabilities.MCPHTTPRequired { + if out == nil || !out.Info.Available || out.Info.Capabilities.MCPHTTPTools != supported || out.Info.Capabilities.MCPHTTPBearerAuth != (supported && slices.Contains(features, "mcp_http_bearer_auth")) || out.Info.Capabilities.MCPHTTPRequired != (supported && slices.Contains(features, "mcp_http_required")) { t.Fatal("MCP feature discovery widened the runtime profile") } } diff --git a/contracts/agents-api/harnesses.md b/contracts/agents-api/harnesses.md index c393e3cd..48131cf2 100644 --- a/contracts/agents-api/harnesses.md +++ b/contracts/agents-api/harnesses.md @@ -60,7 +60,7 @@ syntactically or everything either upstream harness can theoretically perform. | Public functions in `none` | Qualified | Qualified; object-root schemas and text results | | Public functions alongside hosted workspace tools | Qualified | Qualified; object-root schemas and text results | | HTTP MCP and static-bearer Vault credentials in `none` | Qualified | Qualified subset | -| Required MCP initialization | Qualified | Gap; currently rejected | +| Required MCP initialization | Qualified | Qualified on `none`; native readiness before initial input | | Hosted HTTP MCP | Gap | Gap | | Function image results | Supported subset | Gap; currently rejected | | Non-default verbosity | Native/model-dependent support | No equivalent qualified; medium only | diff --git a/contracts/agents-api/openapi.yaml b/contracts/agents-api/openapi.yaml index f55fe9b1..511e8b6e 100644 --- a/contracts/agents-api/openapi.yaml +++ b/contracts/agents-api/openapi.yaml @@ -1912,31 +1912,33 @@ paths: Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous - or attached static-bearer service-origin HTTP MCP on none with required=false - and separately advertised MCP/bearer runtime support. The shared Vault selection - and immutable binding rules apply; unsupported native labels/tool names reject - before persistence. An attached Vault with no matching credential may remain - anonymous; missing keys or failed credential lookup/decryption never fall - back to anonymous execution. Omitted stream defaults to false; stream and - agent_id cannot be null. Metadata may be null, but its values must be strings. - Initial input accepts a string or user-message array containing text. None - initial input atomically starts a Turn; self_hosted initial input is reserved - while returning its Environment connection target, with execution deferred - to native readiness and Session failure on initial timeout. Omitted or null - input creates an idle Session. With stream=true, returns live Session events - starting at creation; disconnect does not cancel execution. New Sessions retain - their authenticated creator; all creation retries require the same typed subject, - including across key rotation. Saved-Agent retries and inline requests using - Vault attachments or credential references retain caller intent independently - of later resource changes; unrelated inline retries preserve resolved/default - equivalences. Unknown historical creators reject retries; known creators without - recorded intent retain resolved-snapshot retry rules. These conflict policies - are local and not verified hosted parity. Creation retries observe future - events without replay; retry with stream=false to retrieve the Session. Non-text - initial input remains unsupported. Basic Codex and Claude SDK openai_hosted - creation requires an explicitly configured managed provider. The Claude workspace - profile supports non-deferred function tools with text results alongside native - workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; + or attached static-bearer service-origin HTTP MCP on none with boolean required + and separately advertised MCP/bearer/required runtime support. Required servers + must be connected before the first native input is released; pending or failed + startup rejects execution. The shared Vault selection and immutable binding + rules apply; unsupported native labels/tool names reject before persistence. + An attached Vault with no matching credential may remain anonymous; missing + keys or failed credential lookup/decryption never fall back to anonymous execution. + Omitted stream defaults to false; stream and agent_id cannot be null. Metadata + may be null, but its values must be strings. Initial input accepts a string + or user-message array containing text. None initial input atomically starts + a Turn; self_hosted initial input is reserved while returning its Environment + connection target, with execution deferred to native readiness and Session + failure on initial timeout. Omitted or null input creates an idle Session. + With stream=true, returns live Session events starting at creation; disconnect + does not cancel execution. New Sessions retain their authenticated creator; + all creation retries require the same typed subject, including across key + rotation. Saved-Agent retries and inline requests using Vault attachments + or credential references retain caller intent independently of later resource + changes; unrelated inline retries preserve resolved/default equivalences. + Unknown historical creators reject retries; known creators without recorded + intent retain resolved-snapshot retry rules. These conflict policies are local + and not verified hosted parity. Creation retries observe future events without + replay; retry with stream=false to retrieve the Session. Non-text initial + input remains unsupported. Basic Codex and Claude SDK openai_hosted creation + requires an explicitly configured managed provider. The Claude workspace profile + supports non-deferred function tools with text results alongside native workspace + tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled is also supported, while restricted domains and populated startup installations are rejected. diff --git a/packages/claude-sdk-adapter/src/adapter.ts b/packages/claude-sdk-adapter/src/adapter.ts index 5c59507e..59fd12a2 100644 --- a/packages/claude-sdk-adapter/src/adapter.ts +++ b/packages/claude-sdk-adapter/src/adapter.ts @@ -11,7 +11,7 @@ import { MCPProfile } from "./mcp.js"; import { MCPObserver, type MCPEvent } from "./mcp_observer.js"; import { CommandObserver, type CommandEvent } from "./command_observer.js"; import { WorkspaceProfile } from "./workspace.js"; -import type { Prepare, Start } from "./request.js"; +import { immediatePrompt, type Prepare, type Start } from "./request.js"; import { recoverSession } from "./recovery.js"; export { parseStart, type Start } from "./request.js"; @@ -29,7 +29,7 @@ export type Event = | { type: "result"; session_id: string; text: string } | { type: "error"; code: "invalid_request" | "history_unavailable" | "execution_failed" | "cancelled" }; -export async function execute(request: Start | Prepare, emit: (event: Event) => Promise, abort: AbortController, functions = new FunctionBridge(emit), inputs = new Inputs(request.type === "start" ? request.prompt : undefined), reads = new WorkspaceReads(emit, abort), directories = new WorkspaceDirectories(emit, abort)): Promise { +export async function execute(request: Start | Prepare, emit: (event: Event) => Promise, abort: AbortController, functions = new FunctionBridge(emit), inputs = new Inputs(immediatePrompt(request)), reads = new WorkspaceReads(emit, abort), directories = new WorkspaceDirectories(emit, abort)): Promise { const definitions = (request.functions ?? []).map(tool => ({ name: tool.name, description: tool.description, inputSchema: tool.parameters })); const names = definitions.map(tool => `mcp__functions__${tool.name}`); const workspace = request.workspace === undefined ? undefined : new WorkspaceProfile(request.cwd, request.workspace, names); @@ -101,6 +101,15 @@ export async function execute(request: Start | Prepare, emit: (event: Event) => reads.bind(stream, request.cwd); if (process.platform === "linux") await directories.bind(request.cwd); await emit({ type: "prepared" }); + } else if (profile) { + if (inputs.hasInput) throw new Error("MCP input released before initialization"); + warm = await startup({ options, initializeTimeoutMs: 15000 }); + stream = warm.query(inputs); + const initialized = await stream.initializationResult(); + if (initialized.hooks_applied !== true || children.length !== 1) throw new Error("MCP initialization unavailable"); + if (request.mcp_http_servers?.some(server => server.required)) profile.verifyRequired(await stream.mcpServerStatus()); + if (abort.signal.aborted || !nativeAlive) throw new Error("MCP initialization interrupted"); + inputs.release(request.prompt); } else stream = query({ prompt: inputs, options }); for await (const message of stream) { await functions.consume(message, nativeID); diff --git a/packages/claude-sdk-adapter/src/main.ts b/packages/claude-sdk-adapter/src/main.ts index 361e5b78..21b0e69f 100644 --- a/packages/claude-sdk-adapter/src/main.ts +++ b/packages/claude-sdk-adapter/src/main.ts @@ -4,7 +4,7 @@ import { Inputs } from "./inputs.js"; import { FunctionBridge } from "./function_bridge.js"; import { createInterface } from "node:readline"; import { execute, type Event } from "./adapter.js"; -import { parseRequest, preparedPrompt } from "./request.js"; +import { immediatePrompt, parseRequest, preparedPrompt } from "./request.js"; const abort = new AbortController(); const lines = createInterface({ input: process.stdin, crlfDelay: Infinity }); @@ -29,7 +29,7 @@ try { const functions = new FunctionBridge(output); const reads = new WorkspaceReads(output, abort); const directories = new WorkspaceDirectories(output, abort); - const prompts = new Inputs(request.type === "start" ? request.prompt : undefined); + const prompts = new Inputs(immediatePrompt(request)); const incoming = (async () => { try { for await (const line of { [Symbol.asyncIterator]: () => input }) { diff --git a/packages/claude-sdk-adapter/src/mcp.ts b/packages/claude-sdk-adapter/src/mcp.ts index 85cc9749..b7ddc3e1 100644 --- a/packages/claude-sdk-adapter/src/mcp.ts +++ b/packages/claude-sdk-adapter/src/mcp.ts @@ -4,6 +4,7 @@ export type HTTPServer = { server_label: string; server_url: string; allowed_tools: string[] | null; + required?: boolean; bearer_token_env_var?: string; }; export type ToolIdentity = { server: string; name: string }; @@ -21,7 +22,8 @@ export function parseHTTPServers(value: unknown): HTTPServer[] | undefined { const references = new Set(); for (const server of value) { if (!server || typeof server !== "object" || - Object.keys(server).some(key => !["server_label", "server_url", "allowed_tools", "bearer_token_env_var"].includes(key)) || + Object.keys(server).some(key => !["server_label", "server_url", "allowed_tools", "bearer_token_env_var", "required"].includes(key)) || + (server.required !== undefined && typeof server.required !== "boolean") || typeof server.server_label !== "string" || !/^[a-zA-Z0-9_-]+$/.test(server.server_label) || server.server_label === "functions" || labels.has(server.server_label) || typeof server.server_url !== "string" || @@ -124,5 +126,14 @@ export class MCPProfile { this.release(true); } + verifyRequired(statuses: McpServerStatus[]): void { + for (const server of this.declarations.filter(server => server.required)) { + const matches = statuses.filter(status => status.name === server.server_label); + if (matches.length !== 1 || matches[0].status !== "connected") { + throw new Error("required native MCP server unavailable"); + } + } + } + close(): void { this.admitted = false; this.release(false); } } diff --git a/packages/claude-sdk-adapter/src/request.ts b/packages/claude-sdk-adapter/src/request.ts index 90bf8469..655c96e1 100644 --- a/packages/claude-sdk-adapter/src/request.ts +++ b/packages/claude-sdk-adapter/src/request.ts @@ -18,6 +18,11 @@ export type Start = { }; export type Prepare = Omit & { type: "prepare"; workspace: Workspace }; +// MCP startup confirms its hooks before the native input iterator yields. +export function immediatePrompt(request: Start | Prepare): string | undefined { + return request.type === "start" && request.mcp_http_servers === undefined ? request.prompt : undefined; +} + export function parseRequest(line: string): Start | Prepare { const value: unknown = JSON.parse(line); if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid_request"); diff --git a/packages/claude-sdk-adapter/src/runtime_check.ts b/packages/claude-sdk-adapter/src/runtime_check.ts index 39274768..8b819e54 100644 --- a/packages/claude-sdk-adapter/src/runtime_check.ts +++ b/packages/claude-sdk-adapter/src/runtime_check.ts @@ -45,7 +45,7 @@ try { assert.equal(smoke.error, undefined, "bridge_unavailable"); assert.equal(smoke.status, 0, "bridge_unavailable"); assert.deepEqual(JSON.parse(smoke.stdout), { type: "error", code: "invalid_request" }); - process.stdout.write(JSON.stringify({ type: "runtime_ready", protocol: 1, features: [...(process.platform === "linux" ? ["workspace_directory", "local_runtime_v1", "workspace_functions"] : []), "mcp_http_tools", "mcp_http_bearer_auth", "workspace_tools", "workspace_prepare", "workspace_read", "workspace_command_observations"], node: process.versions.node, sdk: sdk.version, mcp: mcp.version, native: nativeVersion }) + "\n"); + process.stdout.write(JSON.stringify({ type: "runtime_ready", protocol: 1, features: [...(process.platform === "linux" ? ["workspace_directory", "local_runtime_v1", "workspace_functions"] : []), "mcp_http_tools", "mcp_http_bearer_auth", "mcp_http_required", "workspace_tools", "workspace_prepare", "workspace_read", "workspace_command_observations"], node: process.versions.node, sdk: sdk.version, mcp: mcp.version, native: nativeVersion }) + "\n"); } catch { // Native diagnostics can include operator environment; never forward them. process.stdout.write(JSON.stringify({ type: "runtime_unavailable" }) + "\n"); diff --git a/packages/claude-sdk-adapter/tests/mcp.test.mjs b/packages/claude-sdk-adapter/tests/mcp.test.mjs index ce8f9448..279b189d 100644 --- a/packages/claude-sdk-adapter/tests/mcp.test.mjs +++ b/packages/claude-sdk-adapter/tests/mcp.test.mjs @@ -30,7 +30,7 @@ test("invalid remote declarations and wildcard injection fail at the bridge boun for (const value of [null, {}, [declaration(["*"])], [{ ...declaration(null), server_label: "functions" }], [{ ...declaration(null), server_url: "https://user:secret@example.invalid/mcp" }], [{ ...declaration(null), server_url: "https://example.invalid/mcp?" }], - [{ ...declaration(null), required: true }], [declaration(null), declaration([])]]) { + [{ ...declaration(null), required: "true" }], [{ ...declaration(null), required: null }], [declaration(null), declaration([])]]) { assert.throws(() => parseStart(JSON.stringify({ ...start, mcp_http_servers: value }))); } assert.deepEqual(parseStart(JSON.stringify({ ...start, mcp_http_servers: [declaration(null), { ...declaration([]), server_label: "empty" }] })).mcp_http_servers, diff --git a/packages/claude-sdk-adapter/tests/mcp_required.test.mjs b/packages/claude-sdk-adapter/tests/mcp_required.test.mjs new file mode 100644 index 00000000..8bf2842d --- /dev/null +++ b/packages/claude-sdk-adapter/tests/mcp_required.test.mjs @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +// Run the production entrypoint: its explicitly supplied Inputs must also be held. +const fixture = ` +import assert from "node:assert/strict"; +import { registerHooks } from "node:module"; +const mode=process.argv[1]; +const sdk='export function startup(args){return globalThis.startup(args);} export function query(args){return globalThis.direct(args);} export async function getSessionInfo(){return process.argv[1]==="missing-history"?undefined:{sessionId:"native"};}'; +registerHooks({resolve(s,c,next){return s==="@anthropic-ai/claude-agent-sdk"?{url:"data:text/javascript,"+encodeURIComponent(sdk),shortCircuit:true}:next(s,c);}}); +function create(options){ + assert.deepEqual(options.tools,[]);assert.equal(options.strictMcpConfig,true);assert.equal(options.hooks.PreToolUse.length,1); + assert.equal(options.mcpServers.fixture.alwaysLoad,true); + const child=options.spawnClaudeCodeProcess({command:process.execPath,args:["-e","process.stdin.resume();process.stdin.on('end',()=>process.exit(0));"],env:options.env,signal:options.abortController.signal}); + process.send({kind:"spawn"}); + let iterator,pending,readiness=false; + const status=()=>[{name:"fixture",status:"connected",tools:[{name:"echo"}]},{name:"optional",status:"connected",tools:[]}]; + return {close(){child.stdin.end();},query(inputs){ + iterator=inputs[Symbol.asyncIterator]();pending=iterator.next(); + let yielded=false;pending.then(v=>{if(!v.done)yielded=true;}); + return {close(){child.stdin.end();},async initializationResult(){await new Promise(r=>setTimeout(r,20));assert.equal(yielded,false);return {hooks_applied:mode!=="missing-hooks"};}, + async mcpServerStatus(){ + if(!readiness && mode!=="optional"){ + await new Promise(r=>setTimeout(r,30));assert.equal(yielded,false);process.send({kind:"checked_before_input"}); + if(mode==="cancelled")options.abortController.abort(); + readiness=true; + if(mode==="missing")return []; + if(mode==="duplicate")return [...status(),status()[0]]; + if(mode==="pending"||mode==="failed")return [{name:"fixture",status:mode}]; + } + return status(); + }, + async *[Symbol.asyncIterator](){ + const first=await pending;if(first.done)return; + assert.ok(readiness||mode==="optional");process.send({kind:"input",text:first.value.message.content}); + yield {type:"system",subtype:"init",session_id:mode==="wrong-history"?"foreign":"native",tools:["mcp__fixture__echo"],mcp_servers:[]}; + yield {type:"result",uuid:"result",session_id:"native",user_message_uuids:[first.value.uuid],subtype:"success",is_error:false,result:"done",usage:{input_tokens:1,output_tokens:1},modelUsage:{}}; + } + }; + }}; +} +globalThis.startup=async({options})=>{process.send({kind:"warm"});return create(options);}; +globalThis.direct=()=>{throw new Error("MCP must use the shared startup path");}; +await import(${JSON.stringify(new URL("../dist/main.js", import.meta.url).href)}); +process.disconnect(); +`; + +for (const mode of ["connected", "pending", "failed", "missing", "duplicate", "missing-hooks", "cancelled", "resume", "wrong-history", "missing-history", "optional"]) { + test(`required MCP entrypoint holds input through readiness: ${mode}`, async () => { + const cwd = mkdtempSync(join(tmpdir(), "parsar-required-")); + const child = spawn(process.execPath, ["--input-type=module", "-e", fixture, mode], { stdio: ["pipe", "pipe", "pipe", "ipc"] }); + const observations = []; + let stdout = "", stderr = ""; + child.stdout.on("data", b => { stdout += b; }); + child.stderr.on("data", b => { stderr += b; }); + child.on("message", value => observations.push(value)); + const closed = new Promise(resolve => child.once("close", (code, signal) => resolve({ code, signal }))); + const timer = setTimeout(() => child.kill("SIGKILL"), 8000); + try { + child.stdin.write(JSON.stringify({ type: "start", model: "fixed", prompt: "one input", system_prompt: "", cwd, + ...(mode.includes("history") || mode === "resume" ? { resume: "native" } : {}), + mcp_http_servers: [{ server_label: "fixture", server_url: "https://example.invalid/mcp", allowed_tools: ["echo"], required: mode !== "optional" }, + { server_label: "optional", server_url: "https://optional.invalid/mcp", allowed_tools: [], required: false }] }) + "\n"); + const exit = await closed; + assert.equal(exit.signal, null, stderr); + assert.equal(exit.code, 0, stderr); + const events = stdout.trim().split("\n").map(JSON.parse); + const success = ["connected", "resume", "optional"].includes(mode); + assert.equal(events.at(-1).type, success ? "result" : "error"); + assert.equal(observations.filter(v => v.kind === "input").length, success || mode === "wrong-history" ? 1 : 0); + assert.equal(events.some(e => e.type === "prepared"), false); + assert.equal(events.some(e => e.type === "input_ready"), success); + if (mode === "missing-history") assert.equal(observations.length, 0); + if (mode === "cancelled") assert.equal(events.at(-1).code, "cancelled"); + } finally { + clearTimeout(timer);child.kill("SIGKILL");await closed;rmSync(cwd, { recursive: true, force: true }); + } + }); +} diff --git a/scripts/check-claude-sdk-runtime.mjs b/scripts/check-claude-sdk-runtime.mjs index a0db6e2f..4f480363 100644 --- a/scripts/check-claude-sdk-runtime.mjs +++ b/scripts/check-claude-sdk-runtime.mjs @@ -23,7 +23,7 @@ assert.equal(probe.status, 0, "Exported runtime is unavailable"); const report = JSON.parse(probe.stdout); assert.equal(report.type, "runtime_ready"); assert.equal(report.protocol, 1); -assert.deepEqual(report.features, [...(process.platform === "linux" ? ["workspace_directory", "local_runtime_v1", "workspace_functions"] : []), "mcp_http_tools", "mcp_http_bearer_auth", "workspace_tools", "workspace_prepare", "workspace_read", "workspace_command_observations"]); +assert.deepEqual(report.features, [...(process.platform === "linux" ? ["workspace_directory", "local_runtime_v1", "workspace_functions"] : []), "mcp_http_tools", "mcp_http_bearer_auth", "mcp_http_required", "workspace_tools", "workspace_prepare", "workspace_read", "workspace_command_observations"]); assert.equal(report.sdk, source.dependencies["@anthropic-ai/claude-agent-sdk"]); assert.equal(report.mcp, source.dependencies["@modelcontextprotocol/sdk"]); console.log(`Verified exported SDK ${report.sdk}, MCP ${report.mcp}, ${report.native}`); diff --git a/services/agents-api/internal/api/claude_admission_test.go b/services/agents-api/internal/api/claude_admission_test.go index cfe0d0f8..a04be6dd 100644 --- a/services/agents-api/internal/api/claude_admission_test.go +++ b/services/agents-api/internal/api/claude_admission_test.go @@ -30,7 +30,7 @@ func TestClaudeSessionConfigurationAdmission(t *testing.T) { {"MCP null", `,"tools":[` + strings.TrimSuffix(publicMCP, "}") + `,"allowed_tools":null}]`, true}, {"MCP empty", `,"tools":[` + strings.TrimSuffix(publicMCP, "}") + `,"allowed_tools":[]}]`, true}, {"MCP selected", `,"tools":[` + strings.TrimSuffix(publicMCP, "}") + `,"allowed_tools":["lookup.v1"]}]`, true}, - {"MCP required", `,"tools":[` + strings.TrimSuffix(publicMCP, "}") + `,"required":true}]`, false}, + {"MCP required", `,"tools":[` + strings.TrimSuffix(publicMCP, "}") + `,"required":true}]`, true}, {"MCP reserved label", `,"tools":[` + strings.Replace(publicMCP, `"records"`, `"functions"`, 1) + `]`, false}, {"MCP invalid label", `,"tools":[` + strings.Replace(publicMCP, `"records"`, `"records.v1"`, 1) + `]`, false}, {"MCP wildcard name", `,"tools":[` + strings.TrimSuffix(publicMCP, "}") + `,"allowed_tools":["*"]}]`, false}, diff --git a/services/agents-api/internal/api/handler.go b/services/agents-api/internal/api/handler.go index 6d4dc415..fb160a01 100644 --- a/services/agents-api/internal/api/handler.go +++ b/services/agents-api/internal/api/handler.go @@ -111,7 +111,7 @@ func NewHandler(s ResourceStore, auth *Authenticator, engine string, options ... // createSession atomically reserves or admits initial text with the Session. // @Summary Create an execution Session -// @Description Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, disabled multi_agent, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with explicit service origin, native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Ambiguous selection rejects creation. Frozen private selections never populate an omitted public credential_id; missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and OAuth remain unsupported. The self_hosted profile requires Codex, an absolute workspace_directory and empty capability_directories, with optional non-deferred function tools and HTTP MCP using explicit service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with required=false and separately advertised MCP/bearer runtime support. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null, but its values must be strings. Initial input accepts a string or user-message array containing text. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Omitted or null input creates an idle Session. With stream=true, returns live Session events starting at creation; disconnect does not cancel execution. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; unrelated inline retries preserve resolved/default equivalences. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. Creation retries observe future events without replay; retry with stream=false to retrieve the Session. Non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled is also supported, while restricted domains and populated startup installations are rejected. +// @Description Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, disabled multi_agent, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with explicit service origin, native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Ambiguous selection rejects creation. Frozen private selections never populate an omitted public credential_id; missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and OAuth remain unsupported. The self_hosted profile requires Codex, an absolute workspace_directory and empty capability_directories, with optional non-deferred function tools and HTTP MCP using explicit service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null, but its values must be strings. Initial input accepts a string or user-message array containing text. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Omitted or null input creates an idle Session. With stream=true, returns live Session events starting at creation; disconnect does not cancel execution. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; unrelated inline retries preserve resolved/default equivalences. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. Creation retries observe future events without replay; retry with stream=false to retrieve the Session. Non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled is also supported, while restricted domains and populated startup installations are rejected. // @Tags Sessions // @Accept json // @Produce json,text/event-stream diff --git a/services/agents-api/internal/engine/claude_mcp.go b/services/agents-api/internal/engine/claude_mcp.go index 52db7c8b..a65125a5 100644 --- a/services/agents-api/internal/engine/claude_mcp.go +++ b/services/agents-api/internal/engine/claude_mcp.go @@ -15,7 +15,7 @@ var claudeMCPTool = regexp.MustCompile(`^[a-zA-Z0-9_.-]+$`) // Shared resolution still owns URL, transport and credential-binding validation. func validateClaudeMCP(servers []proto.MCPHTTPServer) error { for _, server := range servers { - if !claudeMCPLabel.MatchString(server.ServerLabel) || server.ServerLabel == "functions" || server.Required || strings.ContainsAny(server.ServerURL, "?#") { + if !claudeMCPLabel.MatchString(server.ServerLabel) || server.ServerLabel == "functions" || strings.ContainsAny(server.ServerURL, "?#") { return errors.New("The configured engine does not support this HTTP MCP declaration.") } if server.AllowedTools != nil { diff --git a/services/agents-api/internal/store/claude_mcp_test.go b/services/agents-api/internal/store/claude_mcp_test.go index cd923341..87a7bbc7 100644 --- a/services/agents-api/internal/store/claude_mcp_test.go +++ b/services/agents-api/internal/store/claude_mcp_test.go @@ -14,22 +14,26 @@ import ( ) func TestClaudeMCPWaitsForCapableRuntime(t *testing.T) { - for _, authenticated := range []bool{false, true} { + for _, requirement := range []string{"anonymous", "bearer", "required"} { + authenticated, required := requirement == "bearer", requirement == "required" for _, prebound := range []bool{false, true} { - t.Run(fmt.Sprintf("bearer=%t/bound=%t", authenticated, prebound), func(t *testing.T) { + t.Run(fmt.Sprintf("%s/bound=%t", requirement, prebound), func(t *testing.T) { h := newFunctionHarness(t) configuration, token := mcpWorkerConfiguration, "" if authenticated { configuration, token = mcpBearerWorkerConfiguration(t, h) } + if required { + configuration = strings.Replace(configuration, `"required":false`, `"required":true`, 1) + } claudeSession(t, h, configuration, prebound) - // An older bundle may support anonymous MCP but not bearer authentication. - caps := proto.AgentKindCapabilities{Streaming: true, Steering: true, DurableTurns: true, DurableInputReceipts: true, ExecutionControls: true, EnvironmentNone: true, SubagentControl: true, ToolObservations: true, MCPHTTPTools: authenticated} + // Base MCP support does not imply authentication or required initialization. + caps := proto.AgentKindCapabilities{Streaming: true, Steering: true, DurableTurns: true, DurableInputReceipts: true, ExecutionControls: true, EnvironmentNone: true, SubagentControl: true, ToolObservations: true, MCPHTTPTools: authenticated || required} h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "claude_sdk", Available: true, Capabilities: caps}}}) awaitDaemonRemoteCondition(t, t.Context(), 3*time.Second, "Claude MCP heartbeat", func() bool { peer, _ := h.registry.LookupDevice(h.device.ID) info, found, known := peer.AgentKindStatus("claude_sdk") - return known && found && info.Capabilities.MCPHTTPTools == authenticated && !info.Capabilities.MCPHTTPBearerAuth + return known && found && info.Capabilities.MCPHTTPTools == (authenticated || required) && !info.Capabilities.MCPHTTPBearerAuth && !info.Capabilities.MCPHTTPRequired }) input := h.message("start", "Use declared tools only") if prebound { @@ -64,13 +68,14 @@ func TestClaudeMCPWaitsForCapableRuntime(t *testing.T) { } } caps.MCPHTTPTools, caps.MCPHTTPBearerAuth = true, authenticated + caps.MCPHTTPRequired = required h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "claude_sdk", Available: true, Capabilities: caps}}}) var prompt proto.PromptRequestPayload if h.read(proto.TypePromptRequest).DecodePayload(&prompt) != nil || prompt.AgentKind != "claude_sdk" || prompt.MCPHTTPServers == nil || len(*prompt.MCPHTTPServers) != 1 { t.Fatal("missing typed MCP dispatch") } server := (*prompt.MCPHTTPServers)[0] - if server.ServerLabel != "tickets" || server.AllowedTools == nil || len(*server.AllowedTools) != 0 || server.Required || !prompt.DisableExecutionEnvironment || !prompt.DisableSubagents { + if server.ServerLabel != "tickets" || server.AllowedTools == nil || len(*server.AllowedTools) != 0 || server.Required != required || !prompt.DisableExecutionEnvironment || !prompt.DisableSubagents { t.Fatal("MCP configuration changed during dispatch") } endpoint := "http://127.0.0.1:9191/mcp" @@ -88,18 +93,21 @@ func TestClaudeMCPWaitsForCapableRuntime(t *testing.T) { } func TestClaudeMCPUnsupportedSnapshotRejectedBeforeClaim(t *testing.T) { - for _, profile := range []string{"required", "reserved label"} { + for _, profile := range []string{"missing required capability", "reserved label"} { t.Run(profile, func(t *testing.T) { h := newDispatchHarness(t) configuration := mcpWorkerConfiguration switch profile { - case "required": + case "missing required capability": configuration = strings.Replace(configuration, `"required":false`, `"required":true`, 1) case "reserved label": configuration = strings.Replace(configuration, `"tickets"`, `"functions"`, 1) } claudeSession(t, h, configuration, true) caps := proto.AgentKindCapabilities{Streaming: true, Steering: true, DurableTurns: true, DurableInputReceipts: true, ExecutionControls: true, EnvironmentNone: true, SubagentControl: true, ToolObservations: true, MCPHTTPTools: true, MCPHTTPRequired: true, MCPHTTPBearerAuth: true} + if profile == "missing required capability" { + caps.MCPHTTPRequired = false + } h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "claude_sdk", Available: true, Capabilities: caps}}}) deadline := time.Now().Add(3 * time.Second) for {