diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ce8974c85..44fc210e7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -211,7 +211,9 @@ The Runtime is not a sandbox; see in the common protocol or flow, not with Harness-, Runtime- or vendor-specific branches in Core. Adapters may differ natively but keep shared semantics. - Express compatibility through declared capabilities and validate selected - combinations explicitly. Replaceability does not mean every model, Harness and + combinations explicitly. Public MCP origin and credential authority follow the + [Environment MCP contract](contracts/agents-api/environments.md#public-mcp-connection-origin); + changing an input source must not change outbound network or credential scope. Replaceability does not mean every model, Harness and Environment combination is supported. Never silently substitute another implementation or give a capability different meanings per vendor. - Core preparation and execution never branch on operating system or diff --git a/apps/docs/content/docs/agents-and-tools.mdx b/apps/docs/content/docs/agents-and-tools.mdx index 0cd017cd3..6739c038e 100644 --- a/apps/docs/content/docs/agents-and-tools.mdx +++ b/apps/docs/content/docs/agents-and-tools.mdx @@ -31,7 +31,8 @@ and Environment Plugin MCP have separate inventories and qualification. | Function image results | Successful ordered inline PNG/JPEG with text, large PNG and image-only JPEG: Codex/Claude `none` F1/F2; Docker M2. Public Items retain submitted bytes. Codex receipt regression: F2. | Claude rejects failed images and remote references before persistence; native resizing may change its image bytes. Self-hosted Claude image results use the same native path; see the current [qualification record](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-capabilities-qualification.md). MiniMax functions remain unqualified. Other Codex image/error/reference combinations cannot be inferred from successful-inline evidence. | | Deferred function discovery | Type-only `tool_search` plus mixed eager/deferred functions: Claude SDK 0.3.269/native 2.1.269, Kimi K3, single Agent, medium, `none`, text results; text and PNG input D1. Native provider observations establish lazy schema loading for D1. [Self-hosted workspace callback, continuation and cancellation](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-capabilities-qualification.md) use the same native path; they do not add model-request observer evidence. [Contract](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/tool-search.md). | A repeated `tool_search` is a protocol error. Codex/MiniMax discovery, search-only/missing-search, workspace with Skills/Plugins, MCP, structured-output and Subagent combinations remain gaps. Opaque native policy changes lack a reliable pre-input deferral signal. Saved tools include `tool_search`; the pinned Session response union excludes it. Exact hosted projection is unverified. | | Explicit disabled search/PTC | Saved and inline `web_search.mode=disabled` and `programmatic_tool_calling.enabled=false`; shared native controls on initial and cold execution. All three harnesses on `none`: P1, including native inventory/control evidence. [Contract](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/tool-policy.md), [parser](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/internal/api/disabled_tools.go). | Unsupported explicit enablement rejects at Session admission; saved Agents keep every pinned search mode as resource data (TV-05), and Sessions from such Agents reject unless they replace the tools. A repeated `web_search` is a protocol error. Omission retains approved native behavior, which does not establish official default-on PTC parity. Enabled search and default/error parity remain gaps; unrelated native utilities are not implicitly removed. | -| Agent service-origin MCP | Implemented Codex/Claude HTTP `none` profiles, anonymous/static bearer, scoped Vault selection and native `mcp_call` Items. [Configuration and qualification limits](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/README.md#http-mcp-execution), [profiles](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/internal/engine/profile.go). | This closure batch does not requalify MCP/provider combinations. MiniMax, self-hosted/hosted service-origin MCP, OAuth, nonempty inline headers/metadata and other transports/origins reject. Claude requires a static connected inventory; original MCP-envelope fidelity and continuing server health are unverified. | +| Agent service-origin MCP | Implemented Codex/Claude HTTP `none` profiles, anonymous/static bearer, scoped Vault selection and native `mcp_call` Items. [Configuration and qualification limits](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/README.md#http-mcp-execution), [profiles](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/internal/engine/profile.go). | This closure batch does not requalify MCP/provider combinations. MiniMax, self-hosted/hosted service-origin MCP, OAuth, nonempty inline headers/metadata and other transports reject. Environment origin follows the separate row below. Claude requires a static connected inventory; original MCP-envelope fidelity and continuing server health are unverified. | +| Agent Environment-origin MCP | Public HTTP declarations reuse installed MCP's effective Runtime bindings; attached Vault selection and native observations remain common. [Origin and Harness matrix](/environments-and-files#public-mcp-connection-origin), [real qualification](https://github.com/MiniMax-AI/parsar-core/blob/e974a7f880a2eb799f0dd39e6ba0870462854a53/contracts/agents-api/public-mcp-qualification.md). | Requires a workspace and enabled network. MiniMax rejects every non-null allowlist and required initialization. No service-origin relocation, automatic fallback or credential copy into native profiles. | | Environment Plugin MCP/native tools | Separate [Docker Plugin transport matrix](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/environment-templates.md#environment-origin-mcp-plugins) and [V1 deployment evidence](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/contracts/agents-api/user-managed-runtime-v1.md). Native tools stay within the existing colocated Runtime. | Plugin MCP is not Agent service-origin MCP or a public function action. No new optional cross-product is qualified here. Native utility inventories need not be identical. | | Required action: `function_call` | Session GET/list and Session SSE expose persisted `{arguments,call_id,name,turn_id,type}`. Actions remain pending until native application or cancellation/terminal settlement. [Projection](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/internal/store/function_state.go), [confirmation](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/internal/store/function_results.go); real handling F1/F2/M2/S1/S2/D1; explicit client disconnect/query/reconnect: F3. | Function-call history is not pending-state authority. Client reconnect does not replay events or redo external application effects. | | Required action: `environment_connection` | Offline waiting input exposes `{environment_id,type}` before Turn creation. Connect the exact Session Environment through our scoped daemon enrollment. Three-harness Docker/E2B execution: E1; explicit initial pending-input/query/connection/native completion: E2; expiry: [controlled initial-input test](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/services/agents-api/tests/official_self_hosted_initial.py). | Idle offline Sessions without pending input request nothing. Registration alone is not connectivity or native readiness. Stock `exec-server`/Noise transport is outside the approved V1 route. Exact upstream registration/action-removal timing remains unverified. | diff --git a/apps/docs/content/docs/api-reference/sessions.mdx b/apps/docs/content/docs/api-reference/sessions.mdx index 011a38b27..64a9dbeb0 100644 --- a/apps/docs/content/docs/api-reference/sessions.mdx +++ b/apps/docs/content/docs/api-reference/sessions.mdx @@ -50,106 +50,112 @@ _openapi: 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 - native OAuth login remain unsupported. The self_hosted profile uses a - qualified native harness, a clean absolute workspace_directory and - optional absolute local capability_directories prepared by Runtime, - with optional non-deferred function tools and HTTP MCP using 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; non-string values and limit violations return - invalid_request_error with a metadata or metadata. param. The - inline agent uses the Agent create configuration validation with - agent.-prefixed params, reported before the input requirement and - saved-Agent lookup; saved configurations with conflicting tools or - schema roots reject admission with the same errors, and execution - limits keep unsupported_or_invalid_configuration. Hosted network - policy rejections return invalid_request_error with a null param. - Initial input accepts a string or ordered user-message array. Codex - and Claude SDK on none and qualified managed or self_hosted workspace - profiles also accept inline PNG/JPEG image content; other image - combinations and remote URLs are unsupported. 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. Initial - input is required for none and for streamed creation outside - self_hosted. Omitted/null input remains valid for non-streaming hosted - and self_hosted creation. With stream=true, returns live Session - events starting with the committed creation snapshot and closes right - after the first agent.session.idle recorded when a Turn ends or an - input reservation stops being pending, or any agent.session.failed, - without sending later events. A creation that admitted nothing closes - after the snapshot; a settlement that records no event closes after - events up to the cursor read with a settled Session projection. - Required actions keep it open; disconnect does not cancel execution. - The GET events stream remains live-only. 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; new hosted - inline requests also freeze caller intent before deployment defaults - resolve; unrelated non-hosted inline retries preserve resolved/default - equivalences, and their resolved hash leaves out any deployment - default. Provider keys enter retry hashes only as fingerprints keyed - by the credential key. 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. A same-key stream=true retry of an existing creation returns - 201 with no events and closes at once; retry with stream=false or use - the GET events stream to recover. Claude SDK on none, Core-managed - Docker openai_hosted and self_hosted supports qualified object-root - json_schema output with medium verbosity, single-Agent execution and - ordinary functions. Hosted execution reuses native workspace tools and - Files/Artifacts; Skills, Plugins, capability directories, HTTP MCP, - Subagent and tool_search combinations remain unqualified, including - inherited template contents. Other 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 or - successful inline PNG/JPEG 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 and restricted policies reject before compute - allocation because the current Runtime cannot enforce them. The - x_agents_core.environment extension accepts common preparation fields - for either hosted or self-hosted placement: environment_template_id, - files, env, packages, setup_commands, skills, plugins and - capability_directories. Duplicate fields in environment and the - extension reject. Confidential env, npm/Python packages and ordered - setup commands use the same Environment-owned initialization - lifecycle; compute allocation does not own preparation. Unknown side - effects are not replayed after disconnect or restart. System - dependencies must be preinstalled in the sandbox image or template, or - on the host machine; packages.system is rejected. Initial inline and - tenant-owned file_id files freeze encrypted bytes before provisioning, - then install through the common Core lifecycle before native execution - or live Files access. With a template reference, omitted/null files, - env, packages and setup_commands inherit. Non-null files and command - lists replace; env overlays by key; each package manager inherits on - omission/null and otherwise replaces its list. Empty lists clear their - selected field. Tenant-owned environment_template_id references - inherit omitted/null network and allow only narrowing overrides. - Inline hosted network:null retains the enabled default; updating a - Template with network:null resets its saved policy to enabled. Core - freezes effective configuration; template updates/deletion do not - alter Session snapshots or same-intent creation retries. Inline or - tenant-owned skill_reference Skills share initialization. Templates - preserve default/latest/explicit selectors; Session creation freezes - concrete metadata and encrypted content atomically. Skill, Plugin and + timing and error parity remain unverified. Explicit environment-origin + HTTP MCP is supported on managed and self-hosted workspaces through + the same Runtime bindings; native OAuth login remains unsupported. The + self_hosted profile uses a qualified native harness, a clean absolute + workspace_directory and optional absolute local capability_directories + prepared by Runtime, with optional non-deferred function tools and + HTTP MCP using explicit environment origin, optionally authenticated + by the attached Vault rules. Service-origin HTTP remains restricted to + service-side environment:none. 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; + non-string values and limit violations return invalid_request_error + with a metadata or metadata. param. The inline agent uses the + Agent create configuration validation with agent.-prefixed params, + reported before the input requirement and saved-Agent lookup; saved + configurations with conflicting tools or schema roots reject admission + with the same errors, and execution limits keep + unsupported_or_invalid_configuration. Hosted network policy rejections + return invalid_request_error with a null param. Initial input accepts + a string or ordered user-message array. Codex and Claude SDK on none + and qualified managed or self_hosted workspace profiles also accept + inline PNG/JPEG image content; other image combinations and remote + URLs are unsupported. 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. Initial input is required for none + and for streamed creation outside self_hosted. Omitted/null input + remains valid for non-streaming hosted and self_hosted creation. With + stream=true, returns live Session events starting with the committed + creation snapshot and closes right after the first agent.session.idle + recorded when a Turn ends or an input reservation stops being pending, + or any agent.session.failed, without sending later events. A creation + that admitted nothing closes after the snapshot; a settlement that + records no event closes after events up to the cursor read with a + settled Session projection. Required actions keep it open; disconnect + does not cancel execution. The GET events stream remains live-only. + 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; new hosted inline requests also freeze caller intent + before deployment defaults resolve; unrelated non-hosted inline + retries preserve resolved/default equivalences, and their resolved + hash leaves out any deployment default. Provider keys enter retry + hashes only as fingerprints keyed by the credential key. 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. A same-key stream=true retry + of an existing creation returns 201 with no events and closes at once; + retry with stream=false or use the GET events stream to recover. + Claude SDK on none, Core-managed Docker openai_hosted and self_hosted + supports qualified object-root json_schema output with medium + verbosity, single-Agent execution and ordinary functions. Hosted + execution reuses native workspace tools and Files/Artifacts; Skills, + Plugins, capability directories, HTTP MCP, Subagent and tool_search + combinations remain unqualified, including inherited template + contents. Other 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 or successful inline PNG/JPEG + results alongside native workspace tools; explicit environment-origin + HTTP MCP uses the common Runtime path. MiniMax accepts public + environment-origin HTTP MCP only with null or omitted allowed_tools + and required=false; even an empty non-null allowlist rejects. Idle + Sessions provision automatically; initial provisioning has no caller + connection action. Network defaults to enabled; disabled and + restricted policies reject before compute allocation because the + current Runtime cannot enforce them. The x_agents_core.environment + extension accepts common preparation fields for either hosted or + self-hosted placement: environment_template_id, files, env, packages, + setup_commands, skills, plugins and capability_directories. Duplicate + fields in environment and the extension reject. Confidential env, + npm/Python packages and ordered setup commands use the same + Environment-owned initialization lifecycle; compute allocation does + not own preparation. Unknown side effects are not replayed after + disconnect or restart. System dependencies must be preinstalled in the + sandbox image or template, or on the host machine; packages.system is + rejected. Initial inline and tenant-owned file_id files freeze + encrypted bytes before provisioning, then install through the common + Core lifecycle before native execution or live Files access. With a + template reference, omitted/null files, env, packages and + setup_commands inherit. Non-null files and command lists replace; env + overlays by key; each package manager inherits on omission/null and + otherwise replaces its list. Empty lists clear their selected field. + Tenant-owned environment_template_id references inherit omitted/null + network and allow only narrowing overrides. Inline hosted network:null + retains the enabled default; updating a Template with network:null + resets its saved policy to enabled. Core freezes effective + configuration; template updates/deletion do not alter Session + snapshots or same-intent creation retries. Inline or tenant-owned + skill_reference Skills share initialization. Templates preserve + default/latest/explicit selectors; Session creation freezes concrete + metadata and encrypted content atomically. Skill, Plugin and capability-directory list omission/null inherit; a non-null list replaces, including empty-list clearing. Omitted/null Skill version selectors resolve the default version. Source deletion/default updates diff --git a/apps/docs/content/docs/environments-and-files.mdx b/apps/docs/content/docs/environments-and-files.mdx index f74e1439f..f56ad979c 100644 --- a/apps/docs/content/docs/environments-and-files.mdx +++ b/apps/docs/content/docs/environments-and-files.mdx @@ -652,9 +652,49 @@ Runtime resolves public HTTP declarations and installed Plugin MCP through retains its connection origin, transport, nullable tool allowlist, required flag, credential authority and installed stdio identity. Bindings are never persisted or logged. Duplicate identities and unavailable selected credentials reject. -A service-origin request cannot silently become an Environment-origin connection; -the existing public HTTP MCP profile remains service-origin `environment:none`. -This internal consolidation does not qualify public `connection_origin=environment`. +Public HTTP MCP uses the same binding path as installed Plugin MCP. Its explicit +`connection_origin` is retained from saved configuration through the Session +snapshot and private Runtime request; the exact Runtime wire version is required. +A service-origin request cannot silently become an Environment-origin connection. + +### Public MCP connection origin + +Core's Harness profile declares `MCPOrigins`; shared admission and dispatch check +the origin against the Environment and Runtime's advertised HTTP/bearer/required +capabilities. Runtime validates the same origin before invoking an adapter. No +Harness-name or Sandbox Provider branch selects a different connection path. + +| Harness | `service` origin | `environment` origin | Optional policy | +| --- | --- | --- | --- | +| Codex | Service execution host, `environment:none` | Managed or self-hosted workspace | Nullable tool allowlist and required initialization | +| Claude SDK | Service execution host, `environment:none` | Managed or self-hosted workspace; packaged `workspace_mcp_http` feature required | Nullable tool allowlist and required initialization | +| MiniMax Code | Unsupported | Managed or self-hosted workspace | `allowed_tools` must be null/omitted; `required` must be false | + +Both origins support the declared Harness's anonymous HTTP and selected HTTPS +bearer path. The existing attached-Vault selection freezes credential identity, +including a unique implicit URL match or an anonymous selection. Only that +Project-authorized credential may enter the transient Runtime request; Core +defaults and unrelated Vaults are not searched. Decryption failure or a missing +credential fails execution without an anonymous fallback. Public Environment +MCP retains `project_vault` authority; Plugin credentials retain +`environment_configuration` authority. Neither source overrides duplicate +server labels. Bearers never enter persisted native configuration or argv. + +Omitted/null origin still means `service`, including on self-hosted requests; +it does not select the local network automatically. Service-origin requests with +a workspace remain rejected because they require separate service-side connection +forwarding. This implementation adds no proxy. Environment origin requires an +initialized workspace with enabled network access and is invalid on `none`. + +Null/omitted `allowed_tools` permits all server tools; an empty list permits none. +MiniMax rejects every non-null allowlist, including an empty list, rather than +silently expanding it. Codex and Claude preserve native allowlists and initialize +required servers before releasing native input, including cold recovery. +Public MCP with native Subagents remains unqualified. Nonempty literal HTTP +headers, request metadata and public stdio declarations remain unsupported. +See [public MCP qualification](https://github.com/MiniMax-AI/parsar-core/blob/e974a7f880a2eb799f0dd39e6ba0870462854a53/contracts/agents-api/public-mcp-qualification.md) for actual model, +platform and infrastructure coverage; admission support is not a claim of +complete cross-platform/provider qualification. Environment-origin literal HTTP headers remain rejected for the pinned Claude and MiniMax clients because their cross-origin forwarding cannot preserve header @@ -662,7 +702,8 @@ authority. MiniMax supports installed stdio and HTTP servers with anonymous or explicit user-selected HTTPS bearer authentication. ACP HTTP declarations remain Session-local native memory; tokens do not enter native configuration files or process arguments. Required initialization and tool allowlists are not exposed -through the Plugin manifest, and public MiniMax MCP remains unqualified. +through the Plugin manifest. Public MiniMax HTTP uses the same transient ACP map +with the stricter admission limits above. MiniMax reads the existing Session-private native runtime-name registry for exact first-frame identities and cross-checks completed native results for both transports. Reuse existing observation and cancellation settlement; never fabricate a delayed diff --git a/apps/docs/content/docs/harness-onboarding.mdx b/apps/docs/content/docs/harness-onboarding.mdx index 372b0007a..8f8654b6f 100644 --- a/apps/docs/content/docs/harness-onboarding.mdx +++ b/apps/docs/content/docs/harness-onboarding.mdx @@ -307,6 +307,23 @@ same dedicated Runtime binding and shared Files helpers. A native Bash sandbox alone does not establish isolation for other native file tools. Enable a placement only after its required security and lifecycle behavior is demonstrated. +### MCP origin and native limits + +Declare supported public origins in the existing engine profile's `MCPOrigins` +and bearer support in `MCPBearer`. Runtime advertises actual HTTP, bearer and +required-initialization capabilities. Shared admission validates origin and +placement; adapter validation retains native label, allowlist and initialization +limits. These are separate checks, not a second MCP executor. + +Consume `agent.ResolveMCPBindings` for public and installed declarations; preserve +origin, credential authority, null versus empty allowlists and required startup. +Do not copy tokens into native profiles or reinterpret a service request as an +Environment request. Reject unsupported native policies instead of dropping them. +Follow [the MCP origin contract](/environments-and-files#public-mcp-connection-origin) +and run public-client, failure, cancellation and cold-recovery qualification for +each advertised combination. Model capability remains separate from Harness +transport support; never infer it from model names or silently degrade input. + ## Optional Subagent observations A harness that supports the Subagent resource reads implements the existing diff --git a/apps/docs/content/docs/runtime-protocol.mdx b/apps/docs/content/docs/runtime-protocol.mdx index b784b9287..cc7236641 100644 --- a/apps/docs/content/docs/runtime-protocol.mdx +++ b/apps/docs/content/docs/runtime-protocol.mdx @@ -526,4 +526,15 @@ The public `connected` state describes transport; initialization completion and native executor readiness remain separate prerequisites for execution. Input sources and frozen metadata follow the [Environment contract](/environments-and-files#runtime-capability-preparation). + +### MCP connection authority + +Public `MCPHTTPServer` messages carry an explicit `connection_origin`; missing or +unknown values reject rather than selecting a default. Core freezes the public +default before dispatch. Both peers require the exact wire version. Runtime uses +the common origin validator before selecting a factory and resolves public and +installed MCP into transient effective bindings. See the +[origin and credential contract](/environments-and-files#public-mcp-connection-origin) +for supported combinations, native limits and failure ownership. + [Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f6d258735fc601c521dd990e6f9e1ed261f4ef2d/docs/runtime-protocol.md) diff --git a/apps/docs/content/guide-sources.json b/apps/docs/content/guide-sources.json index 6c9c5fc66..88522a088 100644 --- a/apps/docs/content/guide-sources.json +++ b/apps/docs/content/guide-sources.json @@ -10,10 +10,10 @@ "docs/web/core-connection.md": "861c75c32676fd17b84eb356b263096703af5750c1ceb71bb339e84607fffc56", "docs/getting-started/quickstart.md": "b989682ac2e58d59a794118fd371b37d1ea64c957d3512ff53739458a95d0f9a", "docs/api/README.md": "dbe3f172a997ee2fd3d2e5765d382b4fb7cf7c50f1dd17212ab9646f6959d63e", - "contracts/agents-api/execution-tools.md": "8cc0dbe207e8e80ac104bf482c37ea297cd64250b51d288ed4baf553756424d2", + "contracts/agents-api/execution-tools.md": "fe1e3cf471fe9c7ef04afa7230e6b7742fbf2146c74bd944a7a22d5d4d4197da", "docs/api/public-agent-api.md": "00979732412a971013e8f01b4c74820ff51aafdded6b0f105d25a78af86a6627", "docs/examples.md": "0e1bdaeff51c9c36779f817be31ea9816b7d8cb2290cf8350d3c4801f436f4f9", - "contracts/agents-api/environments.md": "29be005ded0ad9fae226bd590b5becb880cc7de0380ce847f16a153e7c3c3195", + "contracts/agents-api/environments.md": "3ec73248afbc04e79e2fd0cb6cf4e6fbc0ff915722df2ee676d2537f30fd43ad", "docs/getting-started/nodes.md": "7c1b7e364ce85b5b5916358cafc618f29042b2125ac45653be665ba07e772fdb", "docs/getting-started/self-hosted.md": "5ade597e09cbfa2e321f341693b47730b3696fe7bd4d6834eafbe6b279a55e6b", "docs/self-hosted-native.md": "b3cf736f88792e6925c50b81a4c33eba6b9ee86e195f9ed4e2187db7bed71d88", @@ -25,9 +25,9 @@ "docs/user-guide.md": "190bea5bfe23b71db3d9437065ee270e07226a89a6e98c668853e5c7f7555529", "docs/assets/development-architecture.png": "24e6d0145d4f16ad70b07b6bc643808a6455aaf6398434d199cf74def200fca6", "docs/development.md": "f5c340253036a2cabc43e22aecdf70522d90280d6511e7649278ae93712ab8b6", - "contracts/agents-api/harness-onboarding.md": "874a80dc1ffc5e97b2783bea3f6b217893f997b38bb8180a29f09c6dbf75e622", + "contracts/agents-api/harness-onboarding.md": "e8fceb236e7fb0794eade26639d86b0622ec7dbb1fad90c30f41742c9d029232", "docs/runtime-bootstrap.md": "0d49aed73b298039e04453e6f465b0e925fb2227fa35d4206820bb3acbd8df39", - "docs/runtime-protocol.md": "e8aa4cf862b5f63a4138cfeb25196a6bdb5c40a2e389e828b464585415dc6c45", + "docs/runtime-protocol.md": "1d48186fa84140403358d2d01ddaca56d7122e1b3606cb6855ddefffeb3b8ed6", "docs/sandbox-provider.md": "4d47b234a457f874f3ff7e61a7f5da6fc8b75b0c6df0ce0ce9ead1c07afdfb3e", "apps/docs/scripts/guides.json": "3c3768fb94fd3d42464d4ce8c55724b9ba8360133c7cc19d513d8ec83a8e034f" }, @@ -41,10 +41,10 @@ "content/docs/bootstrap-projects-keys.mdx": "91a6f62cbe41737adfce9e8ec56e6dc3e2fb0ec8f6b677576f941cdfb378dc47", "content/docs/quickstart.mdx": "d0ab9537ab68f6dd3109353e937a29c5d58ec3894b56a52873c9a1d79dfa60a6", "content/docs/public-api.mdx": "cd59f4a661f897d1a9dc6ff08a09f7b7504c355288bbf022eff9b70500a9865c", - "content/docs/agents-and-tools.mdx": "44dfde4e3b3906b30323c2e75a89650ae4837210c7ba425be2266edf87ce8ff8", + "content/docs/agents-and-tools.mdx": "3ca16d2e2ff1c759251fbdf6e071ff09a93d5efc7acaca36a48854597d4f9576", "content/docs/sessions.mdx": "bb63d799ed90038836d652f3f866d0309822425114c6b2aa36654b8a991947f6", "content/docs/examples.mdx": "587061e65ab2e841d14980539ba94e2216d4e8135c948a852b9a8bb0359c85d7", - "content/docs/environments-and-files.mdx": "88d1fc4129270302a1f4bebd7febfaac622f42dcb1f3f5524b4fa49e10106479", + "content/docs/environments-and-files.mdx": "e054c18b22581c3573a6018a9659879ed2776e029d0d12c17e90eb537becb50c", "content/docs/hosted-providers.mdx": "9c241090709a9929ab6a34615db1e20a94c1f36649026281836060e81ac40b4c", "content/docs/self-hosted-execution.mdx": "eeed4c6b3646927ccc3c7ac2d20b2c0f9c8a65e42d734310fe3959e016324e57", "content/docs/self-hosted-native.mdx": "671451580dfb4f5c134221991f68292a4f992008174d23190b1da3742046571f", @@ -56,9 +56,9 @@ "content/docs/user-guide.mdx": "7c099b2d787ce3d7876889c7cefd621840ec083f267eadf278214a83f11ba274", "public/images/source/docs/assets/development-architecture.png": "24e6d0145d4f16ad70b07b6bc643808a6455aaf6398434d199cf74def200fca6", "content/docs/development.mdx": "2e5249ddfca571d264e93fd1e0e390a4bd5b0b623bda100cd02f2982929850bb", - "content/docs/harness-onboarding.mdx": "17626e9f6256ddfffc95fd81dfa8d9c712d9ff16792ea5ef54bfe8c3ce1a2a9f", + "content/docs/harness-onboarding.mdx": "8959febf77b2e9e4b16d301eabd8027f09ebd635f2c2713a40d22884afe95d12", "content/docs/runtime-bootstrap.mdx": "58982955811c9ad46a9fc04d8d2aef5a762fc9d25d5833f88aa75ee3fc46523f", - "content/docs/runtime-protocol.mdx": "d6b818edf2a0e0c4f2d3878f75805043c6dba3b09c6563f80faa4cecd05d40c7", + "content/docs/runtime-protocol.mdx": "caa7700e442b0666340c091f3ec0bff32e6217b3be4bc845c3b9c23ee0c8d0f6", "content/docs/sandbox-provider.mdx": "9db833fe9ff3f30a65451f80d7251348695a08e7830f9e95871739a710529818" } } diff --git a/apps/docs/openapi/public-api.yaml b/apps/docs/openapi/public-api.yaml index 9f8111cc0..7e81e65bb 100644 --- a/apps/docs/openapi/public-api.yaml +++ b/apps/docs/openapi/public-api.yaml @@ -864,7 +864,7 @@ paths: tags: - Sessions post: - description: 'The optional Core model_provider bundle resolves from the Session override, saved Agent defaults, then, for openai_hosted and none, the deployment default of the resolved harness; self_hosted never uses the deployment default and none accepts only it. openai_hosted and self_hosted Sessions that resolve no bundle return 400 model_provider_required with param x_agents_core.model_provider before any write. Core encrypts and freezes the resolved bundle; later Agent or deployment default edits and same-key retries cannot change it. Keys are never returned. 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, adapter-qualified multi_agent with persisted Subagent reads, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with service origin (omitted or null on HTTP transport is saved as service), native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer or OAuth credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Session reads, lists and event snapshots show that implicitly selected credential ID in a null or omitted credential_id, also after the credential is deleted; anonymous selections stay null and the stored caller intent is unchanged. After the input requirement and before any write, a credential_id without vault_ids, one outside the attached Vaults (one message for missing, foreign and unattached IDs) or one for another server_url returns 400 invalid_request_error, and several implicit matches return 409 conflict_error. 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 native OAuth login remain unsupported. The self_hosted profile uses a qualified native harness, a clean absolute workspace_directory and optional absolute local capability_directories prepared by Runtime, with optional non-deferred function tools and HTTP MCP using 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; non-string values and limit violations return invalid_request_error with a metadata or metadata. param. The inline agent uses the Agent create configuration validation with agent.-prefixed params, reported before the input requirement and saved-Agent lookup; saved configurations with conflicting tools or schema roots reject admission with the same errors, and execution limits keep unsupported_or_invalid_configuration. Hosted network policy rejections return invalid_request_error with a null param. Initial input accepts a string or ordered user-message array. Codex and Claude SDK on none and qualified managed or self_hosted workspace profiles also accept inline PNG/JPEG image content; other image combinations and remote URLs are unsupported. 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. Initial input is required for none and for streamed creation outside self_hosted. Omitted/null input remains valid for non-streaming hosted and self_hosted creation. With stream=true, returns live Session events starting with the committed creation snapshot and closes right after the first agent.session.idle recorded when a Turn ends or an input reservation stops being pending, or any agent.session.failed, without sending later events. A creation that admitted nothing closes after the snapshot; a settlement that records no event closes after events up to the cursor read with a settled Session projection. Required actions keep it open; disconnect does not cancel execution. The GET events stream remains live-only. 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; new hosted inline requests also freeze caller intent before deployment defaults resolve; unrelated non-hosted inline retries preserve resolved/default equivalences, and their resolved hash leaves out any deployment default. Provider keys enter retry hashes only as fingerprints keyed by the credential key. 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. A same-key stream=true retry of an existing creation returns 201 with no events and closes at once; retry with stream=false or use the GET events stream to recover. Claude SDK on none, Core-managed Docker openai_hosted and self_hosted supports qualified object-root json_schema output with medium verbosity, single-Agent execution and ordinary functions. Hosted execution reuses native workspace tools and Files/Artifacts; Skills, Plugins, capability directories, HTTP MCP, Subagent and tool_search combinations remain unqualified, including inherited template contents. Other 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 or successful inline PNG/JPEG 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 and restricted policies reject before compute allocation because the current Runtime cannot enforce them. The x_agents_core.environment extension accepts common preparation fields for either hosted or self-hosted placement: environment_template_id, files, env, packages, setup_commands, skills, plugins and capability_directories. Duplicate fields in environment and the extension reject. Confidential env, npm/Python packages and ordered setup commands use the same Environment-owned initialization lifecycle; compute allocation does not own preparation. Unknown side effects are not replayed after disconnect or restart. System dependencies must be preinstalled in the sandbox image or template, or on the host machine; packages.system is rejected. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. With a template reference, omitted/null files, env, packages and setup_commands inherit. Non-null files and command lists replace; env overlays by key; each package manager inherits on omission/null and otherwise replaces its list. Empty lists clear their selected field. Tenant-owned environment_template_id references inherit omitted/null network and allow only narrowing overrides. Inline hosted network:null retains the enabled default; updating a Template with network:null resets its saved policy to enabled. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. Inline or tenant-owned skill_reference Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill, Plugin and capability-directory list omission/null inherit; a non-null list replaces, including empty-list clearing. Omitted/null Skill version selectors resolve the default version. Source deletion/default updates cannot change committed Session Skill contents. Deferred function discovery uses type-only tool_search and per-function defer_loading in the qualified single-agent Claude function profile on none or a managed/user-owned workspace, including qualified inline image messages and text results. Explicit web_search mode disabled and programmatic_tool_calling enabled false use frozen common Runtime controls. Enabled forms, including those saved on an Agent, remain unqualified and reject before any write unless the Session replaces tools. Omitted programmatic configuration preserves native behavior, a documented difference from the official default-on behavior. Other combinations remain unqualified; see the operation coverage.' + description: 'The optional Core model_provider bundle resolves from the Session override, saved Agent defaults, then, for openai_hosted and none, the deployment default of the resolved harness; self_hosted never uses the deployment default and none accepts only it. openai_hosted and self_hosted Sessions that resolve no bundle return 400 model_provider_required with param x_agents_core.model_provider before any write. Core encrypts and freezes the resolved bundle; later Agent or deployment default edits and same-key retries cannot change it. Keys are never returned. 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, adapter-qualified multi_agent with persisted Subagent reads, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with service origin (omitted or null on HTTP transport is saved as service), native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer or OAuth credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Session reads, lists and event snapshots show that implicitly selected credential ID in a null or omitted credential_id, also after the credential is deleted; anonymous selections stay null and the stored caller intent is unchanged. After the input requirement and before any write, a credential_id without vault_ids, one outside the attached Vaults (one message for missing, foreign and unattached IDs) or one for another server_url returns 400 invalid_request_error, and several implicit matches return 409 conflict_error. 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. Explicit environment-origin HTTP MCP is supported on managed and self-hosted workspaces through the same Runtime bindings; native OAuth login remains unsupported. The self_hosted profile uses a qualified native harness, a clean absolute workspace_directory and optional absolute local capability_directories prepared by Runtime, with optional non-deferred function tools and HTTP MCP using explicit environment origin, optionally authenticated by the attached Vault rules. Service-origin HTTP remains restricted to service-side environment:none. 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; non-string values and limit violations return invalid_request_error with a metadata or metadata. param. The inline agent uses the Agent create configuration validation with agent.-prefixed params, reported before the input requirement and saved-Agent lookup; saved configurations with conflicting tools or schema roots reject admission with the same errors, and execution limits keep unsupported_or_invalid_configuration. Hosted network policy rejections return invalid_request_error with a null param. Initial input accepts a string or ordered user-message array. Codex and Claude SDK on none and qualified managed or self_hosted workspace profiles also accept inline PNG/JPEG image content; other image combinations and remote URLs are unsupported. 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. Initial input is required for none and for streamed creation outside self_hosted. Omitted/null input remains valid for non-streaming hosted and self_hosted creation. With stream=true, returns live Session events starting with the committed creation snapshot and closes right after the first agent.session.idle recorded when a Turn ends or an input reservation stops being pending, or any agent.session.failed, without sending later events. A creation that admitted nothing closes after the snapshot; a settlement that records no event closes after events up to the cursor read with a settled Session projection. Required actions keep it open; disconnect does not cancel execution. The GET events stream remains live-only. 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; new hosted inline requests also freeze caller intent before deployment defaults resolve; unrelated non-hosted inline retries preserve resolved/default equivalences, and their resolved hash leaves out any deployment default. Provider keys enter retry hashes only as fingerprints keyed by the credential key. 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. A same-key stream=true retry of an existing creation returns 201 with no events and closes at once; retry with stream=false or use the GET events stream to recover. Claude SDK on none, Core-managed Docker openai_hosted and self_hosted supports qualified object-root json_schema output with medium verbosity, single-Agent execution and ordinary functions. Hosted execution reuses native workspace tools and Files/Artifacts; Skills, Plugins, capability directories, HTTP MCP, Subagent and tool_search combinations remain unqualified, including inherited template contents. Other 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 or successful inline PNG/JPEG results alongside native workspace tools; explicit environment-origin HTTP MCP uses the common Runtime path. MiniMax accepts public environment-origin HTTP MCP only with null or omitted allowed_tools and required=false; even an empty non-null allowlist rejects. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted policies reject before compute allocation because the current Runtime cannot enforce them. The x_agents_core.environment extension accepts common preparation fields for either hosted or self-hosted placement: environment_template_id, files, env, packages, setup_commands, skills, plugins and capability_directories. Duplicate fields in environment and the extension reject. Confidential env, npm/Python packages and ordered setup commands use the same Environment-owned initialization lifecycle; compute allocation does not own preparation. Unknown side effects are not replayed after disconnect or restart. System dependencies must be preinstalled in the sandbox image or template, or on the host machine; packages.system is rejected. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. With a template reference, omitted/null files, env, packages and setup_commands inherit. Non-null files and command lists replace; env overlays by key; each package manager inherits on omission/null and otherwise replaces its list. Empty lists clear their selected field. Tenant-owned environment_template_id references inherit omitted/null network and allow only narrowing overrides. Inline hosted network:null retains the enabled default; updating a Template with network:null resets its saved policy to enabled. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. Inline or tenant-owned skill_reference Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill, Plugin and capability-directory list omission/null inherit; a non-null list replaces, including empty-list clearing. Omitted/null Skill version selectors resolve the default version. Source deletion/default updates cannot change committed Session Skill contents. Deferred function discovery uses type-only tool_search and per-function defer_loading in the qualified single-agent Claude function profile on none or a managed/user-owned workspace, including qualified inline image messages and text results. Explicit web_search mode disabled and programmatic_tool_calling enabled false use frozen common Runtime controls. Enabled forms, including those saved on an Agent, remain unqualified and reject before any write unless the Session replaces tools. Omitted programmatic configuration preserves native behavior, a documented difference from the official default-on behavior. Other combinations remain unqualified; see the operation coverage.' parameters: - schema: type: string diff --git a/apps/docs/openapi/sources.json b/apps/docs/openapi/sources.json index e5ba3e4f7..05824a2cb 100644 --- a/apps/docs/openapi/sources.json +++ b/apps/docs/openapi/sources.json @@ -1,6 +1,6 @@ { "sources": { - "contracts/agents-api/openapi.yaml": "42ae2585b0abe60860de99a6958b639a495a0d9b7c3d0142b199a427fa77b5a5", + "contracts/agents-api/openapi.yaml": "bdda1027eb0e7d2e8ccc5dba3ed3e2dbb6d447e9ab2c5b571fe4056fa7596eec", "contracts/agents-api/core.openapi.yaml": "f1b9278c3a4b1cb55127cf9471583d00f1ab13b6ceb64f8693a01c4cf03674eb", "contracts/agents-api/runtime.openapi.yaml": "505286d5eacf94a14f9527fcf4e7beb4fba4f792681be26ba966254cbf49b4aa" }, @@ -8,7 +8,7 @@ "content/docs/api-reference/agents.mdx": "1f7697959e9c52c9d24fbe70e116e807f49912b9a27ac638b86782e2f326af44", "content/docs/api-reference/environments.mdx": "6a9e08a67479a745983c45b9711137617c13fdf7599a64533997b00a26ac7282", "content/docs/api-reference/environment-templates.mdx": "68df9276e858e525e15bb88af624fc8adee8bcdcc03a788faaff56b560fd038c", - "content/docs/api-reference/sessions.mdx": "68a839427c3ff380644fd79e7876fd8fac7a6792179eb3cb9ed376f5953e6e46", + "content/docs/api-reference/sessions.mdx": "8ce222c8c196b18e6b4a21ed29f3fd54459be0576906d5c60239689ce990292b", "content/docs/api-reference/artifacts.mdx": "8df8d008126b49b1df88f28a676c4b237ccffc45a470b35ec30eab2daf1c0f7f", "content/docs/api-reference/events.mdx": "fa509794f3c208c0cdcdaa68615c26b7e400ea6c892922a4e0a6a833e42fe5dd", "content/docs/api-reference/items.mdx": "fa4d248ac5623beb3f00a4ebb5a6c4ff477865ebee351e0c543a970a7ec3654d", @@ -18,7 +18,7 @@ "content/docs/api-reference/skills.mdx": "774ea05a7a8250fe5af5164388b98dafca99d56dc2eae686e8f628acb4e7c10b", "content/docs/api-reference/vaults.mdx": "d80eade1c2f8100030866abbb0fb6b7a09c94250a859c97cdf1abce32fdfdf1e", "content/docs/api-reference/credentials.mdx": "f129a51f74a3c7021c016600518e2350023f8d29571666ac5d64b7584cd7c7d0", - "openapi/public-api.yaml": "3cdee54523822cd19766771dae4c13a64d6e318ca8e9d5fabe8339e975c8ac25", + "openapi/public-api.yaml": "db69b3ebaf6599ee502e5ec1d385ef0504940fa955ec2a4be271150ef1d59c41", "content/docs/api-reference/meta.json": "56bf1dc145ccc8adde38c5f956f1a06f18ef18b646122250d01a294c516f8f83", "content/docs/api-reference/index.mdx": "9fd6b1c54149874d2999235067c101326f9e8a2e707a70b6672c81eb3ebfe63f", "content/docs/api-reference/core/core-administration.mdx": "5fc3ada51d2190a141f13738151e4d513390cc3bd66ce6f5013994bda68569b0", diff --git a/apps/parsar-daemon/internal/agent/claudesdk/execution_controls_test.go b/apps/parsar-daemon/internal/agent/claudesdk/execution_controls_test.go index 16cf0eb04..15ff569d7 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/execution_controls_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/execution_controls_test.go @@ -65,10 +65,10 @@ func TestMCPWithoutEnvironmentNoneRejectedBeforeSetup(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) config := Config{Node: "must-not-run", Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} - servers := []proto.MCPHTTPServer{} + servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "remote", ServerURL: "https://example.test/mcp"}} request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("Input"), MCPHTTPServers: &servers} _, err := NewFactory(config)(t.Context(), request, make(chan proto.Envelope, 1)) - if err == nil || !strings.Contains(err.Error(), "HTTP MCP requires environment:none") { + if err == nil || !strings.Contains(err.Error(), "service-origin MCP requires a service execution host") { t.Fatal("MCP reached an unsupported environment", err) } if _, err := os.Stat(config.StateDir); !os.IsNotExist(err) { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/executor.go b/apps/parsar-daemon/internal/agent/claudesdk/executor.go index beef6fb3e..39c73ea5e 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/executor.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/executor.go @@ -97,11 +97,16 @@ func validateExecutorFeatures(info RuntimeInfo, start startRequest) error { if start.Workspace != nil && len(start.Functions) > 0 && !info.SupportsWorkspaceFunctions() { return errors.New("claudesdk: workspace functions are unavailable") } - if start.MCPHTTPServers != nil { + if servers := start.declaredMCP(); len(servers) > 0 { + for _, server := range servers { + if start.Workspace != nil && server.ServerURL != "" && !info.SupportsWorkspaceMCP() { + return errors.New("claudesdk: workspace HTTP MCP is unavailable") + } + } if !info.SupportsHTTPMCP() { return errors.New("claudesdk: packaged runtime does not support HTTP MCP") } - for _, server := range *start.MCPHTTPServers { + for _, server := range servers { if server.Required && !info.SupportsHTTPMCPRequired() { return errors.New("claudesdk: packaged runtime does not support required HTTP MCP") } diff --git a/apps/parsar-daemon/internal/agent/claudesdk/mcp.go b/apps/parsar-daemon/internal/agent/claudesdk/mcp.go index 4777ccba8..de9be05b1 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/mcp.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/mcp.go @@ -21,10 +21,11 @@ func validateMCP(req proto.PromptRequestPayload) error { if req.MCPHTTPServers == nil { return nil } - if !req.DisableExecutionEnvironment { - return fmt.Errorf("claudesdk: HTTP MCP requires environment:none") + if err := validateMCPServers(*req.MCPHTTPServers); err != nil { + return err } - return validateMCPServers(*req.MCPHTTPServers) + _, err := agent.ResolveMCPBindings(req) + return err } func validateMCPServers(servers []proto.MCPHTTPServer) error { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/mcp_bearer_test.go b/apps/parsar-daemon/internal/agent/claudesdk/mcp_bearer_test.go index f45eeb6bf..8212ba2b9 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/mcp_bearer_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/mcp_bearer_test.go @@ -18,9 +18,9 @@ func TestMCPBearerUsesFreshOwnedEnvironmentReferences(t *testing.T) { tokens := []string{"first.synthetic+/==", "second-synthetic_token~"} tools := []string{"echo.v1"} servers := []proto.MCPHTTPServer{ - {ServerLabel: "first", ServerURL: "https://first.example/mcp", AllowedTools: &tools, BearerToken: &tokens[0]}, - {ServerLabel: "second", ServerURL: "https://second.example/mcp", BearerToken: &tokens[1]}, - {ServerLabel: "anonymous", ServerURL: "http://anonymous.example/mcp"}, + {ConnectionOrigin: "service", ServerLabel: "first", ServerURL: "https://first.example/mcp", AllowedTools: &tools, BearerToken: &tokens[0]}, + {ConnectionOrigin: "service", ServerLabel: "second", ServerURL: "https://second.example/mcp", BearerToken: &tokens[1]}, + {ConnectionOrigin: "service", ServerLabel: "anonymous", ServerURL: "http://anonymous.example/mcp"}, } req := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), DisableExecutionEnvironment: true, MCPHTTPServers: &servers, AgentOptions: map[string]any{"model": "fixture"}} seen := map[string]bool{} @@ -65,7 +65,7 @@ func TestMCPBearerRejectsInvalidCredentialBeforeStateCreation(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) config := Config{Entrypoint: filepath.Join(root, "main.js"), StateDir: filepath.Join(root, "state")} - servers := []proto.MCPHTTPServer{{ServerLabel: "fixture", ServerURL: "https://example.invalid/mcp", BearerToken: &token}} + servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "fixture", ServerURL: "https://example.invalid/mcp", BearerToken: &token}} req := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), DisableExecutionEnvironment: true, MCPHTTPServers: &servers, AgentOptions: map[string]any{"model": "fixture"}} if _, _, err := prepare(config, req); err == nil || err.Error() != "claudesdk: unsupported HTTPS MCP bearer credential" { t.Fatal("invalid bearer accepted or unsafe error returned") @@ -77,7 +77,7 @@ func TestMCPBearerRejectsInvalidCredentialBeforeStateCreation(t *testing.T) { } for _, url := range []string{"http://example.invalid/mcp", "https://example.invalid/mcp#", "https://example.invalid/mcp?", "https://user:secret@example.invalid/mcp"} { token := "synthetic-token" - servers := []proto.MCPHTTPServer{{ServerLabel: "fixture", ServerURL: url, BearerToken: &token}} + servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "fixture", ServerURL: url, BearerToken: &token}} if err := validateMCP(proto.PromptRequestPayload{DisableExecutionEnvironment: true, MCPHTTPServers: &servers}); err == nil { t.Fatal("unsafe authenticated endpoint accepted") } diff --git a/apps/parsar-daemon/internal/agent/claudesdk/mcp_test.go b/apps/parsar-daemon/internal/agent/claudesdk/mcp_test.go index 334318cf9..796077fd3 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/mcp_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/mcp_test.go @@ -15,7 +15,7 @@ func TestHTTPMCPDeclaration(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) config := Config{Entrypoint: filepath.Join(root, "main.js"), StateDir: filepath.Join(root, "state")} - servers := []proto.MCPHTTPServer{{ServerLabel: "fixture", ServerURL: "https://example.invalid/mcp"}} + servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "fixture", ServerURL: "https://example.invalid/mcp"}} req := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), DisableExecutionEnvironment: true, MCPHTTPServers: &servers, AgentOptions: map[string]any{"model": "fixture"}} tools := []string{"echo"} switch mode { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/preparation_test.go b/apps/parsar-daemon/internal/agent/claudesdk/preparation_test.go index 68159e908..249937d82 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/preparation_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/preparation_test.go @@ -131,7 +131,7 @@ func TestPreparationRejectsInputAndUnavailableProfilesBeforeLaunch(t *testing.T) case "functions": req.FunctionTools = []proto.FunctionTool{{Name: "hello", Parameters: json.RawMessage(`{"type":"object"}`)}} case "mcp": - req.MCPHTTPServers = &[]proto.MCPHTTPServer{} + req.MCPHTTPServers = &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "remote", ServerURL: "https://example.test/mcp"}} case "controls": req.ExecutionControls = &proto.ExecutionControls{WebSearch: "enabled", TextVerbosity: "medium"} } diff --git a/apps/parsar-daemon/internal/agent/claudesdk/readiness.go b/apps/parsar-daemon/internal/agent/claudesdk/readiness.go index 1179f551c..673725413 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/readiness.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/readiness.go @@ -61,6 +61,10 @@ func (info RuntimeInfo) SupportsHTTPMCPBearer() bool { return info.SupportsHTTPMCP() && slices.Contains(info.Features, "mcp_http_bearer_auth") } +func (info RuntimeInfo) SupportsWorkspaceMCP() bool { + return info.SupportsLocalRuntime() && info.SupportsHTTPMCP() && slices.Contains(info.Features, "workspace_mcp_http") +} + func (info RuntimeInfo) SupportsHTTPMCPRequired() bool { return info.SupportsHTTPMCP() && slices.Contains(info.Features, "mcp_http_required") } diff --git a/apps/parsar-daemon/internal/agent/claudesdk/readiness_test.go b/apps/parsar-daemon/internal/agent/claudesdk/readiness_test.go index 27c9f3ceb..dbb0af020 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/readiness_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/readiness_test.go @@ -24,7 +24,7 @@ func TestRequiredMCPNeedsQualifiedRuntime(t *testing.T) { "GO_CLAUDE_READINESS_HELPER=1", "READINESS_MODE=ready-http-mcp", "GORACE=atexit_sleep_ms=0", }} req := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), DisableExecutionEnvironment: true, - AgentOptions: map[string]any{"model": "fixture"}, MCPHTTPServers: &[]proto.MCPHTTPServer{{ServerLabel: "fixture", ServerURL: "https://example.invalid/mcp", Required: true}}} + AgentOptions: map[string]any{"model": "fixture"}, MCPHTTPServers: &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", 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) } @@ -45,7 +45,7 @@ func TestHTTPMCPRejectsOldPackagedRuntime(t *testing.T) { t.Fatal("runtime feature not recognized") } req := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), DisableExecutionEnvironment: true, - AgentOptions: map[string]any{"model": "fixture"}, MCPHTTPServers: &[]proto.MCPHTTPServer{{ServerLabel: "fixture", ServerURL: "https://example.invalid/mcp"}}} + AgentOptions: map[string]any{"model": "fixture"}, MCPHTTPServers: &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "fixture", ServerURL: "https://example.invalid/mcp"}}} if _, err := NewFactory(config)(t.Context(), req, make(chan proto.Envelope, 1)); err == nil || !strings.Contains(err.Error(), "packaged runtime does not support HTTP MCP") { t.Fatalf("old runtime was not rejected before execution: %v", err) } @@ -82,7 +82,7 @@ func TestMCPBearerRejectsAnonymousOnlyRuntimeWithoutProbeSecrets(t *testing.T) { }} token := "private-fixture-token" req := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), DisableExecutionEnvironment: true, - AgentOptions: map[string]any{"model": "fixture"}, MCPHTTPServers: &[]proto.MCPHTTPServer{{ServerLabel: "fixture", ServerURL: "https://example.invalid/mcp", BearerToken: &token}}} + AgentOptions: map[string]any{"model": "fixture"}, MCPHTTPServers: &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "fixture", ServerURL: "https://example.invalid/mcp", BearerToken: &token}}} if _, err := NewFactory(config)(t.Context(), req, make(chan proto.Envelope, 1)); err == nil || err.Error() != "claudesdk: packaged runtime does not support authenticated HTTP MCP" { t.Fatalf("old runtime executed authenticated request or readiness received its secret: %v", err) } diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace.go index 336893cde..c4167b72e 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace.go @@ -40,7 +40,7 @@ type workspaceProfile struct { } func prepareWorkspace(config Config, req proto.PromptRequestPayload) (*workspaceProfile, []string, error) { - if req.DisableExecutionEnvironment || req.MCPHTTPServers != nil { + if req.DisableExecutionEnvironment { return nil, nil, fmt.Errorf("claudesdk: workspace profile does not support the requested execution combination") } if req.WorkDir != "" && req.WorkDir != config.Workspace.Directory { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_test.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace_test.go index 04e2fcfa2..cf59fc099 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace_test.go @@ -86,7 +86,7 @@ func TestWorkspaceRejectsConflictsBeforeSideEffects(t *testing.T) { case "work-dir": req.WorkDir = config.Workspace.ScratchDir case "mcp": - req.MCPHTTPServers = &[]proto.MCPHTTPServer{} + req.MCPHTTPServers = &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "remote", ServerURL: "https://example.test/mcp"}} case "caller-policy": req.AgentOptions["workspace"] = "override" case "relative": @@ -137,3 +137,40 @@ func TestWorkspaceRetainsDeclaredFunctions(t *testing.T) { t.Fatal("workspace function declaration was not retained independently of external MCP") } } + +func TestPublicMCPUsesWorkspaceProjectionWithoutCredentialCopy(t *testing.T) { + config := workspaceFixture(t) + config.Workspace.NetworkAccess = "enabled" + req := workspaceRequest() + req.LocalEnvironment = &proto.LocalEnvironment{NetworkAccess: "enabled"} + token := "vault-selected-canary" + tools := []string{"prove"} + req.MCPHTTPServers = &[]proto.MCPHTTPServer{{ConnectionOrigin: "environment", ServerLabel: "remote", ServerURL: "https://example.test/mcp", AllowedTools: &tools, Required: true, BearerToken: &token}} + start, env, err := prepare(config, req) + if err != nil { + t.Fatal(err) + } + if start.MCPHTTPServers != nil || start.Workspace == nil || len(start.Workspace.MCP) != 1 || !start.Workspace.MCP[0].Required || (*start.Workspace.MCP[0].AllowedTools)[0] != "prove" { + t.Fatal("workspace policy lost") + } + raw, _ := json.Marshal(start) + if strings.Contains(string(raw), token) { + t.Fatal("bearer copied into bridge request") + } + ref := start.Workspace.MCP[0].BearerTokenEnvVar + found := false + for _, entry := range env { + found = found || entry == ref+"="+token + } + if ref == "" || !found { + t.Fatal("selected credential not bound") + } + info := RuntimeInfo{Protocol: 3, Features: []string{"workspace_tools", "workspace_prepare", "workspace_command_observations", "local_runtime_v2", "mcp_http_tools", "mcp_http_bearer_auth", "mcp_http_required"}} + if validateExecutorFeatures(info, start) == nil { + t.Fatal("unqualified workspace bridge admitted") + } + info.Features = append(info.Features, "workspace_mcp_http") + if err := validateExecutorFeatures(info, start); err != nil { + t.Fatal(err) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_environment_test.go b/apps/parsar-daemon/internal/agent/codex/mcp_environment_test.go index 0ab768579..3c8327f26 100644 --- a/apps/parsar-daemon/internal/agent/codex/mcp_environment_test.go +++ b/apps/parsar-daemon/internal/agent/codex/mcp_environment_test.go @@ -62,7 +62,7 @@ func TestEnvironmentMCPRejectsUnqualifiedNetworkAndCredentialChanges(t *testing. t.Fatal("plaintext bearer accepted") } local.MCP[0].Server.URL = "https://example.com/mcp" - if _, _, err := runtimeMCPServers(proto.PromptRequestPayload{LocalEnvironment: local, MCPHTTPServers: &[]proto.MCPHTTPServer{{ServerLabel: "remote", ServerURL: "https://example.com/mcp"}}}); err == nil { + if _, _, err := runtimeMCPServers(proto.PromptRequestPayload{LocalEnvironment: local, MCPHTTPServers: &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "remote", ServerURL: "https://example.com/mcp"}}}); err == nil { t.Fatal("service and environment identity collision accepted") } } diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_http_bearer_test.go b/apps/parsar-daemon/internal/agent/codex/mcp_http_bearer_test.go index bd1fd711a..ee3f3583c 100644 --- a/apps/parsar-daemon/internal/agent/codex/mcp_http_bearer_test.go +++ b/apps/parsar-daemon/internal/agent/codex/mcp_http_bearer_test.go @@ -15,9 +15,9 @@ func TestMCPHTTPBearerPlanSeparatesServersAndProcesses(t *testing.T) { t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) tokens := []string{"first-synthetic.token+/==", "second-synthetic_token~"} servers := []proto.MCPHTTPServer{ - {ServerLabel: "first", ServerURL: "https://first.example/mcp", BearerToken: &tokens[0]}, - {ServerLabel: "second", ServerURL: "https://second.example/mcp", BearerToken: &tokens[1]}, - {ServerLabel: "public", ServerURL: "http://public.example/mcp"}, + {ConnectionOrigin: "service", ServerLabel: "first", ServerURL: "https://first.example/mcp", BearerToken: &tokens[0]}, + {ConnectionOrigin: "service", ServerLabel: "second", ServerURL: "https://second.example/mcp", BearerToken: &tokens[1]}, + {ConnectionOrigin: "service", ServerLabel: "public", ServerURL: "http://public.example/mcp"}, } req := proto.PromptRequestPayload{AgentStateKey: "retained-mcp", DisableExecutionEnvironment: true, MCPHTTPServers: &servers} seen := map[string]bool{} @@ -56,7 +56,7 @@ func TestMCPHTTPBearerRejectsInvalidTokensWithoutPersistence(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) for _, token := range []string{"", "=", " has-space", "has-space ", "has space", "line\r\ninjection", "nul\x00byte", "opaque中文", "middle=padding", "punctuation:invalid"} { - servers := []proto.MCPHTTPServer{{ServerLabel: "tools", ServerURL: "https://tools.example/mcp", BearerToken: &token}} + servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "tools", ServerURL: "https://tools.example/mcp", BearerToken: &token}} req := proto.PromptRequestPayload{AgentStateKey: "invalid-bearer", DisableExecutionEnvironment: true, MCPHTTPServers: &servers} if _, _, err := prepareSessionPlan(t.Context(), req, defaultSessionConfig()); err == nil || err.Error() != "invalid HTTPS MCP bearer credential" { t.Fatal("invalid bearer value accepted or unsafe error returned") @@ -79,7 +79,7 @@ func TestMCPHTTPBearerDoesNotReachModelCatalogProbe(t *testing.T) { t.Fatal(err) } token := "synthetic-catalog-secret" - servers := []proto.MCPHTTPServer{{ServerLabel: "tools", ServerURL: "https://tools.example/mcp", BearerToken: &token}} + servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "tools", ServerURL: "https://tools.example/mcp", BearerToken: &token}} req := proto.PromptRequestPayload{AgentStateKey: "catalog", DisableExecutionEnvironment: true, MCPHTTPServers: &servers, AgentOptions: map[string]any{"model": "fixture-model", "model_verbosity": "medium"}} cfg := defaultSessionConfig() diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_http_preflight_test.go b/apps/parsar-daemon/internal/agent/codex/mcp_http_preflight_test.go index e92b4d7ed..98afda442 100644 --- a/apps/parsar-daemon/internal/agent/codex/mcp_http_preflight_test.go +++ b/apps/parsar-daemon/internal/agent/codex/mcp_http_preflight_test.go @@ -128,7 +128,7 @@ func TestPublicMCPHTTPPreparationChecksBeforeNewAndResumedThread(t *testing.T) { t.Run(mode, func(t *testing.T) { req, cfg, root := preparationFixture(t) req.AgentOptions = map[string]any{"model": "fixture-model"} - servers := []proto.MCPHTTPServer{{ServerLabel: "docs", ServerURL: "https://docs.example/mcp"}} + servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "docs", ServerURL: "https://docs.example/mcp"}} if mode == "reject bearer reference" { token := "synthetic-private-bearer" servers[0].BearerToken = &token diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_http_test.go b/apps/parsar-daemon/internal/agent/codex/mcp_http_test.go index 0a60d7a54..22c6a7854 100644 --- a/apps/parsar-daemon/internal/agent/codex/mcp_http_test.go +++ b/apps/parsar-daemon/internal/agent/codex/mcp_http_test.go @@ -17,8 +17,8 @@ func TestPublicMCPHTTPPlanOwnsConfigurationAndPreservesHistory(t *testing.T) { tools := []string{"lookup.docs", `quote"tool`} denyAll := []string{} servers := []proto.MCPHTTPServer{ - {ServerLabel: "docs.server", ServerURL: "https://docs.example/mcp", AllowedTools: &tools, Required: true}, - {ServerLabel: "blocked", ServerURL: "http://127.0.0.1:12345/mcp", AllowedTools: &denyAll}, + {ConnectionOrigin: "service", ServerLabel: "docs.server", ServerURL: "https://docs.example/mcp", AllowedTools: &tools, Required: true}, + {ConnectionOrigin: "service", ServerLabel: "blocked", ServerURL: "http://127.0.0.1:12345/mcp", AllowedTools: &denyAll}, } original := map[string]any{ "mcp_servers": map[string]any{"operator": map[string]any{"command": "operator-mcp"}}, @@ -64,7 +64,7 @@ func TestPublicMCPHTTPPlanOwnsConfigurationAndPreservesHistory(t *testing.T) { if err := os.WriteFile(history, []byte("native-history"), 0o600); err != nil { t.Fatal(err) } - servers = []proto.MCPHTTPServer{{ServerLabel: "replacement", ServerURL: "https://new.example/mcp"}} + servers = []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "replacement", ServerURL: "https://new.example/mcp"}} second, _, err := prepareSessionPlan(t.Context(), req, defaultSessionConfig()) if err != nil { t.Fatal(err) @@ -81,7 +81,7 @@ func TestPublicMCPHTTPPlanOwnsConfigurationAndPreservesHistory(t *testing.T) { } func TestPublicMCPHTTPRejectsInvalidProfileAndStoredCredentials(t *testing.T) { - valid := []proto.MCPHTTPServer{{ServerLabel: "docs", ServerURL: "https://docs.example/mcp"}} + valid := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "docs", ServerURL: "https://docs.example/mcp"}} for _, req := range []proto.PromptRequestPayload{ {MCPHTTPServers: &valid}, } { @@ -90,10 +90,10 @@ func TestPublicMCPHTTPRejectsInvalidProfileAndStoredCredentials(t *testing.T) { } } for _, server := range []proto.MCPHTTPServer{ - {ServerLabel: "codex_apps", ServerURL: "https://docs.example/mcp"}, - {ServerLabel: "docs", ServerURL: "https://user:synthetic-secret@docs.example/mcp"}, - {ServerLabel: "docs", ServerURL: "https://docs.example/mcp?token=synthetic-secret"}, - {ServerLabel: "docs", ServerURL: "file:///tmp/mcp"}, + {ConnectionOrigin: "service", ServerLabel: "codex_apps", ServerURL: "https://docs.example/mcp"}, + {ConnectionOrigin: "service", ServerLabel: "docs", ServerURL: "https://user:synthetic-secret@docs.example/mcp"}, + {ConnectionOrigin: "service", ServerLabel: "docs", ServerURL: "https://docs.example/mcp?token=synthetic-secret"}, + {ConnectionOrigin: "service", ServerLabel: "docs", ServerURL: "file:///tmp/mcp"}, } { servers := []proto.MCPHTTPServer{server} if _, _, err := runtimeMCPServers(proto.PromptRequestPayload{DisableExecutionEnvironment: true, MCPHTTPServers: &servers}); err == nil || strings.Contains(err.Error(), "synthetic-secret") { @@ -149,7 +149,7 @@ func writeMCPHTTPConfigResponse(t *testing.T, path string, response any) { func TestPublicMCPBearerRequiresHTTPS(t *testing.T) { req := proto.PromptRequestPayload{DisableExecutionEnvironment: true} token := "synthetic-private-token" - servers := []proto.MCPHTTPServer{{ServerLabel: "tools", ServerURL: "http://tools.example/mcp", BearerToken: &token}} + servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "tools", ServerURL: "http://tools.example/mcp", BearerToken: &token}} req.MCPHTTPServers = &servers if _, _, err := runtimeMCPServers(req); err == nil || strings.Contains(err.Error(), token) { t.Fatal("plaintext bearer accepted or exposed") diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_required_test.go b/apps/parsar-daemon/internal/agent/codex/mcp_required_test.go index 5eb978b5e..7def29158 100644 --- a/apps/parsar-daemon/internal/agent/codex/mcp_required_test.go +++ b/apps/parsar-daemon/internal/agent/codex/mcp_required_test.go @@ -19,7 +19,7 @@ func TestRequiredMCPWaitsForNativeThreadAndNeverRestartsFailedResume(t *testing. req, cfg, root := preparationFixture(t) req.StrictResume = true req.AgentOptions = map[string]any{"model": "fixture-model"} - servers := []proto.MCPHTTPServer{{ServerLabel: "docs", ServerURL: "https://docs.example/mcp", Required: true}} + servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "docs", ServerURL: "https://docs.example/mcp", Required: true}} req.MCPHTTPServers = &servers method := "thread/start" if strings.HasPrefix(mode, "resume") { diff --git a/apps/parsar-daemon/internal/agent/mcode/environment_mcp_test.go b/apps/parsar-daemon/internal/agent/mcode/environment_mcp_test.go index aca2db8ba..4bc09f370 100644 --- a/apps/parsar-daemon/internal/agent/mcode/environment_mcp_test.go +++ b/apps/parsar-daemon/internal/agent/mcode/environment_mcp_test.go @@ -232,3 +232,47 @@ func TestEnvironmentHTTPMCPUsesEphemeralACPConfiguration(t *testing.T) { } } } + +func TestPublicEnvironmentHTTPMCPKeepsCredentialTransient(t *testing.T) { + c, req, _ := workspaceFixture(t) + c.Network, req.LocalEnvironment.NetworkAccess = "enabled", "enabled" + token := "selected-public-vault-canary" + req.MCPHTTPServers = &[]proto.MCPHTTPServer{{ConnectionOrigin: "environment", ServerLabel: "remote", ServerURL: "https://example.test/mcp", BearerToken: &token}} + opts, err := prepareWorkspaceOptions(t.Context(), c, req) + if err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(opts.MCP) + if err != nil || !strings.Contains(string(raw), "Bearer "+token) { + t.Fatal("selected token not supplied to native ACP") + } + err = filepath.WalkDir(opts.DataDir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + value, err := os.ReadFile(path) + if err != nil { + return err + } + if strings.Contains(string(value), token) { + t.Fatal("public credential persisted in native state") + } + return nil + }) + if err != nil { + t.Fatal(err) + } + empty := []string{} + (*req.MCPHTTPServers)[0].AllowedTools = &empty + if _, err := prepareWorkspaceOptions(t.Context(), c, req); err == nil { + t.Fatal("empty allowlist silently treated as all") + } + (*req.MCPHTTPServers)[0].AllowedTools = nil + (*req.MCPHTTPServers)[0].Required = true + if _, err := prepareWorkspaceOptions(t.Context(), c, req); err == nil { + t.Fatal("required initialization silently ignored") + } +} diff --git a/apps/parsar-daemon/internal/agent/mcode/mcp_observations.go b/apps/parsar-daemon/internal/agent/mcode/mcp_observations.go index f309dea72..f017a0b4e 100644 --- a/apps/parsar-daemon/internal/agent/mcode/mcp_observations.go +++ b/apps/parsar-daemon/internal/agent/mcode/mcp_observations.go @@ -8,6 +8,7 @@ import ( "slices" "strings" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" ) @@ -16,10 +17,17 @@ type mcpToolIdentity struct{ server, tool string } // MiniMax 0.4.12 atomically writes these assignments before exposing tools. // Read its exact mapping instead of reversing lossy native name normalization. func (s *Session) environmentMCPIdentity(name string) (*mcpToolIdentity, error) { - if s.req.LocalEnvironment == nil || len(s.req.LocalEnvironment.MCP) == 0 || + if s.req.LocalEnvironment == nil || !strings.HasPrefix(name, "mcp__") || strings.HasPrefix(name, "mcp__oac_workspace__") { return nil, nil } + bindings, err := agent.ResolveMCPBindings(s.req) + if err != nil { + return nil, err + } + if len(bindings) == 0 { + return nil, nil + } raw, err := os.ReadFile(filepath.Join(s.opts.DataDir, "mcp-runtime-names.json")) if err != nil { return nil, fmt.Errorf("mcode: native MCP identity registry unavailable") @@ -42,8 +50,8 @@ func (s *Session) environmentMCPIdentity(name string) (*mcpToolIdentity, error) } var key []string declared := 0 - for _, item := range s.req.LocalEnvironment.MCP { - if item.Server.Name == server.Raw && (item.Server.Type == "stdio" || item.Server.Type == "http") { + for _, item := range bindings { + if item.ServerLabel == server.Raw { declared++ } } diff --git a/apps/parsar-daemon/internal/agent/mcode/mcp_observations_test.go b/apps/parsar-daemon/internal/agent/mcode/mcp_observations_test.go index 6c4e20905..6c0435e32 100644 --- a/apps/parsar-daemon/internal/agent/mcode/mcp_observations_test.go +++ b/apps/parsar-daemon/internal/agent/mcode/mcp_observations_test.go @@ -16,7 +16,7 @@ func mcpObservationSession(t *testing.T) (*Session, chan proto.Envelope) { out := make(chan proto.Envelope, 16) s := &Session{ctx: context.Background(), opts: launchOptions{DataDir: t.TempDir()}, req: proto.PromptRequestPayload{RunID: "run", ObserveToolObservations: true, - LocalEnvironment: &proto.LocalEnvironment{MCP: []proto.EnvironmentMCP{environmentMCPFixture()}}}, + LocalEnvironment: &proto.LocalEnvironment{NetworkAccess: "enabled", MCP: []proto.EnvironmentMCP{environmentMCPFixture()}}}, out: out, tools: map[string]toolUpdate{}, completedTools: map[string]bool{}, active: true, sessionID: "native-session"} if err := writeMCPRegistry(s.opts.DataDir, mcpRegistryEntry("proof.server", "proof_server_2", "read.status", "read_status_2")); err != nil { t.Fatal(err) @@ -47,9 +47,13 @@ func mcpNativeResult(server, tool string, isError bool) map[string]any { } func TestEnvironmentMCPUsesNativeRegistryBeforeResultAndRetainsErrors(t *testing.T) { - for _, failure := range []string{"none", "tool", "transport"} { + for _, failure := range []string{"none", "tool", "transport", "public-none", "public-tool", "public-transport"} { t.Run(failure, func(t *testing.T) { s, out := mcpObservationSession(t) + if strings.HasPrefix(failure, "public-") { + usePublicMCP(s) + failure = strings.TrimPrefix(failure, "public-") + } if err := s.emitTool(toolUpdate{ID: "native-call", Name: "mcp__proof_server_2__read_status_2"}); err != nil { t.Fatal(err) } @@ -120,9 +124,13 @@ func TestEnvironmentMCPIdentityRequiresUniqueCurrentConfiguredAssignment(t *test } func TestEnvironmentMCPResultMustMatchStartAndUnsettledCallsCloseOnce(t *testing.T) { - for _, change := range []string{"server", "tool", "missing-details", "native-name", "cancel"} { + for _, change := range []string{"server", "tool", "missing-details", "native-name", "cancel", "public-cancel"} { t.Run(change, func(t *testing.T) { s, out := mcpObservationSession(t) + if change == "public-cancel" { + usePublicMCP(s) + change = "cancel" + } if err := s.emitTool(toolUpdate{ID: "native-call", Name: "mcp__proof_server_2__read_status_2", RawInput: map[string]any{"key": "value"}}); err != nil { t.Fatal(err) } @@ -185,3 +193,11 @@ func TestEnvironmentMCPNativeJSONRetainsIntegerPrecision(t *testing.T) { t.Fatal("MCP structured number rounded by native observation decoding") } } + +// Both declaration sources must produce the same native observation semantics. +func usePublicMCP(s *Session) { + s.req.LocalEnvironment.MCP = nil + s.req.MCPHTTPServers = &[]proto.MCPHTTPServer{{ + ConnectionOrigin: "environment", ServerLabel: "proof.server", ServerURL: "https://mcp.example.test", + }} +} diff --git a/apps/parsar-daemon/internal/agent/mcode/workspace.go b/apps/parsar-daemon/internal/agent/mcode/workspace.go index d2d53cfce..6372bc059 100644 --- a/apps/parsar-daemon/internal/agent/mcode/workspace.go +++ b/apps/parsar-daemon/internal/agent/mcode/workspace.go @@ -65,6 +65,8 @@ func prepareWorkspaceOptions(ctx context.Context, c WorkspaceConfig, req proto.P // The native process, ACP Session and workspace tools share the declared cwd. private := req private.LocalEnvironment, private.WorkDir, private.DisableExecutionEnvironment = nil, "", true + // Public declarations have already been resolved into the transient ACP map. + private.MCPHTTPServers = nil opts, err := prepareOptionsWithSkills(ctx, private, false) if err != nil { return opts, err diff --git a/apps/parsar-daemon/internal/agent/mcp_binding.go b/apps/parsar-daemon/internal/agent/mcp_binding.go index 4072eb33e..0d8cad5a5 100644 --- a/apps/parsar-daemon/internal/agent/mcp_binding.go +++ b/apps/parsar-daemon/internal/agent/mcp_binding.go @@ -28,17 +28,17 @@ type MCPBinding struct { } // ResolveMCPBindings combines public declarations with the frozen installation. -// The current public profile is service-origin HTTP; a workspace cannot move -// that connection or its credential authority onto the Environment implicitly. +// Public declarations retain explicit origin and Vault authority; installed MCP +// retains Environment configuration authority. Neither may relocate implicitly. func ResolveMCPBindings(req proto.PromptRequestPayload) ([]MCPBinding, error) { var bindings []MCPBinding if req.MCPHTTPServers != nil { - if !req.DisableExecutionEnvironment { - return nil, errors.New("service-origin MCP requires a service execution host") - } bindings = make([]MCPBinding, 0, len(*req.MCPHTTPServers)) for _, server := range *req.MCPHTTPServers { - item := MCPBinding{ServerLabel: server.ServerLabel, ConnectionOrigin: "service", CredentialAuthority: "none", Transport: "http", ServerURL: server.ServerURL, Required: server.Required, BearerToken: server.BearerToken} + if err := server.ValidateConnectionOrigin(req); err != nil { + return nil, err + } + item := MCPBinding{ServerLabel: server.ServerLabel, ConnectionOrigin: server.ConnectionOrigin, CredentialAuthority: "none", Transport: "http", ServerURL: server.ServerURL, Required: server.Required, BearerToken: server.BearerToken} if server.AllowedTools != nil { names := append([]string{}, (*server.AllowedTools)...) item.AllowedTools = &names diff --git a/apps/parsar-daemon/internal/agent/mcp_binding_test.go b/apps/parsar-daemon/internal/agent/mcp_binding_test.go index e4cf7c89d..02febe3a8 100644 --- a/apps/parsar-daemon/internal/agent/mcp_binding_test.go +++ b/apps/parsar-daemon/internal/agent/mcp_binding_test.go @@ -11,7 +11,7 @@ func TestMCPBindingsPreserveOriginAuthorityAndPolicy(t *testing.T) { token := "explicit-token" empty := []string{} for _, allow := range []*[]string{nil, &empty} { - public := []proto.MCPHTTPServer{{ServerLabel: "remote", ServerURL: "https://example.test/mcp", AllowedTools: allow, Required: true, BearerToken: &token}} + public := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "remote", ServerURL: "https://example.test/mcp", AllowedTools: allow, Required: true, BearerToken: &token}} got, err := ResolveMCPBindings(proto.PromptRequestPayload{DisableExecutionEnvironment: true, MCPHTTPServers: &public}) if err != nil || len(got) != 1 { t.Fatal("public binding unavailable", err) @@ -56,7 +56,7 @@ func TestMCPBindingsDistinguishAbsentAndEmptyProfile(t *testing.T) { } func TestMCPBindingsRejectRelocationAndAmbiguousInstallation(t *testing.T) { - public := []proto.MCPHTTPServer{{ServerLabel: "remote", ServerURL: "https://example.test/mcp"}} + public := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "remote", ServerURL: "https://example.test/mcp"}} local := &proto.LocalEnvironment{NetworkAccess: "enabled", MCP: []proto.EnvironmentMCP{{Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.test/mcp"}}}} for _, req := range []proto.PromptRequestPayload{ {MCPHTTPServers: &public, LocalEnvironment: local}, @@ -70,3 +70,37 @@ func TestMCPBindingsRejectRelocationAndAmbiguousInstallation(t *testing.T) { } } } + +func TestPublicEnvironmentMCPRetainsVaultAuthority(t *testing.T) { + token := "selected-vault-canary" + names := []string{"prove"} + public := []proto.MCPHTTPServer{{ConnectionOrigin: "environment", ServerLabel: "remote", ServerURL: "https://example.test/mcp", BearerToken: &token, AllowedTools: &names, Required: true}} + req := proto.PromptRequestPayload{MCPHTTPServers: &public, LocalEnvironment: &proto.LocalEnvironment{NetworkAccess: "enabled"}} + got, err := ResolveMCPBindings(req) + if err != nil || len(got) != 1 { + t.Fatal("environment binding unavailable", err) + } + if got[0].ConnectionOrigin != "environment" || got[0].CredentialAuthority != "project_vault" || !got[0].Required || (*got[0].AllowedTools)[0] != "prove" { + t.Fatal("public policy changed") + } + *got[0].BearerToken = "mutated" + (*got[0].AllowedTools)[0] = "mutated" + if token != "selected-vault-canary" || names[0] != "prove" { + t.Fatal("shared mutable authority") + } + for _, origin := range []string{"service", "", "unknown"} { + public[0].ConnectionOrigin = origin + if _, err := ResolveMCPBindings(req); err == nil { + t.Fatal("origin silently relocated", origin) + } + } + public[0].ConnectionOrigin = "environment" + req.LocalEnvironment.MCP = []proto.EnvironmentMCP{{Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.test/mcp"}}} + if _, err := ResolveMCPBindings(req); err == nil { + t.Fatal("public/Plugin identity collision accepted") + } + req.LocalEnvironment = nil + if _, err := ResolveMCPBindings(req); err == nil { + t.Fatal("unprepared environment accepted") + } +} diff --git a/apps/parsar-daemon/internal/cli/claude_sdk.go b/apps/parsar-daemon/internal/cli/claude_sdk.go index 8fcb330e4..8ede63c61 100644 --- a/apps/parsar-daemon/internal/cli/claude_sdk.go +++ b/apps/parsar-daemon/internal/cli/claude_sdk.go @@ -111,7 +111,7 @@ func discoverClaudeSDK(parent context.Context, rc *runContext, profile string, c out.Info.Capabilities.MCPHTTPTools = info.SupportsHTTPMCP() out.Info.Capabilities.MCPHTTPBearerAuth = info.SupportsHTTPMCPBearer() out.Info.Capabilities.MCPHTTPRequired = info.SupportsHTTPMCPRequired() - if out.Config.Workspace != nil { + if out.Config.Workspace != nil && !info.SupportsWorkspaceMCP() { out.Info.Capabilities.MCPHTTPTools, out.Info.Capabilities.MCPHTTPBearerAuth = false, false out.Info.Capabilities.MCPHTTPRequired = false } diff --git a/apps/parsar-daemon/internal/cli/mcode.go b/apps/parsar-daemon/internal/cli/mcode.go index a8bf3e807..30ea20275 100644 --- a/apps/parsar-daemon/internal/cli/mcode.go +++ b/apps/parsar-daemon/internal/cli/mcode.go @@ -32,6 +32,8 @@ func discoverMCode(parent context.Context, rc *runContext, check func(context.Co // Native preparation verifies the applied admission/tool profile before input. result.Capabilities.SubagentObservations = true result.Capabilities.EnvironmentNone = true + result.Capabilities.MCPHTTPTools = true + result.Capabilities.MCPHTTPBearerAuth = true } fmt.Fprintf(rc.stdout, "mcode preflight ok (%s)\n", version) return result diff --git a/apps/parsar-daemon/internal/dispatch/mcp_http.go b/apps/parsar-daemon/internal/dispatch/mcp_http.go index afb382481..894d9e759 100644 --- a/apps/parsar-daemon/internal/dispatch/mcp_http.go +++ b/apps/parsar-daemon/internal/dispatch/mcp_http.go @@ -12,12 +12,15 @@ func validateMCPHTTP(req proto.PromptRequestPayload, caps proto.AgentKindCapabil if req.MCPHTTPServers == nil { return nil } - if req.LocalEnvironment != nil { - return errors.New("service-side HTTP MCP is not supported with a local Environment") - } for _, server := range *req.MCPHTTPServers { - if server.Required && (!caps.MCPHTTPTools || !caps.MCPHTTPRequired || !req.DisableExecutionEnvironment) { - return errors.New("engine does not support required service-side HTTP MCP initialization") + if err := server.ValidateConnectionOrigin(req); err != nil { + return err + } + if !caps.MCPHTTPTools { + return errors.New("engine does not support HTTP MCP") + } + if server.Required && !caps.MCPHTTPRequired { + return errors.New("engine does not support required HTTP MCP initialization") } if server.BearerToken == nil { continue @@ -25,12 +28,6 @@ func validateMCPHTTP(req proto.PromptRequestPayload, caps proto.AgentKindCapabil if !caps.MCPHTTPTools || !caps.MCPHTTPBearerAuth { return errors.New("engine does not support authenticated HTTP MCP") } - if !req.DisableExecutionEnvironment { - return errors.New("authenticated HTTP MCP requires a supported service-side environment") - } - if !caps.EnvironmentNone { - return errors.New("engine does not support authenticated HTTP MCP with environment:none") - } endpoint, err := url.Parse(server.ServerURL) if err != nil || endpoint.Scheme != "https" || endpoint.Hostname() == "" { return errors.New("authenticated HTTP MCP requires HTTPS") diff --git a/apps/parsar-daemon/internal/dispatch/mcp_http_test.go b/apps/parsar-daemon/internal/dispatch/mcp_http_test.go index 61ee6f0dd..f9021c275 100644 --- a/apps/parsar-daemon/internal/dispatch/mcp_http_test.go +++ b/apps/parsar-daemon/internal/dispatch/mcp_http_test.go @@ -20,7 +20,7 @@ func TestMCPHTTPBearerRejectsUnsupportedRequestsBeforeFactory(t *testing.T) { h := newHarness(t) defer h.router.Shutdown(context.Background()) token := "synthetic-private-token" - servers := []proto.MCPHTTPServer{{ServerLabel: "tools", ServerURL: "https://tools.example/mcp", BearerToken: &token}} + servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "tools", ServerURL: "https://tools.example/mcp", BearerToken: &token}} req := proto.PromptRequestPayload{AgentKind: "codex", DisableExecutionEnvironment: true, MCPHTTPServers: &servers} caps := proto.AgentKindCapabilities{EnvironmentNone: true, MCPHTTPTools: true, MCPHTTPBearerAuth: true} switch mode { @@ -78,19 +78,23 @@ func TestMCPHTTPBearerRejectsUnsupportedRequestsBeforeFactory(t *testing.T) { } } -func TestLocalMCPRejectsBeforePreparationFactory(t *testing.T) { - for _, mode := range []string{"anonymous", "bearer", "required", "empty declaration", "no declaration"} { +func TestLocalMCPOriginAndCapabilityAdmission(t *testing.T) { + for _, mode := range []string{"anonymous", "bearer", "required", "empty declaration", "no declaration", "environment anonymous", "environment bearer", "environment required", "environment missing capability"} { t.Run(mode, func(t *testing.T) { h := localPreparationHarness(t) defer h.router.Shutdown(context.Background()) req := preparationRequest() - servers := []proto.MCPHTTPServer{{ServerLabel: "tools", ServerURL: "https://tools.example/mcp"}} + servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "tools", ServerURL: "https://tools.example/mcp"}} req.Configuration.MCPHTTPServers = &servers - if mode == "bearer" { + if strings.HasPrefix(mode, "environment") { + servers[0].ConnectionOrigin = "environment" + req.Configuration.LocalEnvironment.NetworkAccess = "enabled" + } + if strings.Contains(mode, "bearer") { token := "synthetic-private-token" servers[0].BearerToken = &token } - if mode == "required" { + if strings.Contains(mode, "required") { servers[0].Required = true } if mode == "empty declaration" { @@ -100,7 +104,7 @@ func TestLocalMCPRejectsBeforePreparationFactory(t *testing.T) { req.Configuration.MCPHTTPServers = nil } entered := make(chan struct{}, 1) - h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "prepared", Available: true, Capabilities: proto.AgentKindCapabilities{LocalEnvironment: true, MCPHTTPTools: true, MCPHTTPBearerAuth: true, MCPHTTPRequired: true}}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + h.reg.RegisterKind(proto.SupportedAgentKind{Kind: "prepared", Available: true, Capabilities: proto.AgentKindCapabilities{LocalEnvironment: true, MCPHTTPTools: mode != "environment missing capability", MCPHTTPBearerAuth: true, MCPHTTPRequired: true}}, harnessconfig.Configuration{}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { t.Error("ordinary factory called") return nil, errors.New("unexpected") }) @@ -109,7 +113,7 @@ func TestLocalMCPRejectsBeforePreparationFactory(t *testing.T) { return nil, errors.New("controlled stop") }) err := h.router.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "local-mcp", req)) - allowed := mode == "no declaration" + allowed := mode == "no declaration" || mode == "empty declaration" || strings.HasPrefix(mode, "environment") && mode != "environment missing capability" if (err == nil) != allowed { t.Fatal("wrong preparation admission", err) } diff --git a/contracts/agents-api/README.md b/contracts/agents-api/README.md index 0d07353b9..457b8c267 100644 --- a/contracts/agents-api/README.md +++ b/contracts/agents-api/README.md @@ -480,16 +480,18 @@ operation and placement; native support is not public admission by itself. | Engine | Qualified placements and limits | | --- | --- | -| `codex` (default) | Qualified `none` and Docker `openai_hosted`; public functions with ordered text/image results; service-origin HTTP MCP on `none` only; verbosity follows native policy | -| `claude_sdk` | Qualified `none` and Docker `openai_hosted`; medium verbosity, object-root function schemas and text or successful inline PNG/JPEG results; qualified anonymous/static-bearer service-origin HTTP MCP on `none` | -| `mcode` | Qualified `none` text and Docker `openai_hosted`; medium verbosity; public functions/service-origin MCP, image input and complete public usage breakdown remain unsupported | +| `codex` (default) | Qualified `none` and Docker `openai_hosted`; public functions with ordered text/image results; service-origin HTTP MCP on `none` only; Environment-origin HTTP uses the common workspace path; verbosity follows native policy | +| `claude_sdk` | Qualified `none` and Docker `openai_hosted`; medium verbosity, object-root function schemas and text or successful inline PNG/JPEG results; anonymous/static-bearer HTTP MCP on `none` (service origin) or a workspace (Environment origin), subject to qualification | +| `mcode` | Qualified `none` text and Docker `openai_hosted`; medium verbosity; Environment-origin HTTP MCP with null/omitted allowlist and optional initialization; public functions/service-origin MCP, image input and complete public usage breakdown remain unsupported | All three profiles implement user-managed `self_hosted` enrollment at `/workspace` through our private daemon transport; [separate real acceptance](user-managed-runtime-v1.md) records qualified deployments and limits. A `self_hosted` Session supplies its own model provider in the request or through a saved Agent; deployment defaults apply to `openai_hosted` and `none`, never to `self_hosted` ([model execution](model-execution.md)). Service-origin HTTP MCP is rejected on `self_hosted` and hosted local -placements. This does not remove separately qualified Environment Plugin MCP. +placements. Explicit Environment-origin HTTP declarations use the same Runtime +binding path as Plugin MCP; see the [origin matrix](environments.md#public-mcp-connection-origin) +and [public qualification](public-mcp-qualification.md). The [Docker lifecycle](environments.md#basic-public-docker-hosted-profile) retains workspace Files/Artifacts, cancellation and recovery. Managed isolation belongs to the outer Environment; native tools use the starting account's permissions. @@ -506,7 +508,7 @@ unsupported startup installations, unqualified restricted hostname forms and hos service-origin HTTP MCP remain outside these accepted profiles. Environment-origin MCP Plugins have a separate [Docker qualification and transport matrix](environment-templates.md#environment-origin-mcp-plugins): stdio on all three harnesses, Codex HTTP with literal headers or HTTPS bearer, -and Claude anonymous HTTP or HTTPS bearer without literal headers. This batch +and Claude/MiniMax anonymous HTTP or HTTPS bearer without literal headers. This batch does not qualify those new Plugin paths on E2B. MiniMax's private workspace MCP bridge remains internal transport, distinct from installed Environment MCP servers. diff --git a/contracts/agents-api/environments.md b/contracts/agents-api/environments.md index d14d387cf..8ae8ec6b6 100644 --- a/contracts/agents-api/environments.md +++ b/contracts/agents-api/environments.md @@ -649,9 +649,49 @@ Runtime resolves public HTTP declarations and installed Plugin MCP through retains its connection origin, transport, nullable tool allowlist, required flag, credential authority and installed stdio identity. Bindings are never persisted or logged. Duplicate identities and unavailable selected credentials reject. -A service-origin request cannot silently become an Environment-origin connection; -the existing public HTTP MCP profile remains service-origin `environment:none`. -This internal consolidation does not qualify public `connection_origin=environment`. +Public HTTP MCP uses the same binding path as installed Plugin MCP. Its explicit +`connection_origin` is retained from saved configuration through the Session +snapshot and private Runtime request; the exact Runtime wire version is required. +A service-origin request cannot silently become an Environment-origin connection. + +### Public MCP connection origin + +Core's Harness profile declares `MCPOrigins`; shared admission and dispatch check +the origin against the Environment and Runtime's advertised HTTP/bearer/required +capabilities. Runtime validates the same origin before invoking an adapter. No +Harness-name or Sandbox Provider branch selects a different connection path. + +| Harness | `service` origin | `environment` origin | Optional policy | +| --- | --- | --- | --- | +| Codex | Service execution host, `environment:none` | Managed or self-hosted workspace | Nullable tool allowlist and required initialization | +| Claude SDK | Service execution host, `environment:none` | Managed or self-hosted workspace; packaged `workspace_mcp_http` feature required | Nullable tool allowlist and required initialization | +| MiniMax Code | Unsupported | Managed or self-hosted workspace | `allowed_tools` must be null/omitted; `required` must be false | + +Both origins support the declared Harness's anonymous HTTP and selected HTTPS +bearer path. The existing attached-Vault selection freezes credential identity, +including a unique implicit URL match or an anonymous selection. Only that +Project-authorized credential may enter the transient Runtime request; Core +defaults and unrelated Vaults are not searched. Decryption failure or a missing +credential fails execution without an anonymous fallback. Public Environment +MCP retains `project_vault` authority; Plugin credentials retain +`environment_configuration` authority. Neither source overrides duplicate +server labels. Bearers never enter persisted native configuration or argv. + +Omitted/null origin still means `service`, including on self-hosted requests; +it does not select the local network automatically. Service-origin requests with +a workspace remain rejected because they require separate service-side connection +forwarding. This implementation adds no proxy. Environment origin requires an +initialized workspace with enabled network access and is invalid on `none`. + +Null/omitted `allowed_tools` permits all server tools; an empty list permits none. +MiniMax rejects every non-null allowlist, including an empty list, rather than +silently expanding it. Codex and Claude preserve native allowlists and initialize +required servers before releasing native input, including cold recovery. +Public MCP with native Subagents remains unqualified. Nonempty literal HTTP +headers, request metadata and public stdio declarations remain unsupported. +See [public MCP qualification](https://github.com/MiniMax-AI/parsar-core/blob/e974a7f880a2eb799f0dd39e6ba0870462854a53/contracts/agents-api/public-mcp-qualification.md) for actual model, +platform and infrastructure coverage; admission support is not a claim of +complete cross-platform/provider qualification. Environment-origin literal HTTP headers remain rejected for the pinned Claude and MiniMax clients because their cross-origin forwarding cannot preserve header @@ -659,7 +699,8 @@ authority. MiniMax supports installed stdio and HTTP servers with anonymous or explicit user-selected HTTPS bearer authentication. ACP HTTP declarations remain Session-local native memory; tokens do not enter native configuration files or process arguments. Required initialization and tool allowlists are not exposed -through the Plugin manifest, and public MiniMax MCP remains unqualified. +through the Plugin manifest. Public MiniMax HTTP uses the same transient ACP map +with the stricter admission limits above. MiniMax reads the existing Session-private native runtime-name registry for exact first-frame identities and cross-checks completed native results for both transports. Reuse existing observation and cancellation settlement; never fabricate a delayed diff --git a/contracts/agents-api/execution-tools.md b/contracts/agents-api/execution-tools.md index 1448f3c86..70f2efde8 100644 --- a/contracts/agents-api/execution-tools.md +++ b/contracts/agents-api/execution-tools.md @@ -28,7 +28,8 @@ and Environment Plugin MCP have separate inventories and qualification. | Function image results | Successful ordered inline PNG/JPEG with text, large PNG and image-only JPEG: Codex/Claude `none` F1/F2; Docker M2. Public Items retain submitted bytes. Codex receipt regression: F2. | Claude rejects failed images and remote references before persistence; native resizing may change its image bytes. Self-hosted Claude image results use the same native path; see the current [qualification record](environment-capabilities-qualification.md). MiniMax functions remain unqualified. Other Codex image/error/reference combinations cannot be inferred from successful-inline evidence. | | Deferred function discovery | Type-only `tool_search` plus mixed eager/deferred functions: Claude SDK 0.3.269/native 2.1.269, Kimi K3, single Agent, medium, `none`, text results; text and PNG input D1. Native provider observations establish lazy schema loading for D1. [Self-hosted workspace callback, continuation and cancellation](environment-capabilities-qualification.md) use the same native path; they do not add model-request observer evidence. [Contract](tool-search.md). | A repeated `tool_search` is a protocol error. Codex/MiniMax discovery, search-only/missing-search, workspace with Skills/Plugins, MCP, structured-output and Subagent combinations remain gaps. Opaque native policy changes lack a reliable pre-input deferral signal. Saved tools include `tool_search`; the pinned Session response union excludes it. Exact hosted projection is unverified. | | Explicit disabled search/PTC | Saved and inline `web_search.mode=disabled` and `programmatic_tool_calling.enabled=false`; shared native controls on initial and cold execution. All three harnesses on `none`: P1, including native inventory/control evidence. [Contract](tool-policy.md), [parser](../../services/agents-api/internal/api/disabled_tools.go). | Unsupported explicit enablement rejects at Session admission; saved Agents keep every pinned search mode as resource data (TV-05), and Sessions from such Agents reject unless they replace the tools. A repeated `web_search` is a protocol error. Omission retains approved native behavior, which does not establish official default-on PTC parity. Enabled search and default/error parity remain gaps; unrelated native utilities are not implicitly removed. | -| Agent service-origin MCP | Implemented Codex/Claude HTTP `none` profiles, anonymous/static bearer, scoped Vault selection and native `mcp_call` Items. [Configuration and qualification limits](../../services/agents-api/README.md#http-mcp-execution), [profiles](../../services/agents-api/internal/engine/profile.go). | This closure batch does not requalify MCP/provider combinations. MiniMax, self-hosted/hosted service-origin MCP, OAuth, nonempty inline headers/metadata and other transports/origins reject. Claude requires a static connected inventory; original MCP-envelope fidelity and continuing server health are unverified. | +| Agent service-origin MCP | Implemented Codex/Claude HTTP `none` profiles, anonymous/static bearer, scoped Vault selection and native `mcp_call` Items. [Configuration and qualification limits](../../services/agents-api/README.md#http-mcp-execution), [profiles](../../services/agents-api/internal/engine/profile.go). | This closure batch does not requalify MCP/provider combinations. MiniMax, self-hosted/hosted service-origin MCP, OAuth, nonempty inline headers/metadata and other transports reject. Environment origin follows the separate row below. Claude requires a static connected inventory; original MCP-envelope fidelity and continuing server health are unverified. | +| Agent Environment-origin MCP | Public HTTP declarations reuse installed MCP's effective Runtime bindings; attached Vault selection and native observations remain common. [Origin and Harness matrix](environments.md#public-mcp-connection-origin), [real qualification](https://github.com/MiniMax-AI/parsar-core/blob/e974a7f880a2eb799f0dd39e6ba0870462854a53/contracts/agents-api/public-mcp-qualification.md). | Requires a workspace and enabled network. MiniMax rejects every non-null allowlist and required initialization. No service-origin relocation, automatic fallback or credential copy into native profiles. | | Environment Plugin MCP/native tools | Separate [Docker Plugin transport matrix](environment-templates.md#environment-origin-mcp-plugins) and [V1 deployment evidence](user-managed-runtime-v1.md). Native tools stay within the existing colocated Runtime. | Plugin MCP is not Agent service-origin MCP or a public function action. No new optional cross-product is qualified here. Native utility inventories need not be identical. | | Required action: `function_call` | Session GET/list and Session SSE expose persisted `{arguments,call_id,name,turn_id,type}`. Actions remain pending until native application or cancellation/terminal settlement. [Projection](../../services/agents-api/internal/store/function_state.go), [confirmation](../../services/agents-api/internal/store/function_results.go); real handling F1/F2/M2/S1/S2/D1; explicit client disconnect/query/reconnect: F3. | Function-call history is not pending-state authority. Client reconnect does not replay events or redo external application effects. | | Required action: `environment_connection` | Offline waiting input exposes `{environment_id,type}` before Turn creation. Connect the exact Session Environment through our scoped daemon enrollment. Three-harness Docker/E2B execution: E1; explicit initial pending-input/query/connection/native completion: E2; expiry: [controlled initial-input test](../../services/agents-api/tests/official_self_hosted_initial.py). | Idle offline Sessions without pending input request nothing. Registration alone is not connectivity or native readiness. Stock `exec-server`/Noise transport is outside the approved V1 route. Exact upstream registration/action-removal timing remains unverified. | diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index 2848ed2b4..fbb144445 100644 --- a/contracts/agents-api/harness-onboarding.md +++ b/contracts/agents-api/harness-onboarding.md @@ -304,6 +304,23 @@ same dedicated Runtime binding and shared Files helpers. A native Bash sandbox alone does not establish isolation for other native file tools. Enable a placement only after its required security and lifecycle behavior is demonstrated. +### MCP origin and native limits + +Declare supported public origins in the existing engine profile's `MCPOrigins` +and bearer support in `MCPBearer`. Runtime advertises actual HTTP, bearer and +required-initialization capabilities. Shared admission validates origin and +placement; adapter validation retains native label, allowlist and initialization +limits. These are separate checks, not a second MCP executor. + +Consume `agent.ResolveMCPBindings` for public and installed declarations; preserve +origin, credential authority, null versus empty allowlists and required startup. +Do not copy tokens into native profiles or reinterpret a service request as an +Environment request. Reject unsupported native policies instead of dropping them. +Follow [the MCP origin contract](environments.md#public-mcp-connection-origin) +and run public-client, failure, cancellation and cold-recovery qualification for +each advertised combination. Model capability remains separate from Harness +transport support; never infer it from model names or silently degrade input. + ## Optional Subagent observations A harness that supports the Subagent resource reads implements the existing diff --git a/contracts/agents-api/official-semantics-alignment.md b/contracts/agents-api/official-semantics-alignment.md index 3cc81c16c..22f1ff9a8 100644 --- a/contracts/agents-api/official-semantics-alignment.md +++ b/contracts/agents-api/official-semantics-alignment.md @@ -682,7 +682,7 @@ and read back 404. The error records are `ERR-UNATTACHED` | Row | Case | Core behavior | | --- | --- | --- | -| M1 | HTTP MCP tool with omitted or null `connection_origin`, on a saved Agent, an inline Session agent or a per-Session replacement | Saved and projected as `"service"`. The stored and frozen configuration equals an explicit `service` declaration, so execution is unchanged. Explicit `"environment"` and other transports keep their rejection. | +| M1 | HTTP MCP tool with omitted or null `connection_origin`, on a saved Agent, an inline Session agent or a per-Session replacement | Saved and projected as `"service"`. The stored and frozen configuration equals an explicit `service` declaration, so execution is unchanged. Explicit `"environment"` follows the [qualified Environment MCP contract](environments.md#public-mcp-connection-origin); other transports remain rejected. | | M2 | Session tool without an explicit `credential_id` whose attached credential was selected | Retrieve, list and the created, in-progress and idle event snapshots show the selected credential ID, also after that credential is deleted. Anonymous and unmatched tools stay null; explicit IDs are echoed as sent. | | M3 | `credential_id` with omitted, null or empty `vault_ids` | 400 `invalid_request_error`, null param: "MCP credential_id requires an attached vault". | | M4 | `credential_id` not in an attached Vault: missing, foreign tenant, another Vault of the tenant, or malformed | 400 `invalid_request_error`, null param: "MCP credential_id `` was not found in an attached vault". Byte-identical for one ID across the missing, foreign and unattached cases. | diff --git a/contracts/agents-api/openapi.yaml b/contracts/agents-api/openapi.yaml index f53cc5999..a7100a8de 100644 --- a/contracts/agents-api/openapi.yaml +++ b/contracts/agents-api/openapi.yaml @@ -3012,68 +3012,73 @@ paths: conflict_error. 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 native OAuth login remain unsupported. The self_hosted profile - uses a qualified native harness, a clean absolute workspace_directory and - optional absolute local capability_directories prepared by Runtime, with optional - non-deferred function tools and HTTP MCP using 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; non-string values and limit violations return invalid_request_error - with a metadata or metadata. param. The inline agent uses the Agent create - configuration validation with agent.-prefixed params, reported before the - input requirement and saved-Agent lookup; saved configurations with conflicting - tools or schema roots reject admission with the same errors, and execution - limits keep unsupported_or_invalid_configuration. Hosted network policy rejections - return invalid_request_error with a null param. Initial input accepts a string - or ordered user-message array. Codex and Claude SDK on none and qualified - managed or self_hosted workspace profiles also accept inline PNG/JPEG image - content; other image combinations and remote URLs are unsupported. 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. Initial input is required - for none and for streamed creation outside self_hosted. Omitted/null input - remains valid for non-streaming hosted and self_hosted creation. With stream=true, - returns live Session events starting with the committed creation snapshot - and closes right after the first agent.session.idle recorded when a Turn ends - or an input reservation stops being pending, or any agent.session.failed, - without sending later events. A creation that admitted nothing closes after - the snapshot; a settlement that records no event closes after events up to - the cursor read with a settled Session projection. Required actions keep it - open; disconnect does not cancel execution. The GET events stream remains - live-only. 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; new hosted inline - requests also freeze caller intent before deployment defaults resolve; unrelated - non-hosted inline retries preserve resolved/default equivalences, and their - resolved hash leaves out any deployment default. Provider keys enter retry - hashes only as fingerprints keyed by the credential key. 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. - A same-key stream=true retry of an existing creation returns 201 with no events - and closes at once; retry with stream=false or use the GET events stream to - recover. Claude SDK on none, Core-managed Docker openai_hosted and self_hosted - supports qualified object-root json_schema output with medium verbosity, single-Agent - execution and ordinary functions. Hosted execution reuses native workspace - tools and Files/Artifacts; Skills, Plugins, capability directories, HTTP MCP, - Subagent and tool_search combinations remain unqualified, including inherited - template contents. Other 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 or successful inline PNG/JPEG results alongside native workspace - tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; + exact hosted creation timing and error parity remain unverified. Explicit + environment-origin HTTP MCP is supported on managed and self-hosted workspaces + through the same Runtime bindings; native OAuth login remains unsupported. + The self_hosted profile uses a qualified native harness, a clean absolute + workspace_directory and optional absolute local capability_directories prepared + by Runtime, with optional non-deferred function tools and HTTP MCP using explicit + environment origin, optionally authenticated by the attached Vault rules. + Service-origin HTTP remains restricted to service-side environment:none. 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; non-string values + and limit violations return invalid_request_error with a metadata or metadata. + param. The inline agent uses the Agent create configuration validation with + agent.-prefixed params, reported before the input requirement and saved-Agent + lookup; saved configurations with conflicting tools or schema roots reject + admission with the same errors, and execution limits keep unsupported_or_invalid_configuration. + Hosted network policy rejections return invalid_request_error with a null + param. Initial input accepts a string or ordered user-message array. Codex + and Claude SDK on none and qualified managed or self_hosted workspace profiles + also accept inline PNG/JPEG image content; other image combinations and remote + URLs are unsupported. 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. Initial input is required for none and for streamed creation outside + self_hosted. Omitted/null input remains valid for non-streaming hosted and + self_hosted creation. With stream=true, returns live Session events starting + with the committed creation snapshot and closes right after the first agent.session.idle + recorded when a Turn ends or an input reservation stops being pending, or + any agent.session.failed, without sending later events. A creation that admitted + nothing closes after the snapshot; a settlement that records no event closes + after events up to the cursor read with a settled Session projection. Required + actions keep it open; disconnect does not cancel execution. The GET events + stream remains live-only. 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; new hosted inline requests also freeze caller intent before deployment + defaults resolve; unrelated non-hosted inline retries preserve resolved/default + equivalences, and their resolved hash leaves out any deployment default. Provider + keys enter retry hashes only as fingerprints keyed by the credential key. + 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. A same-key stream=true retry of an existing + creation returns 201 with no events and closes at once; retry with stream=false + or use the GET events stream to recover. Claude SDK on none, Core-managed + Docker openai_hosted and self_hosted supports qualified object-root json_schema + output with medium verbosity, single-Agent execution and ordinary functions. + Hosted execution reuses native workspace tools and Files/Artifacts; Skills, + Plugins, capability directories, HTTP MCP, Subagent and tool_search combinations + remain unqualified, including inherited template contents. Other 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 or successful inline + PNG/JPEG results alongside native workspace tools; explicit environment-origin + HTTP MCP uses the common Runtime path. MiniMax accepts public environment-origin + HTTP MCP only with null or omitted allowed_tools and required=false; even + an empty non-null allowlist rejects. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted policies reject before compute allocation because the current Runtime cannot enforce them. The x_agents_core.environment diff --git a/contracts/agents-api/public-mcp-qualification.md b/contracts/agents-api/public-mcp-qualification.md new file mode 100644 index 000000000..6070fc0a0 --- /dev/null +++ b/contracts/agents-api/public-mcp-qualification.md @@ -0,0 +1,91 @@ +# Public Environment-origin MCP qualification + +This record covers explicit public HTTP MCP declarations through the pinned +official client, Core, Runtime and native Harness adapters. The +[Environment contract](environments.md#public-mcp-connection-origin) owns origin, +credential and policy semantics. Earlier Plugin-only evidence does not qualify +this public entrypoint. + +## Linux execution evidence (2026-09-30) + +Tests use real Kimi K3 through Codex 0.153.4 and Claude SDK 0.3.269, and real +MiniMax-M2.7 through MiniMax Code 0.4.12. Each successful invocation obtains a +fresh random proof from the actual MCP HTTPS server; the model must return both +anonymous and authenticated proofs. Tests use public Session, input, Turn and +Items APIs. Authentication uses an explicitly selected credential from an +attached Project Vault. The anonymous server rejects nonempty Authorization. + +| Harness | User-managed Linux | Core-managed Docker | +| --- | --- | --- | +| Codex | Anonymous/bearer, cold continuation, cancellation, tool failure | Same workflow passed | +| Claude SDK | Anonymous/bearer, cold continuation, cancellation, tool failure | Same workflow passed | +| MiniMax Code | Anonymous/bearer, cold continuation, cancellation, tool failure | Same workflow passed | + +Codex and Claude declare a named tool allowlist and required initialization. +The fixture also offers an excluded tool; native inventory/call authorization +must preserve the allowlist. Entry-point tests additionally cover an empty +allowlist, unavailable required servers and holding input until initialization. +MiniMax uses null allowlists and required=false; raw HTTP and adapter tests +reject both an empty allowlist and required=true. + +After stopping and restarting the daemon (or managed container), the same public +Session continues. Cancellation interrupts an in-flight native MCP call and +settles the Turn as cancelled. A controlled MCP isError response produces a +failed MCP Item while the model can finish its Turn. Codex exposes that failure +with output and a nullable error field; requiring error to be non-null was a +test assertion error, corrected by checking the durable public failed status. + +Raw HTTP and official SDK Session creation preserve environment origin. Negative +cases reject service-origin relocation, an unattached selected credential and +MiniMax's unsupported policies. Store tests also verify anonymous and unique +implicit credential selection without writes on rejection. Native state scans +check for the selected bearer after normal calls, cold continuation and +cancellation; it must not appear in native files. + +## Reproducible evidence + +Source revisions used during acceptance: + +- Core: 3448797d33026734d7f1f39d8b201d1af6cf7317, including the common + preparation-failure fix from main. +- Runtime executable: 04fee4e3b2a4c44f124438b03dddb1be836f38ad for Codex/Claude; + 04883f329ebebf17e35944e39b6ca854eedb061d for final MiniMax observation qualification. +- Claude bridge: cb5a42253cac16921e17e115669cdc87798a3294. +- Private Runtime protocol: 0.10.0 on both peers. + +These are development artifacts assembled from the recorded revisions, not a +published distribution. The acceptance image includes a private test CA and a +test model-network proxy. The final MiniMax fixture makes the test CA readable +by the native process; earlier root-only certificate copies did not qualify. +It is not a release image. Only owned acceptance +infrastructure was created or restarted; existing deployments and histories +were retained. + +| Evidence | Public Session ID | +| --- | --- | +| Codex self-hosted | b7300fae-6e33-4ce7-8345-d69824986c5c | +| Claude self-hosted | ce1e7e3e-37d4-439a-b017-688523fec37f | +| MiniMax self-hosted | 89a63c73-dec5-49ef-974c-1548bbb82cd1 | +| Codex managed | 23389dd4-2afb-495b-9b14-c403362f5100 | +| Claude managed | 14e5a213-cd9a-4989-89d2-1b814a653395 | +| MiniMax managed | 633a8bc6-1c8c-4674-bb8d-e47425d2abac | + +Private scripts, identities, durable Turn records, native-state checks and build +logs are retained under ~/.oac/acceptance/public-mcp-20260930 on the Linux +acceptance host. Credentials are excluded from repository evidence. + +## Limits and checks + +Real macOS/Windows model execution and E2B/microsandbox qualification are not +claimed by this batch. Service-origin workspace forwarding, public stdio, +literal headers/metadata, public functions and Subagent/MCP combinations are +outside scope. MiniMax's non-null allowlists and required initialization remain +explicitly unsupported. There is no service-network proxy or model-loop fallback. + +Focused Core/Runtime tests, 170 Claude adapter tests and the pinned official +client workflow pass. Full make check, native platform CI and independent review +results are recorded on [PR #251](https://github.com/MiniMax-AI/parsar-core/pull/251). +The first local full run stopped because its new test database did not match the +required oac_*_tests naming rule. A later run passed Go/database/adapter checks +but reached an occupied browser fixture port; remaining checks use separate +ports. These interrupted attempts are not successful full gates. diff --git a/docs/runtime-protocol.md b/docs/runtime-protocol.md index c40367646..4828bed2a 100644 --- a/docs/runtime-protocol.md +++ b/docs/runtime-protocol.md @@ -522,3 +522,14 @@ failure settles Environment input without destroying the machine or workspace. The public `connected` state describes transport; initialization completion and native executor readiness remain separate prerequisites for execution. Input sources and frozen metadata follow the [Environment contract](../contracts/agents-api/environments.md#runtime-capability-preparation). + + +### MCP connection authority + +Public `MCPHTTPServer` messages carry an explicit `connection_origin`; missing or +unknown values reject rather than selecting a default. Core freezes the public +default before dispatch. Both peers require the exact wire version. Runtime uses +the common origin validator before selecting a factory and resolves public and +installed MCP into transient effective bindings. See the +[origin and credential contract](../contracts/agents-api/environments.md#public-mcp-connection-origin) +for supported combinations, native limits and failure ownership. diff --git a/internal/agentdaemon/gateway/mcp_bearer_live_linux_test.go b/internal/agentdaemon/gateway/mcp_bearer_live_linux_test.go index c9045ffdc..72035cc5f 100644 --- a/internal/agentdaemon/gateway/mcp_bearer_live_linux_test.go +++ b/internal/agentdaemon/gateway/mcp_bearer_live_linux_test.go @@ -81,7 +81,7 @@ func TestLiveMCPBearerGatewayColdContinuation(t *testing.T) { time.Sleep(50 * time.Millisecond) } allowed, anonymousTools := []string{"remember", "fail"}, []string{"ping"} - servers := []proto.MCPHTTPServer{{ServerLabel: "private_mcp", ServerURL: fixture.private.URL, AllowedTools: &allowed, BearerToken: &token}, {ServerLabel: "anonymous_mcp", ServerURL: fixture.anonymous.URL, AllowedTools: &anonymousTools}} + servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "private_mcp", ServerURL: fixture.private.URL, AllowedTools: &allowed, BearerToken: &token}, {ConnectionOrigin: "service", ServerLabel: "anonymous_mcp", ServerURL: fixture.anonymous.URL, AllowedTools: &anonymousTools}} run := func(prompt, resume string, expected map[string]string) *mcpBearerTurn { t.Helper() turn := &mcpBearerTurn{} diff --git a/internal/agentdaemon/proto/mcp.go b/internal/agentdaemon/proto/mcp.go index 5fbf86774..79af7876e 100644 --- a/internal/agentdaemon/proto/mcp.go +++ b/internal/agentdaemon/proto/mcp.go @@ -1,13 +1,33 @@ package proto -// MCPHTTPServer declares HTTP tools on the trusted harness host. Send only to a +import "errors" + +// MCPHTTPServer declares HTTP tools with an explicit outbound connection origin. Send only to a // peer advertising mcp_http_tools; a transient BearerToken additionally requires // mcp_http_bearer_auth. Required initialization requires mcp_http_required. // Never persist or log this private request as configuration. type MCPHTTPServer struct { - ServerLabel string `json:"server_label"` - ServerURL string `json:"server_url"` - AllowedTools *[]string `json:"allowed_tools"` - Required bool `json:"required,omitempty"` - BearerToken *string `json:"bearer_token,omitempty"` + ConnectionOrigin string `json:"connection_origin"` + ServerLabel string `json:"server_label"` + ServerURL string `json:"server_url"` + AllowedTools *[]string `json:"allowed_tools"` + Required bool `json:"required,omitempty"` + BearerToken *string `json:"bearer_token,omitempty"` +} + +// ValidateConnectionOrigin rejects relocation before dispatch and adapter setup. +func (server MCPHTTPServer) ValidateConnectionOrigin(req PromptRequestPayload) error { + switch server.ConnectionOrigin { + case "service": + if !req.DisableExecutionEnvironment || req.LocalEnvironment != nil { + return errors.New("service-origin MCP requires a service execution host") + } + case "environment": + if req.DisableExecutionEnvironment || req.LocalEnvironment == nil || req.LocalEnvironment.NetworkAccess != "enabled" { + return errors.New("environment MCP requires an enabled workspace network") + } + default: + return errors.New("MCP requires an explicit connection origin") + } + return nil } diff --git a/internal/agentdaemon/proto/outbound.go b/internal/agentdaemon/proto/outbound.go index 3a99400f9..c9cb483e2 100644 --- a/internal/agentdaemon/proto/outbound.go +++ b/internal/agentdaemon/proto/outbound.go @@ -67,7 +67,7 @@ type PromptRequestPayload struct { // ExecutionControls are authoritative engine-neutral settings, translated by the adapter. ExecutionControls *ExecutionControls `json:"execution_controls,omitempty"` - // MCPHTTPServers replaces MCP configuration for the service-side HTTP profile. + // MCPHTTPServers supplies public HTTP declarations with explicit connection origins. // Nil preserves existing behavior; an empty list explicitly declares no servers. MCPHTTPServers *[]MCPHTTPServer `json:"mcp_http_servers,omitempty"` diff --git a/internal/agentdaemon/proto/version.go b/internal/agentdaemon/proto/version.go index bc752f8d0..a2214276f 100644 --- a/internal/agentdaemon/proto/version.go +++ b/internal/agentdaemon/proto/version.go @@ -2,7 +2,7 @@ package proto // Version identifies the complete Core–Runtime wire contract. Change it when // removing or changing a payload or its semantics; deploy both endpoints together. -const Version = "0.9.0" +const Version = "0.10.0" // VersionCompatible accepts only this contract. Patch drift, prerelease suffixes // and malformed versions do not select an implicit compatibility path. diff --git a/packages/claude-sdk-adapter/src/adapter.ts b/packages/claude-sdk-adapter/src/adapter.ts index 0cdf3e4be..e0375740f 100644 --- a/packages/claude-sdk-adapter/src/adapter.ts +++ b/packages/claude-sdk-adapter/src/adapter.ts @@ -95,11 +95,11 @@ export async function execute(request: Start | Prepare | ExecutorPrepare, emit: systemPrompt: request.system_prompt, ...(request.resume ? { resume: request.resume } : {}), tools: subagents ? ["Agent", "SendMessage"] : request.tool_search ? ["ToolSearch"] : [], allowedTools: profile?.allowed ?? allowed, strictMcpConfig: true, settingSources: [], - ...(profile && !workspace ? { + ...(profile ? { agent: "oac_root", disallowedTools: profile.denied, hooks: { PreToolUse: [{ hooks: [profile.beforeTool] }] }, agents: { oac_root: { description: "Execution root.", prompt: request.system_prompt, - model: request.model, tools: profile.allowed } }, + model: request.model, tools: [...(Array.isArray(workspace?.options.tools) ? workspace.options.tools : []), ...profile.allowed] } }, } : {}), persistSession: true, includePartialMessages: true, abortController: abort, canUseTool: async () => ({ behavior: "deny", message: "Tools are unavailable in this execution profile." }), @@ -141,7 +141,7 @@ export async function execute(request: Start | Prepare | ExecutorPrepare, emit: reads.bind(stream, request.cwd); if (process.platform === "linux") await directories.bind(request.cwd); } - if (request.mcp_http_servers?.some(server=>server.required)) profile?.verifyRequired(await stream.mcpServerStatus()); + if (declarations?.some(server => "required" in server && server.required)) profile?.verifyRequired(await stream.mcpServerStatus()); if(turns) { turns.configure(stream,(input,output)=>{ inputs=new Inputs(input); @@ -164,7 +164,7 @@ export async function execute(request: Start | Prepare | ExecutorPrepare, emit: stream = warm.query(turns ?? 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 (declarations?.some(server => "required" in server && server.required)) profile.verifyRequired(await stream.mcpServerStatus()); if (abort.signal.aborted || !nativeAlive) throw new Error("MCP initialization interrupted"); inputs.release(request.input); } else stream = query({ prompt: inputs, options }); diff --git a/packages/claude-sdk-adapter/src/mcp_environment.ts b/packages/claude-sdk-adapter/src/mcp_environment.ts index 4484c1674..b949b6e7c 100644 --- a/packages/claude-sdk-adapter/src/mcp_environment.ts +++ b/packages/claude-sdk-adapter/src/mcp_environment.ts @@ -28,7 +28,6 @@ export function parseEnvironmentMCP(value: unknown): EnvironmentMCPServer[] | un } else { parseHTTPServers([server]); - if (server.allowed_tools !== null || server.required !== undefined) throw new Error("invalid_request"); } labels.add(server.server_label); } diff --git a/packages/claude-sdk-adapter/src/runtime_check.ts b/packages/claude-sdk-adapter/src/runtime_check.ts index 3571f1dfc..730030d6d 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: 3, features: ["executor_reuse", ...(["linux", "darwin", "win32"].includes(process.platform) ? ["workspace_directory", "local_runtime_v2", "workspace_functions", "workspace_structured_output", "workspace_tool_search"] : []), "message_images", "function_result_images", "tool_search", "structured_output", "subagent_resources", "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"); + process.stdout.write(JSON.stringify({ type: "runtime_ready", protocol: 3, features: ["executor_reuse", ...(["linux", "darwin", "win32"].includes(process.platform) ? ["workspace_directory", "local_runtime_v2", "workspace_functions", "workspace_structured_output", "workspace_tool_search", "workspace_mcp_http"] : []), "message_images", "function_result_images", "tool_search", "structured_output", "subagent_resources", "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_required.test.mjs b/packages/claude-sdk-adapter/tests/mcp_required.test.mjs index f48b7dc36..40dcb9b4c 100644 --- a/packages/claude-sdk-adapter/tests/mcp_required.test.mjs +++ b/packages/claude-sdk-adapter/tests/mcp_required.test.mjs @@ -10,10 +10,15 @@ const fixture = ` import assert from "node:assert/strict"; import { registerHooks } from "node:module"; const mode=process.argv[1]; +const workspace=mode.startsWith("workspace-"); +const baseline=workspace?["Bash","Read","Edit"]:[]; 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.deepEqual(options.tools,baseline); + assert.equal(options.agent,"oac_root"); + assert.deepEqual(options.agents.oac_root.tools,[...baseline,...(mode==="workspace-empty"?[]:["mcp__fixture__echo"])]); + assert.deepEqual(options.disallowedTools,mode==="workspace-empty"?["mcp__fixture__*","mcp__optional__*"]:["mcp__optional__*"]);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"}); @@ -37,7 +42,7 @@ function create(options){ 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[0].text}); - yield {type:"system",subtype:"init",session_id:mode==="wrong-history"?"foreign":"native",tools:["mcp__fixture__echo"],mcp_servers:[]}; + yield {type:"system",subtype:"init",session_id:mode==="wrong-history"?"foreign":"native",tools:[...baseline,...(mode==="workspace-empty"?[]:["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:{}}; } }; @@ -49,10 +54,10 @@ 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"]) { +for (const mode of ["connected", "pending", "failed", "missing", "duplicate", "missing-hooks", "cancelled", "resume", "wrong-history", "missing-history", "optional", "workspace-connected", "workspace-empty"]) { test(`required MCP entrypoint holds input through readiness: ${mode}`, async () => { const cwd = mkdtempSync(join(tmpdir(), "oac-required-")); - const child = spawn(process.execPath, ["--input-type=module", "-e", fixture, mode], { stdio: ["pipe", "pipe", "pipe", "ipc"] }); + const child = spawn(process.execPath, ["--input-type=module", "-e", fixture, mode], { stdio: ["pipe", "pipe", "pipe", "ipc"], env: {...process.env,HOME:cwd,CLAUDE_CONFIG_DIR:cwd} }); const observations = []; let stdout = "", stderr = ""; child.stdout.on("data", b => { stdout += b; }); @@ -61,15 +66,16 @@ for (const mode of ["connected", "pending", "failed", "missing", "duplicate", "m const closed = new Promise(resolve => child.once("close", (code, signal) => resolve({ code, signal }))); const timer = setTimeout(() => child.kill("SIGKILL"), 8000); try { + const servers = [{ server_label: "fixture", server_url: "https://example.invalid/mcp", allowed_tools: mode === "workspace-empty" ? [] : ["echo"], required: mode !== "optional" }, + { server_label: "optional", server_url: "https://optional.invalid/mcp", allowed_tools: [], required: false }]; child.stdin.write(JSON.stringify({ type: "start", model: "fixed", input: [{ content: [{ type: "input_text", text: "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"); + ...(mode.startsWith("workspace-") ? {workspace:{home:cwd,state:cwd,scratch:cwd,capability_root:cwd,env_names:[],network_access:"enabled",mcp:servers}} : {mcp_http_servers:servers}) }) + "\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); + const success = ["connected", "resume", "optional", "workspace-connected", "workspace-empty"].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); diff --git a/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs b/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs index 80cc7bd77..49bc7e805 100644 --- a/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs +++ b/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs @@ -121,3 +121,23 @@ test("MCP identity validation preserves host functions and ordinary child enviro assert.equal((await workspace.canUseTool(functions[0], {}, { signal })).behavior, "allow"); mcp.close(); }); + +test("public workspace HTTP preserves required startup and null versus empty allowlists", t => { + const http = {server_label:"remote",server_url:"https://example.invalid/mcp",allowed_tools:[],required:true}; + const {dirs,config,request}=fixture(t,[http]); + assert.deepEqual(parseStart(JSON.stringify(request)),request); + for(const allowed_tools of [null,[],["prove"]]) { + const declaration={...http,allowed_tools}; + assert.deepEqual(parseEnvironmentMCP([declaration]),[declaration]); + const mcp=new MCPProfile([declaration],[]); + const workspace=new WorkspaceProfile(dirs.work,{...config,mcp:[declaration]},[],mcp); + assert.throws(()=>mcp.verifyRequired([{name:"remote",status:"pending"}]),/required/); + const statuses=[{name:"remote",status:"connected",tools:[{name:"prove"},{name:"other"}]}]; + mcp.verifyRequired(statuses); + const selected=allowed_tools===null?["prove","other"]:allowed_tools; + workspace.verify([...baseline,...selected.map(name=>"mcp__remote__"+name)],statuses,"session"); + assert.equal(mcp.permits("mcp__remote__prove"),allowed_tools===null||allowed_tools.includes("prove")); + assert.equal(mcp.permits("mcp__remote__other"),allowed_tools===null); + mcp.close(); + } +}); diff --git a/scripts/check-claude-sdk-runtime.mjs b/scripts/check-claude-sdk-runtime.mjs index 37327baab..9675881e2 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, 3); -assert.deepEqual(report.features, ["executor_reuse", ...(["linux", "darwin", "win32"].includes(process.platform) ? ["workspace_directory", "local_runtime_v2", "workspace_functions", "workspace_structured_output", "workspace_tool_search"] : []), "message_images", "function_result_images", "tool_search", "structured_output", "subagent_resources", "mcp_http_tools", "mcp_http_bearer_auth", "mcp_http_required", "workspace_tools", "workspace_prepare", "workspace_read", "workspace_command_observations"]); +assert.deepEqual(report.features, ["executor_reuse", ...(["linux", "darwin", "win32"].includes(process.platform) ? ["workspace_directory", "local_runtime_v2", "workspace_functions", "workspace_structured_output", "workspace_tool_search", "workspace_mcp_http"] : []), "message_images", "function_result_images", "tool_search", "structured_output", "subagent_resources", "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/README.md b/services/agents-api/README.md index 1e5000398..3e238afbc 100644 --- a/services/agents-api/README.md +++ b/services/agents-api/README.md @@ -554,10 +554,12 @@ restart/history and credential lifecycle evidence, with its recorded revision li ### HTTP MCP execution -This section covers `agent.tools` with `connection_origin: "service"`. An omitted -or null origin on HTTP transport is saved as `"service"`, exactly like the explicit -form. Environment-origin Plugin declarations use the separate -[initialization and transport contract](../../contracts/agents-api/environment-templates.md#environment-origin-mcp-plugins). +Public `agent.tools` declares an explicit MCP connection origin. Omitted/null +origin remains `service`. The [origin, credential and Harness matrix](../../contracts/agents-api/environments.md#public-mcp-connection-origin) +owns the supported combinations: Codex/Claude service HTTP on `none`, and +Codex/Claude/MiniMax Environment HTTP on managed or user-owned workspaces, subject +to declared native limits. Public declarations and installed Plugin MCP converge +on the same Runtime effective bindings; they retain distinct credential authority. Service-origin MCP runs on trusted service-side compute. Codex supports `environment:{"type":"none"}`; Claude SDK supports HTTP MCP with `environment:{"type":"none"}`. Inline or saved Agent tools may declare: diff --git a/services/agents-api/internal/api/handler.go b/services/agents-api/internal/api/handler.go index dc3c51a26..13edad72c 100644 --- a/services/agents-api/internal/api/handler.go +++ b/services/agents-api/internal/api/handler.go @@ -164,7 +164,7 @@ func (h *Handler) routes() *chi.Mux { // createSession atomically reserves or admits initial text with the Session. // @Summary Create an execution Session -// @Description The optional Core model_provider bundle resolves from the Session override, saved Agent defaults, then, for openai_hosted and none, the deployment default of the resolved harness; self_hosted never uses the deployment default and none accepts only it. openai_hosted and self_hosted Sessions that resolve no bundle return 400 model_provider_required with param x_agents_core.model_provider before any write. Core encrypts and freezes the resolved bundle; later Agent or deployment default edits and same-key retries cannot change it. Keys are never returned. 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, adapter-qualified multi_agent with persisted Subagent reads, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with service origin (omitted or null on HTTP transport is saved as service), native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer or OAuth credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Session reads, lists and event snapshots show that implicitly selected credential ID in a null or omitted credential_id, also after the credential is deleted; anonymous selections stay null and the stored caller intent is unchanged. After the input requirement and before any write, a credential_id without vault_ids, one outside the attached Vaults (one message for missing, foreign and unattached IDs) or one for another server_url returns 400 invalid_request_error, and several implicit matches return 409 conflict_error. 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 native OAuth login remain unsupported. The self_hosted profile uses a qualified native harness, a clean absolute workspace_directory and optional absolute local capability_directories prepared by Runtime, with optional non-deferred function tools and HTTP MCP using 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; non-string values and limit violations return invalid_request_error with a metadata or metadata. param. The inline agent uses the Agent create configuration validation with agent.-prefixed params, reported before the input requirement and saved-Agent lookup; saved configurations with conflicting tools or schema roots reject admission with the same errors, and execution limits keep unsupported_or_invalid_configuration. Hosted network policy rejections return invalid_request_error with a null param. Initial input accepts a string or ordered user-message array. Codex and Claude SDK on none and qualified managed or self_hosted workspace profiles also accept inline PNG/JPEG image content; other image combinations and remote URLs are unsupported. 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. Initial input is required for none and for streamed creation outside self_hosted. Omitted/null input remains valid for non-streaming hosted and self_hosted creation. With stream=true, returns live Session events starting with the committed creation snapshot and closes right after the first agent.session.idle recorded when a Turn ends or an input reservation stops being pending, or any agent.session.failed, without sending later events. A creation that admitted nothing closes after the snapshot; a settlement that records no event closes after events up to the cursor read with a settled Session projection. Required actions keep it open; disconnect does not cancel execution. The GET events stream remains live-only. 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; new hosted inline requests also freeze caller intent before deployment defaults resolve; unrelated non-hosted inline retries preserve resolved/default equivalences, and their resolved hash leaves out any deployment default. Provider keys enter retry hashes only as fingerprints keyed by the credential key. 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. A same-key stream=true retry of an existing creation returns 201 with no events and closes at once; retry with stream=false or use the GET events stream to recover. Claude SDK on none, Core-managed Docker openai_hosted and self_hosted supports qualified object-root json_schema output with medium verbosity, single-Agent execution and ordinary functions. Hosted execution reuses native workspace tools and Files/Artifacts; Skills, Plugins, capability directories, HTTP MCP, Subagent and tool_search combinations remain unqualified, including inherited template contents. Other 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 or successful inline PNG/JPEG 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 and restricted policies reject before compute allocation because the current Runtime cannot enforce them. The x_agents_core.environment extension accepts common preparation fields for either hosted or self-hosted placement: environment_template_id, files, env, packages, setup_commands, skills, plugins and capability_directories. Duplicate fields in environment and the extension reject. Confidential env, npm/Python packages and ordered setup commands use the same Environment-owned initialization lifecycle; compute allocation does not own preparation. Unknown side effects are not replayed after disconnect or restart. System dependencies must be preinstalled in the sandbox image or template, or on the host machine; packages.system is rejected. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. With a template reference, omitted/null files, env, packages and setup_commands inherit. Non-null files and command lists replace; env overlays by key; each package manager inherits on omission/null and otherwise replaces its list. Empty lists clear their selected field. Tenant-owned environment_template_id references inherit omitted/null network and allow only narrowing overrides. Inline hosted network:null retains the enabled default; updating a Template with network:null resets its saved policy to enabled. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. Inline or tenant-owned skill_reference Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill, Plugin and capability-directory list omission/null inherit; a non-null list replaces, including empty-list clearing. Omitted/null Skill version selectors resolve the default version. Source deletion/default updates cannot change committed Session Skill contents. Deferred function discovery uses type-only tool_search and per-function defer_loading in the qualified single-agent Claude function profile on none or a managed/user-owned workspace, including qualified inline image messages and text results. Explicit web_search mode disabled and programmatic_tool_calling enabled false use frozen common Runtime controls. Enabled forms, including those saved on an Agent, remain unqualified and reject before any write unless the Session replaces tools. Omitted programmatic configuration preserves native behavior, a documented difference from the official default-on behavior. Other combinations remain unqualified; see the operation coverage. +// @Description The optional Core model_provider bundle resolves from the Session override, saved Agent defaults, then, for openai_hosted and none, the deployment default of the resolved harness; self_hosted never uses the deployment default and none accepts only it. openai_hosted and self_hosted Sessions that resolve no bundle return 400 model_provider_required with param x_agents_core.model_provider before any write. Core encrypts and freezes the resolved bundle; later Agent or deployment default edits and same-key retries cannot change it. Keys are never returned. 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, adapter-qualified multi_agent with persisted Subagent reads, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with service origin (omitted or null on HTTP transport is saved as service), native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer or OAuth credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Session reads, lists and event snapshots show that implicitly selected credential ID in a null or omitted credential_id, also after the credential is deleted; anonymous selections stay null and the stored caller intent is unchanged. After the input requirement and before any write, a credential_id without vault_ids, one outside the attached Vaults (one message for missing, foreign and unattached IDs) or one for another server_url returns 400 invalid_request_error, and several implicit matches return 409 conflict_error. 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. Explicit environment-origin HTTP MCP is supported on managed and self-hosted workspaces through the same Runtime bindings; native OAuth login remains unsupported. The self_hosted profile uses a qualified native harness, a clean absolute workspace_directory and optional absolute local capability_directories prepared by Runtime, with optional non-deferred function tools and HTTP MCP using explicit environment origin, optionally authenticated by the attached Vault rules. Service-origin HTTP remains restricted to service-side environment:none. 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; non-string values and limit violations return invalid_request_error with a metadata or metadata. param. The inline agent uses the Agent create configuration validation with agent.-prefixed params, reported before the input requirement and saved-Agent lookup; saved configurations with conflicting tools or schema roots reject admission with the same errors, and execution limits keep unsupported_or_invalid_configuration. Hosted network policy rejections return invalid_request_error with a null param. Initial input accepts a string or ordered user-message array. Codex and Claude SDK on none and qualified managed or self_hosted workspace profiles also accept inline PNG/JPEG image content; other image combinations and remote URLs are unsupported. 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. Initial input is required for none and for streamed creation outside self_hosted. Omitted/null input remains valid for non-streaming hosted and self_hosted creation. With stream=true, returns live Session events starting with the committed creation snapshot and closes right after the first agent.session.idle recorded when a Turn ends or an input reservation stops being pending, or any agent.session.failed, without sending later events. A creation that admitted nothing closes after the snapshot; a settlement that records no event closes after events up to the cursor read with a settled Session projection. Required actions keep it open; disconnect does not cancel execution. The GET events stream remains live-only. 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; new hosted inline requests also freeze caller intent before deployment defaults resolve; unrelated non-hosted inline retries preserve resolved/default equivalences, and their resolved hash leaves out any deployment default. Provider keys enter retry hashes only as fingerprints keyed by the credential key. 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. A same-key stream=true retry of an existing creation returns 201 with no events and closes at once; retry with stream=false or use the GET events stream to recover. Claude SDK on none, Core-managed Docker openai_hosted and self_hosted supports qualified object-root json_schema output with medium verbosity, single-Agent execution and ordinary functions. Hosted execution reuses native workspace tools and Files/Artifacts; Skills, Plugins, capability directories, HTTP MCP, Subagent and tool_search combinations remain unqualified, including inherited template contents. Other 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 or successful inline PNG/JPEG results alongside native workspace tools; explicit environment-origin HTTP MCP uses the common Runtime path. MiniMax accepts public environment-origin HTTP MCP only with null or omitted allowed_tools and required=false; even an empty non-null allowlist rejects. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted policies reject before compute allocation because the current Runtime cannot enforce them. The x_agents_core.environment extension accepts common preparation fields for either hosted or self-hosted placement: environment_template_id, files, env, packages, setup_commands, skills, plugins and capability_directories. Duplicate fields in environment and the extension reject. Confidential env, npm/Python packages and ordered setup commands use the same Environment-owned initialization lifecycle; compute allocation does not own preparation. Unknown side effects are not replayed after disconnect or restart. System dependencies must be preinstalled in the sandbox image or template, or on the host machine; packages.system is rejected. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. With a template reference, omitted/null files, env, packages and setup_commands inherit. Non-null files and command lists replace; env overlays by key; each package manager inherits on omission/null and otherwise replaces its list. Empty lists clear their selected field. Tenant-owned environment_template_id references inherit omitted/null network and allow only narrowing overrides. Inline hosted network:null retains the enabled default; updating a Template with network:null resets its saved policy to enabled. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. Inline or tenant-owned skill_reference Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill, Plugin and capability-directory list omission/null inherit; a non-null list replaces, including empty-list clearing. Omitted/null Skill version selectors resolve the default version. Source deletion/default updates cannot change committed Session Skill contents. Deferred function discovery uses type-only tool_search and per-function defer_loading in the qualified single-agent Claude function profile on none or a managed/user-owned workspace, including qualified inline image messages and text results. Explicit web_search mode disabled and programmatic_tool_calling enabled false use frozen common Runtime controls. Enabled forms, including those saved on an Agent, remain unqualified and reject before any write unless the Session replaces tools. Omitted programmatic configuration preserves native behavior, a documented difference from the official default-on behavior. Other combinations remain unqualified; see the operation coverage. // @Tags Sessions // @Accept json // @Produce json,text/event-stream diff --git a/services/agents-api/internal/api/mcp_configuration.go b/services/agents-api/internal/api/mcp_configuration.go index 6e70ead4b..ba113326a 100644 --- a/services/agents-api/internal/api/mcp_configuration.go +++ b/services/agents-api/internal/api/mcp_configuration.go @@ -30,8 +30,8 @@ func resolveMCPTool(raw json.RawMessage, saved bool) (json.RawMessage, error) { service := "service" input.ConnectionOrigin = &service } - if *input.ConnectionOrigin != "service" { - return nil, errors.New("MCP currently requires explicit connection_origin=service.") + if *input.ConnectionOrigin != "service" && *input.ConnectionOrigin != "environment" { + return nil, errors.New("MCP connection_origin must be service or environment.") } if input.CredentialID != nil && *input.CredentialID == "" { return nil, errors.New("MCP credential_id must be null or a nonempty string.") @@ -77,7 +77,7 @@ func resolveMCPTool(raw json.RawMessage, saved bool) (json.RawMessage, error) { } tool := v1.MCPTool{Type: "mcp", ServerLabel: *input.ServerLabel, Transport: v1.MCPHTTPTransport{Type: "http", ServerURL: *transport.ServerURL}, - AllowedTools: allowed, Required: required, ConnectionOrigin: "service", CredentialID: input.CredentialID, RequestMetadata: map[string]json.RawMessage{}} + AllowedTools: allowed, Required: required, ConnectionOrigin: *input.ConnectionOrigin, CredentialID: input.CredentialID, RequestMetadata: map[string]json.RawMessage{}} if saved { headers := map[string]string{} tool.Transport.Headers = &headers diff --git a/services/agents-api/internal/api/mcp_configuration_test.go b/services/agents-api/internal/api/mcp_configuration_test.go index ad0da63a1..49ecc87e7 100644 --- a/services/agents-api/internal/api/mcp_configuration_test.go +++ b/services/agents-api/internal/api/mcp_configuration_test.go @@ -99,7 +99,7 @@ func TestMCPAllowedToolsAndOptionalFields(t *testing.T) { func TestMCPUnsupportedInputsAreSecretSafe(t *testing.T) { for name, replacement := range map[string]map[string]json.RawMessage{ - "environment origin": {"connection_origin": json.RawMessage(`"environment"`)}, + "unknown origin": {"connection_origin": json.RawMessage(`"unknown"`)}, "stdio origin missing": {"connection_origin": nil, "transport": json.RawMessage(`{"type":"stdio","command":"private-marker"}`)}, "stdio origin null": {"connection_origin": json.RawMessage("null"), "transport": json.RawMessage(`{"type":"stdio","command":"private-marker"}`)}, "origin missing, case": {"connection_origin": nil, "transport": json.RawMessage(`{"Type":"http","server_url":"https://mcp.example.test"}`)}, @@ -153,3 +153,19 @@ func TestSessionMCPKeepsToolOrderAndStripsSavedHeaders(t *testing.T) { t.Fatal("duplicate MCP server labels were admitted") } } + +func TestPublicEnvironmentMCPPreservesDeclaration(t *testing.T) { + for _, saved := range []bool{false, true} { + for _, allowed := range []string{"null", "[]", `["prove"]`} { + raw := []byte(`{"type":"mcp","server_label":"remote","connection_origin":"environment","transport":{"type":"http","server_url":"https://example.test/mcp"},"allowed_tools":` + allowed + `,"required":true,"credential_id":"selected"}`) + resolved, err := resolveMCPTool(raw, saved) + if err != nil { + t.Fatal(err) + } + var got map[string]json.RawMessage + if json.Unmarshal(resolved, &got) != nil || string(got["connection_origin"]) != `"environment"` || string(got["allowed_tools"]) != allowed || string(got["credential_id"]) != `"selected"` || string(got["required"]) != "true" { + t.Fatal("public declaration changed") + } + } + } +} diff --git a/services/agents-api/internal/api/session_tools.go b/services/agents-api/internal/api/session_tools.go index 912f99508..b97e8de2e 100644 --- a/services/agents-api/internal/api/session_tools.go +++ b/services/agents-api/internal/api/session_tools.go @@ -72,7 +72,7 @@ func resolveSessionTools(input []json.RawMessage) ([]json.RawMessage, error) { functions = append(functions, function) positions = append(positions, i) default: - return nil, errors.New("Unsupported execution tool; supported tools include functions, qualified tool_search, service-origin HTTP MCP and explicit disabled controls.") + return nil, errors.New("Unsupported execution tool; supported tools include functions, qualified tool_search, qualified HTTP MCP and explicit disabled controls.") } } resolved, err := resolveFunctions(functions) diff --git a/services/agents-api/internal/engine/claude.go b/services/agents-api/internal/engine/claude.go index 876eea4ff..c7ffca9a2 100644 --- a/services/agents-api/internal/engine/claude.go +++ b/services/agents-api/internal/engine/claude.go @@ -14,6 +14,7 @@ import ( func claudeProfile() Profile { return Profile{ ProgrammaticToolCallingDisable: true, + MCPOrigins: []string{"service", "environment"}, StructuredOutput: true, ToolSearch: true, MessageImages: true, @@ -83,9 +84,6 @@ func validateClaudeConfiguration(agent v1.Agent, environment *v1.Environment, ha } func validateClaudeTools(environment *v1.Environment, _ bool, tools []proto.FunctionTool, mcp []proto.MCPHTTPServer) error { - if environment.Type != "none" && len(mcp) != 0 { - return errors.New("The configured workspace profile does not support HTTP MCP tools.") - } if err := validateClaudeMCP(mcp); err != nil { return err } diff --git a/services/agents-api/internal/engine/codex.go b/services/agents-api/internal/engine/codex.go index 6e767264d..399a99219 100644 --- a/services/agents-api/internal/engine/codex.go +++ b/services/agents-api/internal/engine/codex.go @@ -1,27 +1,19 @@ package engine import ( - "errors" - v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" ) func codexProfile() Profile { return Profile{ ProgrammaticToolCallingDisable: true, Placements: []string{"none", "self_hosted", "openai_hosted"}, + MCPOrigins: []string{"service", "environment"}, MessageImages: true, WhitespaceOnlyText: true, WebSearchControl: true, TextVerbosity: true, MCPBearer: true, ValidateConfiguration: func(agent v1.Agent, _ *v1.Environment, _ bool) error { return rejectSubagentTools(agent, "function", "mcp") }, - ValidateTools: func(environment *v1.Environment, hasDaemon bool, _ []proto.FunctionTool, mcp []proto.MCPHTTPServer) error { - if len(mcp) != 0 && (environment == nil || environment.Type != "none" || hasDaemon) { - return errors.New("HTTP MCP execution currently requires the Codex service-side environment:none profile") - } - return nil - }, } } diff --git a/services/agents-api/internal/engine/mcode.go b/services/agents-api/internal/engine/mcode.go index 1215be755..9f3d50e5e 100644 --- a/services/agents-api/internal/engine/mcode.go +++ b/services/agents-api/internal/engine/mcode.go @@ -11,14 +11,19 @@ import ( // WhitespaceOnlyText stays unqualified: the native runtime refuses such prompts // with "Local message content or attachments are required.". func mcodeProfile() Profile { - return Profile{ProgrammaticToolCallingDisable: true, Placements: []string{"none", "openai_hosted", "self_hosted"}, ValidateConfiguration: func(a v1.Agent, e *v1.Environment, daemon bool) error { + return Profile{MCPOrigins: []string{"environment"}, MCPBearer: true, ProgrammaticToolCallingDisable: true, Placements: []string{"none", "openai_hosted", "self_hosted"}, ValidateConfiguration: func(a v1.Agent, e *v1.Environment, daemon bool) error { if e == nil || (e.Type != "none" && e.Type != "openai_hosted" && e.Type != "self_hosted") || daemon || strings.TrimSpace(a.Model) == "" || a.Reasoning.Effort != nil || a.Reasoning.Summary != nil || (a.ServiceTier != "" && a.ServiceTier != "auto") || (a.Text.Format.Type != "" && a.Text.Format.Type != "text") || (a.Text.Verbosity != "" && a.Text.Verbosity != "medium") { return ErrInvalidInput } - return nil + return rejectSubagentTools(a, "mcp") }, ValidateTools: func(_ *v1.Environment, _ bool, functions []proto.FunctionTool, mcp []proto.MCPHTTPServer) error { - if len(functions) > 0 || len(mcp) > 0 { - return errors.New("The configured engine does not support public functions or MCP tools.") + if len(functions) > 0 { + return errors.New("The configured engine does not support public functions.") + } + for _, server := range mcp { + if server.ServerLabel == "oac_workspace" || server.AllowedTools != nil || server.Required { + return errors.New("The configured engine requires an unreserved MCP label, allowed_tools=null and required=false.") + } } return nil }} diff --git a/services/agents-api/internal/engine/mcp.go b/services/agents-api/internal/engine/mcp.go new file mode 100644 index 000000000..c5c7caa4f --- /dev/null +++ b/services/agents-api/internal/engine/mcp.go @@ -0,0 +1,32 @@ +package engine + +import ( + "errors" + "slices" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// ValidateMCPOrigins preserves outbound authority independently of Harness names. +// Workspace connections never stand in for service-network connections. +func (p Profile) ValidateMCPOrigins(environment *v1.Environment, hasDaemon bool, servers []proto.MCPHTTPServer) error { + for _, server := range servers { + switch server.ConnectionOrigin { + case "service": + if environment == nil || environment.Type != "none" || hasDaemon { + return errors.New("Service-origin MCP requires the service-side environment:none profile.") + } + case "environment": + if environment == nil || (environment.Type != "openai_hosted" && environment.Type != "self_hosted") || hasDaemon { + return errors.New("Environment-origin MCP requires a managed or self-hosted execution Environment.") + } + default: + return errors.New("MCP requires an explicit connection origin.") + } + if !slices.Contains(p.MCPOrigins, server.ConnectionOrigin) { + return errors.New("The configured engine does not support this MCP connection origin.") + } + } + return nil +} diff --git a/services/agents-api/internal/engine/mcp_test.go b/services/agents-api/internal/engine/mcp_test.go new file mode 100644 index 000000000..be70bad26 --- /dev/null +++ b/services/agents-api/internal/engine/mcp_test.go @@ -0,0 +1,56 @@ +package engine + +import ( + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "testing" +) + +func TestMCPOriginQualification(t *testing.T) { + for _, kind := range []string{"codex", "claude_sdk", "mcode"} { + profile, ok := (Catalog{}).Lookup(kind) + if !ok { + t.Fatal(kind) + } + for _, placement := range []string{"none", "self_hosted", "openai_hosted"} { + for _, origin := range []string{"service", "environment", "", "unknown"} { + servers := []proto.MCPHTTPServer{{ConnectionOrigin: origin, ServerLabel: "proof", ServerURL: "https://example.test/mcp"}} + allowed := origin == "environment" && placement != "none" || origin == "service" && placement == "none" && kind != "mcode" + if err := profile.ValidateMCPOrigins(&v1.Environment{Type: placement}, false, servers); (err == nil) != allowed { + t.Fatalf("%s/%s/%s: %v", kind, placement, origin, err) + } + if profile.ValidateMCPOrigins(&v1.Environment{Type: placement}, true, servers) == nil { + t.Fatal("legacy daemon placement admitted") + } + } + } + } +} +func TestMiniMaxMCPPoliciesRejectInsteadOfDropping(t *testing.T) { + p, _ := (Catalog{}).Lookup("mcode") + empty := []string{} + named := []string{"proof"} + for _, allowed := range []*[]string{nil, &empty, &named} { + for _, required := range []bool{false, true} { + server := proto.MCPHTTPServer{ConnectionOrigin: "environment", ServerLabel: "proof", ServerURL: "https://example.test", AllowedTools: allowed, Required: required} + err := p.ValidateTools(&v1.Environment{Type: "self_hosted"}, false, nil, []proto.MCPHTTPServer{server}) + if (err == nil) != (allowed == nil && !required) { + t.Fatal("unsupported MCP policy accepted", err) + } + } + } +} +func TestMCPOriginCatalogIsImmutable(t *testing.T) { + p := Profile{MCPOrigins: []string{"environment"}} + c := NewCatalog(map[string]Profile{"fixture": p}) + p.MCPOrigins[0] = "service" + first, _ := c.Lookup("fixture") + if first.MCPOrigins[0] != "environment" { + t.Fatal("mutable source") + } + first.MCPOrigins[0] = "service" + second, _ := c.Lookup("fixture") + if second.MCPOrigins[0] != "environment" { + t.Fatal("mutable lookup") + } +} diff --git a/services/agents-api/internal/engine/profile.go b/services/agents-api/internal/engine/profile.go index 1e9ad9fb2..223d1a6bb 100644 --- a/services/agents-api/internal/engine/profile.go +++ b/services/agents-api/internal/engine/profile.go @@ -15,6 +15,7 @@ var ErrInvalidInput = errors.New("invalid engine configuration") type Profile struct { ProgrammaticToolCallingDisable bool Placements []string + MCPOrigins []string WebSearchControl, TextVerbosity, MCPBearer bool StructuredOutput bool ToolSearch bool @@ -42,6 +43,7 @@ func NewCatalog(profiles map[string]Profile) Catalog { c := Catalog{profiles: make(map[string]Profile, len(profiles))} for kind, profile := range profiles { profile.Placements = slices.Clone(profile.Placements) + profile.MCPOrigins = slices.Clone(profile.MCPOrigins) c.profiles[kind] = profile } return c @@ -53,6 +55,7 @@ func (c Catalog) Lookup(kind string) (Profile, bool) { } profile, ok := c.profiles[kind] profile.Placements = slices.Clone(profile.Placements) + profile.MCPOrigins = slices.Clone(profile.MCPOrigins) return profile, ok } diff --git a/services/agents-api/internal/execution/engine_profile.go b/services/agents-api/internal/execution/engine_profile.go index 4ea65aca7..b54c2903a 100644 --- a/services/agents-api/internal/execution/engine_profile.go +++ b/services/agents-api/internal/execution/engine_profile.go @@ -32,6 +32,9 @@ func validateProfileConfiguration(profile engine.Profile, snapshot Snapshot) err return err } if err == nil { + if err := profile.ValidateMCPOrigins(snapshot.Environment, snapshot.Daemon != nil, tools.MCP); err != nil { + return err + } if tools.DisableProgrammatic && !profile.ProgrammaticToolCallingDisable { return errors.New("Disabling programmatic tool calling is not qualified for this engine.") } diff --git a/services/agents-api/internal/execution/mcp.go b/services/agents-api/internal/execution/mcp.go index 4fc1a481c..ddc4d45e1 100644 --- a/services/agents-api/internal/execution/mcp.go +++ b/services/agents-api/internal/execution/mcp.go @@ -74,7 +74,7 @@ func executionTools(raw []json.RawMessage) (executionToolSet, error) { var tool v1.MCPTool decoder := json.NewDecoder(bytes.NewReader(value)) decoder.DisallowUnknownFields() - if decoder.Decode(&tool) != nil || strings.TrimSpace(tool.ServerLabel) == "" || names[tool.ServerLabel] || tool.ConnectionOrigin != "service" || len(tool.RequestMetadata) != 0 || tool.Transport.Type != "http" || tool.Transport.Headers != nil { + if decoder.Decode(&tool) != nil || strings.TrimSpace(tool.ServerLabel) == "" || names[tool.ServerLabel] || (tool.ConnectionOrigin != "service" && tool.ConnectionOrigin != "environment") || len(tool.RequestMetadata) != 0 || tool.Transport.Type != "http" || tool.Transport.Headers != nil { return executionToolSet{}, errors.New("unsupported execution MCP configuration") } u, err := url.Parse(tool.Transport.ServerURL) @@ -89,7 +89,7 @@ func executionTools(raw []json.RawMessage) (executionToolSet, error) { } } names[tool.ServerLabel] = true - servers = append(servers, proto.MCPHTTPServer{ServerLabel: tool.ServerLabel, + servers = append(servers, proto.MCPHTTPServer{ConnectionOrigin: tool.ConnectionOrigin, ServerLabel: tool.ServerLabel, ServerURL: tool.Transport.ServerURL, AllowedTools: tool.AllowedTools, Required: tool.Required}) } resolved, err := functionTools(functions) diff --git a/services/agents-api/internal/execution/mcp_support.go b/services/agents-api/internal/execution/mcp_support.go index 764f23cec..40d7d5061 100644 --- a/services/agents-api/internal/execution/mcp_support.go +++ b/services/agents-api/internal/execution/mcp_support.go @@ -26,8 +26,12 @@ func (p Policy) mcpExecutionCredentials(engine string, snapshot Snapshot, server fail := func(message string) (map[string]store.MCPCredentialBinding, error) { return nil, errors.New(message) } - if len(servers) > 0 && (!caps.MCPHTTPTools || snapshot.Environment == nil || snapshot.Environment.Type != "none" || snapshot.Daemon != nil) { - return fail("device must support the service-side HTTP MCP profile") + profile, _ := p.Engines.Lookup(engine) + if err := profile.ValidateMCPOrigins(snapshot.Environment, snapshot.Daemon != nil, servers); err != nil { + return nil, err + } + if len(servers) > 0 && !caps.MCPHTTPTools { + return fail("device must advertise mcp_http_tools") } selected, err := p.mcpCredentialBindings(engine, snapshot) if err != nil { diff --git a/services/agents-api/internal/execution/mcp_support_test.go b/services/agents-api/internal/execution/mcp_support_test.go index bae016a74..cdb05d639 100644 --- a/services/agents-api/internal/execution/mcp_support_test.go +++ b/services/agents-api/internal/execution/mcp_support_test.go @@ -48,7 +48,7 @@ func TestMCPPublicBearerPolicyIsIndependentOfRuntimeCapabilities(t *testing.T) { if allowed && err.Error() != "authenticated MCP execution is unavailable" { t.Fatal("accepted profile did not reach scoped credential lookup", err) } - if !allowed && err.Error() != "The configured engine currently supports anonymous HTTP MCP only." { + if !allowed && err.Error() != "The configured engine does not support this MCP connection origin." { t.Fatal("unverified profile bypassed public policy", err) } }) diff --git a/services/agents-api/internal/store/environment_mcp_public_test.go b/services/agents-api/internal/store/environment_mcp_public_test.go new file mode 100644 index 000000000..07d5fa283 --- /dev/null +++ b/services/agents-api/internal/store/environment_mcp_public_test.go @@ -0,0 +1,74 @@ +package store_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestPublicEnvironmentMCPUsesAttachedVaultSelection(t *testing.T) { + for _, kind := range []string{"codex", "claude_sdk", "mcode"} { + t.Run(kind, func(t *testing.T) { + s, pool, tenant, vault, credential := selfHostedMCPAdmissionFixture(t) + auth, err := newTestAuthenticator([]testAPIKey{{OrganizationID: "test-org", ProjectID: tenant, SubjectKind: "service_account", SubjectID: "test", TenantID: tenant, TokenSHA256: device.HashCredential("test-token")}}) + if err != nil { + t.Fatal(err) + } + handler, err := api.NewHandler(s, auth, kind, api.WithEnvironmentRemoteURL("https://executor.example"), api.WithExecution(&execution.Worker{})) + if err != nil { + t.Fatal(err) + } + send := func(origin string, vaults []string, cred any, allowed any, required bool) *httptest.ResponseRecorder { + tool := map[string]any{"type": "mcp", "server_label": "proof", "connection_origin": origin, "credential_id": cred, "allowed_tools": allowed, "required": required, "transport": map[string]string{"type": "http", "server_url": "https://tools.example/mcp"}} + body := map[string]any{"agent": map[string]any{"model": "model", "tools": []any{tool}}, "environment": map[string]string{"type": "self_hosted", "workspace_directory": "/workspace"}, "vault_ids": vaults, "x_agents_core": map[string]any{"model_provider": store.FixtureModelProvider(kind)}} + raw, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(string(raw))) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("OpenAI-Beta", "agents=v1") + req.Header.Set("Content-Type", "application/json") + out := httptest.NewRecorder() + handler.ServeHTTP(out, req) + if strings.Contains(out.Body.String(), "synthetic-token") || strings.Contains(out.Body.String(), "ciphertext") { + t.Fatal("credential disclosed") + } + return out + } + for _, r := range []*httptest.ResponseRecorder{send("service", []string{vault.ID}, credential.ID, nil, false), send("environment", []string{}, credential.ID, nil, false)} { + if r.Code != 400 { + t.Fatal("unsupported origin or unattached credential admitted", r.Code, r.Body) + } + } + if kind == "mcode" { + for _, r := range []*httptest.ResponseRecorder{send("environment", []string{vault.ID}, credential.ID, []string{}, false), send("environment", []string{vault.ID}, credential.ID, nil, true)} { + if r.Code != 400 { + t.Fatal("unsupported native policy admitted", r.Code, r.Body) + } + } + } + assertSelfHostedMCPRejectionHasNoWrites(t, pool, tenant) + for _, r := range []*httptest.ResponseRecorder{send("environment", []string{}, nil, nil, false), send("environment", []string{vault.ID}, credential.ID, nil, false), send("environment", []string{vault.ID}, nil, nil, false)} { + if r.Code != 201 { + t.Fatal("qualified public MCP rejected", r.Code, r.Body) + } + var session struct { + Agent struct { + Tools []struct { + ConnectionOrigin string `json:"connection_origin"` + CredentialID *string `json:"credential_id"` + } + } + } + if json.Unmarshal(r.Body.Bytes(), &session) != nil || len(session.Agent.Tools) != 1 || session.Agent.Tools[0].ConnectionOrigin != "environment" { + t.Fatal("public origin not retained") + } + } + }) + } +} diff --git a/services/agents-api/tests/official_agents.py b/services/agents-api/tests/official_agents.py index 291baaad0..c193d82f3 100644 --- a/services/agents-api/tests/official_agents.py +++ b/services/agents-api/tests/official_agents.py @@ -165,13 +165,19 @@ def verify_agents(client, other, invalid, expect_error): assert raw.post(base, json={"model": "x"}).json()["error"]["code"] == "invalid_beta" assert raw.post(base, headers={"OpenAI-Beta": "agents=v1"}, json={"model": "x"}).status_code == 401 # The minimal pinned MCP tool saves its omitted origin as "service" (MV-01); - # the environment origin remains an explicit gap. + # explicit environment origin is retained independently of execution placement. mcp = {"type": "mcp", "server_label": "x", "transport": {"type": "http", "server_url": "https://example.invalid"}} minimal = agents.with_raw_response.create(model="x", tools=[mcp]).http_response.json() assert minimal["tools"] == [{**mcp, "transport": {**mcp["transport"], "headers": {}}, "connection_origin": "service", "allowed_tools": None, "credential_id": None, "request_metadata": {}, "required": False}] assert agents.delete(minimal["id"]).deleted - expect_error(BadRequestError, lambda: agents.create(model="x", tools=[{**mcp, "connection_origin": "environment"}])) + environment_mcp = agents.create(model="x", tools=[{**mcp, "connection_origin": "environment"}]) + assert environment_mcp.tools[0].connection_origin == "environment" + assert agents.retrieve(environment_mcp.id).tools == environment_mcp.tools + assert raw.get(base + "/" + environment_mcp.id, headers=headers).json()["tools"][0]["connection_origin"] == "environment" + assert agents.delete(environment_mcp.id).deleted + expect_error(BadRequestError, lambda: agents.create(model="x", tools=[{**mcp, "connection_origin": "unknown"}])) + expect_error(BadRequestError, lambda: agents.create(model="x", tools=[{**mcp, "transport": {"type": "stdio", "command": "unqualified"}}])) # Every pinned web_search mode is saved as the official service does (TV-05); # omitted or null mode is saved as live. A supplied location, including {}, # has all four keys (req_db41d2f6261b4abfb69465eafe719ab5, diff --git a/services/agents-api/tests/official_mcp.py b/services/agents-api/tests/official_mcp.py index 5823fd69f..716073371 100644 --- a/services/agents-api/tests/official_mcp.py +++ b/services/agents-api/tests/official_mcp.py @@ -11,26 +11,29 @@ def verify_mcp_configuration(client, other, expect_error): tool = {"type": "mcp", "server_label": "tickets", "transport": transport, "connection_origin": "service"} recovered, saved = [], [] - for allow, readiness in product( + model_override = {"x_agents_core": {"model_provider": {"protocol": "responses", "base_url": "https://model.invalid/v1", "api_key": "synthetic-mcp-model-key"}}} + for origin, allow, readiness in product( + ("service", "environment"), ({}, {"allowed_tools": None}, {"allowed_tools": []}, {"allowed_tools": ["lookup_ticket"]}), ({}, {"required": False}, {"required": True}), ): - declared = {**tool, **allow, **readiness} + declared = {**tool, "connection_origin": origin, **allow, **readiness} + placement = {"type": "none"} if origin == "service" else {"type": "self_hosted", "workspace_directory": "/tmp/oac-official-mcp"} response = agents.with_raw_response.create(model="requested-model", tools=[declared]) resource, body = response.parse(), response.http_response.json() canonical = {**declared, "allowed_tools": allow.get("allowed_tools"), "credential_id": None, "request_metadata": {}, "required": readiness.get("required", False), "transport": {**transport, "headers": {}}} assert body["tools"] == [canonical] - spec = {"input": "Verify mcp fixture admission.", "agent_id": resource.id, "environment": {"type": "none"}} + spec = {"input": "Verify mcp fixture admission.", "agent_id": resource.id, "environment": placement, "extra_body": model_override if origin == "environment" else {}} headers = {"Idempotency-Key": "mcp-snapshot-" + resource.id} response = sessions.with_raw_response.create(**spec, extra_headers=headers) session, body = response.parse(), response.http_response.json() assert body["agent"]["tools"] == [{**canonical, "transport": transport}] expect_error(NotFoundError, lambda: other.beta.agents.sessions.create(**spec)) assert sessions.create(agent={"model": "requested-model", "tools": [declared]}, - input="Verify mcp fixture admission.", environment={"type": "none"}).agent.tools == session.agent.tools + input="Verify mcp fixture admission.", environment=placement, extra_body=model_override if origin == "environment" else {}).agent.tools == session.agent.tools override = sessions.create(**spec, agent={"tools": []}) assert override.agent.tools == [] changed = agents.update(resource.id, tools=[]) @@ -61,7 +64,9 @@ def verify_mcp_configuration(client, other, expect_error): before = {item.id for item in sessions.list()} saved_before = {item.id for item in agents.list()} - invalid = [{**tool, "connection_origin": "environment"}] + expect_error(BadRequestError, lambda: sessions.create(agent={"model": "requested-model", "tools": [{**tool, "connection_origin": "environment"}]}, environment={"type": "none"})) + expect_error(BadRequestError, lambda: sessions.create(agent={"model": "requested-model", "tools": [tool]}, environment={"type": "self_hosted", "workspace_directory": "/tmp/oac-official-mcp"}, extra_body=model_override)) + invalid = [{**tool, "connection_origin": "unknown"}] invalid += [{**tool, "required": "true"}, {**tool, "required": None}, {**tool, "request_metadata": {"x": "y"}}, {**tool, "allowed_tools": [None]}]