From a27248c129199c9d3f9491ec4679bd7e25d5b193 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 19 Oct 2026 15:56:02 +0000 Subject: [PATCH 1/2] Pass MCP headers through to every Harness --- apps/daemon/internal/agent/claudesdk/mcp.go | 17 +++++++++++------ .../internal/agent/claudesdk/mcp_environment.go | 7 +------ .../agent/claudesdk/mcp_environment_test.go | 2 +- .../agent/codex/mcp_environment_test.go | 9 +++++++++ .../internal/agent/mcode/environment_mcp.go | 8 +++++--- .../agent/mcode/environment_mcp_test.go | 17 ++++++++++++----- apps/daemon/internal/agent/mcp_binding.go | 2 +- contracts/agents-api/environments.md | 2 +- contracts/agents-api/execution-tools.md | 2 +- contracts/agents-api/harness-capabilities.md | 2 +- contracts/agents-api/index.md | 3 ++- contracts/agents-api/wire-semantics.md | 2 +- contracts/agents-api/zh/environments.md | 4 ++-- contracts/agents-api/zh/execution-tools.md | 4 ++-- contracts/agents-api/zh/harness-capabilities.md | 4 ++-- contracts/agents-api/zh/index.md | 5 +++-- contracts/agents-api/zh/wire-semantics.md | 4 ++-- docs/runtime-protocol.md | 2 +- docs/zh/runtime-protocol.md | 4 ++-- internal/agentdaemon/proto/mcp.go | 2 ++ internal/agentdaemon/proto/version.go | 2 +- internal/harnessconfig/claudesdk/declaration.go | 14 ++++++++++++++ .../harnessconfig/claudesdk/declaration_test.go | 11 +++++++++++ packages/claude-sdk-adapter/src/mcp.ts | 9 ++++++--- .../tests/mcp_bearer.test.mjs | 9 ++++++++- services/core/internal/api/mcp_configuration.go | 13 ++++++++----- .../core/internal/api/mcp_configuration_test.go | 13 +++++++++++++ services/core/internal/execution/mcp.go | 8 ++++++-- 28 files changed, 129 insertions(+), 52 deletions(-) diff --git a/apps/daemon/internal/agent/claudesdk/mcp.go b/apps/daemon/internal/agent/claudesdk/mcp.go index 726b6da61..af8a08533 100644 --- a/apps/daemon/internal/agent/claudesdk/mcp.go +++ b/apps/daemon/internal/agent/claudesdk/mcp.go @@ -12,6 +12,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + declaration "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/claudesdk" ) var mcpLabel = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) @@ -39,6 +40,9 @@ func validateMCPServers(servers []proto.MCPHTTPServer) error { if server.BearerToken != nil && (endpoint.Scheme != "https" || !agent.ValidMCPHTTPBearerToken(*server.BearerToken)) { return fmt.Errorf("claudesdk: unsupported HTTPS MCP bearer credential") } + if declaration.ExpandsEnvironment(server.Headers) { + return fmt.Errorf("claudesdk: MCP header values cannot contain ${") + } labels[server.ServerLabel] = true } return nil @@ -47,11 +51,12 @@ func validateMCPServers(servers []proto.MCPHTTPServer) error { // Only generated references cross the private bridge; secrets stay in the owned // process environment and are expanded by the native HTTP client. type mcpHTTPServer struct { - ServerLabel string `json:"server_label"` - ServerURL string `json:"server_url,omitempty"` - AllowedTools *[]string `json:"allowed_tools"` - Required bool `json:"required,omitempty"` - BearerTokenEnvVar string `json:"bearer_token_env_var,omitempty"` + ServerLabel string `json:"server_label"` + ServerURL string `json:"server_url,omitempty"` + AllowedTools *[]string `json:"allowed_tools"` + Required bool `json:"required,omitempty"` + BearerTokenEnvVar string `json:"bearer_token_env_var,omitempty"` + Headers map[string]string `json:"headers,omitempty"` } func prepareMCPHTTP(declarations *[]proto.MCPHTTPServer) (*[]mcpHTTPServer, []string) { @@ -61,7 +66,7 @@ func prepareMCPHTTP(declarations *[]proto.MCPHTTPServer) (*[]mcpHTTPServer, []st servers := make([]mcpHTTPServer, len(*declarations)) var env []string for i, declaration := range *declarations { - server := mcpHTTPServer{ServerLabel: declaration.ServerLabel, ServerURL: declaration.ServerURL, Required: declaration.Required} + server := mcpHTTPServer{ServerLabel: declaration.ServerLabel, ServerURL: declaration.ServerURL, Required: declaration.Required, Headers: declaration.Headers} if declaration.AllowedTools != nil { tools := append([]string{}, (*declaration.AllowedTools)...) server.AllowedTools = &tools diff --git a/apps/daemon/internal/agent/claudesdk/mcp_environment.go b/apps/daemon/internal/agent/claudesdk/mcp_environment.go index 5c9e1db31..76cdaf4db 100644 --- a/apps/daemon/internal/agent/claudesdk/mcp_environment.go +++ b/apps/daemon/internal/agent/claudesdk/mcp_environment.go @@ -32,12 +32,7 @@ func prepareRuntimeMCP(req proto.PromptRequestPayload) ([]environmentMCPServer, servers = append(servers, environmentMCPServer{mcpHTTPServer: mcpHTTPServer{ServerLabel: binding.ServerLabel}, Command: command, Args: args}) continue } - // Native header interpolation and redirect behavior cannot preserve literal - // custom-header authority. Reject this unqualified combination explicitly. - if len(binding.HTTPHeaders) != 0 { - return nil, nil, fmt.Errorf("claudesdk: literal MCP HTTP headers are not supported") - } - declarations := []proto.MCPHTTPServer{{ServerLabel: binding.ServerLabel, ServerURL: binding.ServerURL, AllowedTools: binding.AllowedTools, Required: binding.Required, BearerToken: binding.BearerToken}} + declarations := []proto.MCPHTTPServer{{ServerLabel: binding.ServerLabel, ServerURL: binding.ServerURL, AllowedTools: binding.AllowedTools, Required: binding.Required, BearerToken: binding.BearerToken, Headers: binding.HTTPHeaders}} if err := validateMCPServers(declarations); err != nil { return nil, nil, err } diff --git a/apps/daemon/internal/agent/claudesdk/mcp_environment_test.go b/apps/daemon/internal/agent/claudesdk/mcp_environment_test.go index 0c0d70376..b6d3b3ce7 100644 --- a/apps/daemon/internal/agent/claudesdk/mcp_environment_test.go +++ b/apps/daemon/internal/agent/claudesdk/mcp_environment_test.go @@ -54,7 +54,7 @@ func TestEnvironmentMCPRejectsUnqualifiedCombinations(t *testing.T) { func(e *proto.LocalEnvironment) { e.MCP[0].Server.Name = "functions" }, func(e *proto.LocalEnvironment) { e.MCP = append(e.MCP, e.MCP[0]) }, func(e *proto.LocalEnvironment) { e.MCP[0].Server.Type = "sse" }, - func(e *proto.LocalEnvironment) { e.MCP[0].Server.HTTPHeaders = map[string]string{"X-Key": "literal"} }, + func(e *proto.LocalEnvironment) { e.MCP[0].Server.HTTPHeaders = map[string]string{"X-Key": "${HOME}"} }, func(e *proto.LocalEnvironment) { e.MCP[0].Server.BearerTokenEnvVar = "MISSING" }, func(e *proto.LocalEnvironment) { token := "token" diff --git a/apps/daemon/internal/agent/codex/mcp_environment_test.go b/apps/daemon/internal/agent/codex/mcp_environment_test.go index 980399441..3f12d1c54 100644 --- a/apps/daemon/internal/agent/codex/mcp_environment_test.go +++ b/apps/daemon/internal/agent/codex/mcp_environment_test.go @@ -49,6 +49,15 @@ func TestEnvironmentMCPProjectsIsolatedStdioAndPrivateHTTPReferences(t *testing. } } +func TestSessionMCPHeadersReachNativeConfig(t *testing.T) { + req := proto.PromptRequestPayload{DisableExecutionEnvironment: true, MCPHTTPServers: &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "remote", ServerURL: "https://example.com/mcp", Headers: map[string]string{"X-Tenant": "a"}}}} + servers, env, err := runtimeMCPServers(req) + reference := servers["remote"].EnvHTTPHeaders["X-Tenant"] + if err != nil || reference == "" || !slices.Contains(env, reference+"=a") { + t.Fatal("Session headers were not projected", err) + } +} + func TestEnvironmentMCPRejectsUnqualifiedCredentialChanges(t *testing.T) { token := "user-token" local := &proto.LocalEnvironment{MCP: []proto.EnvironmentMCP{{BearerToken: &token, Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "http://example.com/mcp"}}}} diff --git a/apps/daemon/internal/agent/mcode/environment_mcp.go b/apps/daemon/internal/agent/mcode/environment_mcp.go index 2a5d00f40..a2a9907c0 100644 --- a/apps/daemon/internal/agent/mcode/environment_mcp.go +++ b/apps/daemon/internal/agent/mcode/environment_mcp.go @@ -32,14 +32,16 @@ func runtimeMCP(req proto.PromptRequestPayload) ([]map[string]any, error) { } // ACP session servers remain in native memory; credentials never enter argv or -// persisted native configuration. Custom headers are not qualified because the -// pinned native HTTP client can forward them across redirect origins. +// persisted native configuration. func httpMCP(item agent.MCPBinding) (map[string]any, error) { endpoint, err := url.Parse(item.ServerURL) - if err != nil || (endpoint.Scheme != "http" && endpoint.Scheme != "https") || endpoint.Hostname() == "" || endpoint.User != nil || endpoint.Fragment != "" || endpoint.Opaque != "" || len(item.HTTPHeaders) != 0 { + if err != nil || (endpoint.Scheme != "http" && endpoint.Scheme != "https") || endpoint.Hostname() == "" || endpoint.User != nil || endpoint.Fragment != "" || endpoint.Opaque != "" { return nil, fmt.Errorf("mcode: unsupported HTTP MCP declaration") } headers := []map[string]string{} + for name, value := range item.HTTPHeaders { + headers = append(headers, map[string]string{"name": name, "value": value}) + } if item.BearerToken != nil { if endpoint.Scheme != "https" || !agent.ValidMCPHTTPBearerToken(*item.BearerToken) { return nil, fmt.Errorf("mcode: unsupported MCP bearer credential") diff --git a/apps/daemon/internal/agent/mcode/environment_mcp_test.go b/apps/daemon/internal/agent/mcode/environment_mcp_test.go index ea8851c97..cfd062609 100644 --- a/apps/daemon/internal/agent/mcode/environment_mcp_test.go +++ b/apps/daemon/internal/agent/mcode/environment_mcp_test.go @@ -71,16 +71,13 @@ func TestEnvironmentMCPUsesFixedLauncherForNewAndLoadedSessions(t *testing.T) { } func TestEnvironmentMCPRejectsUnqualifiedAuthorityBeforePreparation(t *testing.T) { - for _, name := range []string{"http-headers", "http-bearer-insecure", "http-bearer-missing", "duplicate"} { + for _, name := range []string{"http-bearer-insecure", "http-bearer-missing", "duplicate"} { t.Run(name, func(t *testing.T) { c, req, _ := workspaceFixture(t) req.LocalEnvironment.MCP = []proto.EnvironmentMCP{environmentMCPFixture()} switch name { - case "http-headers", "http-bearer-insecure", "http-bearer-missing": + case "http-bearer-insecure", "http-bearer-missing": req.LocalEnvironment.MCP[0].Server = agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.invalid/mcp"} - if name == "http-headers" { - req.LocalEnvironment.MCP[0].Server.HTTPHeaders = map[string]string{"X-Private": "secret"} - } if name == "http-bearer-missing" { req.LocalEnvironment.MCP[0].Server.BearerTokenEnvVar = "SELECTED_TOKEN" } @@ -266,6 +263,16 @@ func TestPublicEnvironmentHTTPMCPKeepsCredentialTransient(t *testing.T) { } } +func TestMCPHeadersReachACPHeaders(t *testing.T) { + req := testRequest(t) + req.MCPHTTPServers = &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "fixture", ServerURL: "https://mcp.example/mcp", Headers: map[string]string{"X-Tenant": "a"}}} + servers, err := runtimeMCP(req) + want := []map[string]string{{"name": "X-Tenant", "value": "a"}} + if err != nil || len(servers) != 1 || !reflect.DeepEqual(servers[0]["headers"], want) { + t.Fatalf("MCP = %v, %v", servers, err) + } +} + func TestServiceOriginMCPOnNonePlacement(t *testing.T) { req := testRequest(t) req.MCPHTTPServers = &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "fixture", ServerURL: "https://mcp.example/mcp"}} diff --git a/apps/daemon/internal/agent/mcp_binding.go b/apps/daemon/internal/agent/mcp_binding.go index 8951efdd1..e86eea12d 100644 --- a/apps/daemon/internal/agent/mcp_binding.go +++ b/apps/daemon/internal/agent/mcp_binding.go @@ -38,7 +38,7 @@ func ResolveMCPBindings(req proto.PromptRequestPayload) ([]MCPBinding, error) { 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} + item := MCPBinding{ServerLabel: server.ServerLabel, ConnectionOrigin: server.ConnectionOrigin, CredentialAuthority: "none", Transport: "http", ServerURL: server.ServerURL, Required: server.Required, BearerToken: server.BearerToken, HTTPHeaders: maps.Clone(server.Headers)} if server.AllowedTools != nil { names := append([]string{}, (*server.AllowedTools)...) item.AllowedTools = &names diff --git a/contracts/agents-api/environments.md b/contracts/agents-api/environments.md index 9ec5e766f..a78c2c1d9 100644 --- a/contracts/agents-api/environments.md +++ b/contracts/agents-api/environments.md @@ -392,7 +392,7 @@ A stdio server starts through the daemon's stdio helper, which resolves the inst [Harness capabilities](./harness-capabilities.md#environment-preparation) owns the supported Plugin transports and per-Harness limits. -Duplicate server identities are rejected. Claude rejects literal headers because the pinned client expands them again and forwards custom headers across origins. MiniMax ACP HTTP declarations stay in session-local native memory; tokens never enter native configuration files or process arguments. Required initialization and tool allowlists cannot be set through the Plugin manifest. +Duplicate server identities are rejected. Claude rejects a header value containing `${` ([native differences](./index.md#differences-from-openai)). MiniMax ACP HTTP declarations stay in session-local native memory; tokens never enter native configuration files or process arguments. Required initialization and tool allowlists cannot be set through the Plugin manifest. ### Effective bindings diff --git a/contracts/agents-api/execution-tools.md b/contracts/agents-api/execution-tools.md index ec26c9ea5..b1ca669d6 100644 --- a/contracts/agents-api/execution-tools.md +++ b/contracts/agents-api/execution-tools.md @@ -85,7 +85,7 @@ Execution carries the resolved values to the Harness and validates them against } ``` -- `server_label` is nonempty and unique within the Session. Only the `http` transport is accepted; `server_url` is an absolute HTTP or HTTPS URL without credentials or fragment. Nonempty `headers` and `request_metadata` and an inline `authorization` are rejected. +- `server_label` is nonempty and unique within the Session. Only the `http` transport is accepted; `server_url` is an absolute HTTP or HTTPS URL without credentials or fragment. Nonempty `request_metadata` and an inline `authorization` are rejected. `headers` are non-secret and reach the native Harness unchanged beside any bearer; an `Authorization` header is rejected, because credentials enter only through a Vault credential. Claude rejects a header value containing `${` ([native differences](./index.md#differences-from-openai)). - [Public MCP connection origin](./environments.md#public-mcp-connection-origin) owns origin defaults, placement and credential authority; [Harness capabilities](./harness-capabilities.md#tools) owns per-Harness support. - Omitted or null `allowed_tools` permits every server tool; `[]` permits none. - `required: true` makes native thread creation and cold resume wait for the server to initialize; a failure stops execution without replacing retained history. It needs the Runtime's `mcp_http_required` capability. Public work can be accepted or queued during the wait. diff --git a/contracts/agents-api/harness-capabilities.md b/contracts/agents-api/harness-capabilities.md index c845fee27..004ba060b 100644 --- a/contracts/agents-api/harness-capabilities.md +++ b/contracts/agents-api/harness-capabilities.md @@ -61,6 +61,6 @@ These operations need a workspace, so they apply to hosted and self-hosted place | `packages.system` | Rejected | Rejected | Rejected | | Network `disabled` or `restricted` | Rejected | Rejected | Rejected | | [Plugin MCP over stdio](./environments.md#plugin-mcp) | Verified: hosted, self-hosted | Verified: self-hosted; admitted: hosted | Verified: self-hosted; admitted: hosted | -| Plugin MCP over HTTP | Admitted, with literal headers or HTTPS bearer | Admitted, anonymous or HTTPS bearer | Verified: self-hosted; admitted: hosted; anonymous or HTTPS bearer | +| Plugin MCP over HTTP | Admitted, with literal headers or HTTPS bearer | Admitted, with literal headers or HTTPS bearer | Verified: self-hosted; admitted: hosted; literal headers or HTTPS bearer | External workspace storage additionally requires the selected Harness declaration and Runtime to support `retained_native_history`; owned workspace storage does not. The [Workspace filesystem protocol](../../docs/workspace-provider.md) owns storage combination admission. diff --git a/contracts/agents-api/index.md b/contracts/agents-api/index.md index 83785f020..45dcb32d9 100644 --- a/contracts/agents-api/index.md +++ b/contracts/agents-api/index.md @@ -74,6 +74,7 @@ Each item is Core's deliberate or native behavior where the official service beh - An omitted reasoning effort stays null instead of taking the model's default. - Codex maps programmatic tool calling to its native `code_mode`, whose model-written scripts call the other tools; the feature is under development upstream. - A Session's `agent.tools` omits `tool_search` declarations. +- Claude rejects an MCP `headers` value containing `${`, because the native client expands `${VAR}` from a process environment that holds Core-held credentials. - Deleting a Session right after an events 202 returns 409, because Core admits the Turn in the same transaction; the official service returned 200. **Input, events and history** ([Sessions, events and history](./sessions-events.md), [message content](./message-content.md)) @@ -110,7 +111,7 @@ Each item is Core's deliberate or native behavior where the official service beh - A `reasoning.effort`, `reasoning.summary`, `service_tier`, `web_search` mode or setting or `programmatic_tool_calling` value that the selected Harness does not declare is saved but rejected at Session admission ([Harness capabilities](./harness-capabilities.md#execution-and-input)). - Harness support for tools, structured output, deferred discovery, subagents and MCP differs by Harness and placement; see the [Harness capabilities](./harness-capabilities.md). MiniMax Code has no public functions and no image input. - Model-derived reasoning defaults are not resolved. -- MCP tools support the `http` transport only; `stdio` is rejected, and so is an inline `authorization` on a Session MCP transport ([HTTP MCP](./execution-tools.md#http-mcp)). +- MCP tools support the `http` transport only, so `stdio` is rejected. An inline `authorization` on a Session MCP transport and an `Authorization` entry in `headers` are rejected too ([HTTP MCP](./execution-tools.md#http-mcp)). **Execution and history** diff --git a/contracts/agents-api/wire-semantics.md b/contracts/agents-api/wire-semantics.md index f039fc833..42869c79c 100644 --- a/contracts/agents-api/wire-semantics.md +++ b/contracts/agents-api/wire-semantics.md @@ -156,7 +156,7 @@ Agent create requires `model`. Core saves and returns these values for omitted f | Function `defer_loading` | `false` | | `programmatic_tool_calling.enabled` | `true` | | `web_search` | Every pinned mode is saved; see [tool policy](./execution-tools.md#web-search-and-programmatic-tool-calling) | -| HTTP MCP transport | Saved with `headers: {}`; nonempty headers are rejected. Origin and allowlist defaults are in [public MCP connection origin](./environments.md#public-mcp-connection-origin) | +| HTTP MCP transport | Saved with `headers: {}`; nonempty headers are stored as given and an `Authorization` header is rejected. Origin and allowlist defaults are in [public MCP connection origin](./environments.md#public-mcp-connection-origin) | Saving a value does not make it executable. Session creation admits a smaller set; see [Session admission](#session-admission). diff --git a/contracts/agents-api/zh/environments.md b/contracts/agents-api/zh/environments.md index 83c9e6f6b..61c7f640c 100644 --- a/contracts/agents-api/zh/environments.md +++ b/contracts/agents-api/zh/environments.md @@ -1,7 +1,7 @@ --- title: "Environment 与模板" source: contracts/agents-api/environments.md -source_hash: 68f12431a9f5685dd88b04d239d40f28f2390e923397eb0071a1a57ea58666d1 +source_hash: 8433303b1e06c831b272a6714639ff496be24a40e9b1efc8d29b3495a243529c --- Environment 是 Session 的执行资源,包括 Harness 运行所在的机器、工作区以及已完成准备的能力。Session 通过其 `environment` 配置创建 Environment;不存在独立的 create 调用。Environment 模板是 Session 创建时解析的可复用准备配置。本契约涵盖这两类资源、两种放置方式、输入接纳、能力准备、Skills、Plugins 和 MCP 连接来源。 @@ -394,7 +394,7 @@ stdio 服务器通过 daemon 的 stdio helper 启动;该 helper 会解析已 [Harness capabilities](harness-capabilities.md#environment-preparation) 负责受支持的 Plugin 传输及每个 Harness 的限制。 -重复的服务器身份会被拒绝。Claude 会拒绝字面量标头,因为固定版本客户端会再次展开这些标头,并将自定义标头跨来源转发。MiniMax ACP HTTP 声明会保留在 Session 本地的原生内存中;令牌绝不会进入原生配置文件或进程参数。无法通过 Plugin 清单设置必需初始化和工具允许列表。 +重复的服务器身份会被拒绝。Claude 会拒绝包含 `${` 的标头值([原生差异](index.md#differences-from-openai))。MiniMax ACP HTTP 声明会保留在 Session 本地的原生内存中;令牌绝不会进入原生配置文件或进程参数。无法通过 Plugin 清单设置必需初始化和工具允许列表。 ### 有效绑定 {#effective-bindings} diff --git a/contracts/agents-api/zh/execution-tools.md b/contracts/agents-api/zh/execution-tools.md index 88f61eed9..8a05872bd 100644 --- a/contracts/agents-api/zh/execution-tools.md +++ b/contracts/agents-api/zh/execution-tools.md @@ -1,7 +1,7 @@ --- title: "执行工具" source: contracts/agents-api/execution-tools.md -source_hash: 346c66fcc23ee4faeed7ee25feb081be2cd37baf69c5982a8792f9228df8d127 +source_hash: 67ebe75e8c46e75aa8f03e0a630992abaa34e5cdcf99f0883232f4bb8f4bf4c9 --- Agent 在 `tools` 中声明应用函数、控制项和 MCP 服务器,并可在 `text.format` 中声明输出 schema。本契约说明 Core 如何验证声明、哪些内容跨越 Runtime 边界,以及调用方如何恢复待执行操作。[Harness 能力](harness-capabilities.md)列出各 Harness 在不同部署位置支持的操作。原生工作区工具和 Environment Plugin MCP 属于 [Environment](environments.md#skills-plugins-and-environment-mcp)。 @@ -87,7 +87,7 @@ Core 发送 `PromptRequestPayload.ToolSearch` 和每个 `FunctionTool.DeferLoadi } ``` -- `server_label` 非空且在 Session 中唯一。仅接受 `http` 传输;`server_url` 为不带凭据或片段的绝对 HTTP 或 HTTPS URL。非空 `headers`、`request_metadata` 和内联 `authorization` 被拒绝。 +- `server_label` 非空且在 Session 中唯一。仅接受 `http` 传输;`server_url` 为不带凭据或片段的绝对 HTTP 或 HTTPS URL。非空 `request_metadata` 和内联 `authorization` 被拒绝。`headers` 为非机密标头,会与 bearer 一同原样传给原生 Harness;`Authorization` 标头被拒绝,因为凭据只通过 Vault 凭据进入。Claude 会拒绝包含 `${` 的标头值([原生差异](index.md#differences-from-openai))。 - [公开 MCP 连接来源](environments.md#public-mcp-connection-origin)定义来源默认值、部署位置和凭据权限;[Harness 能力](harness-capabilities.md#tools)定义各 Harness 支持范围。 - 省略或 null 的 `allowed_tools` 允许所有服务器工具;`[]` 不允许任何工具。 - `required: true` 使原生线程创建和冷恢复等待服务器初始化;失败会停止执行,不替换保留历史。它要求 Runtime 的 `mcp_http_required` 能力。等待期间公开工作可被接受或排队。 diff --git a/contracts/agents-api/zh/harness-capabilities.md b/contracts/agents-api/zh/harness-capabilities.md index be038dd94..8abac57f9 100644 --- a/contracts/agents-api/zh/harness-capabilities.md +++ b/contracts/agents-api/zh/harness-capabilities.md @@ -1,7 +1,7 @@ --- title: "Harness 能力" source: contracts/agents-api/harness-capabilities.md -source_hash: c51422803ad90e85d6d11b6698a2a70ba606f663ed55be5591faf5fcc701487e +source_hash: 4a37a1f01a48dc0b9527de1edecb9117a2506558fcbefed2a297561edb5a4a07 --- 本页列出每个 Harness 在每种部署位置支持的能力。Core 根据 `internal/harnessconfig//declaration.go` 中 Harness 的声明决定准入,运行 Session 的 Runtime 也必须声明操作。所链接契约定义各操作;[Harness 接入](harness-onboarding.md#qualify-the-adapter)说明资格验证方法。 @@ -63,6 +63,6 @@ Claude 结构化输出要求 medium verbosity,可与 `multi_agent`、Skill、P | `packages.system` | 已拒绝 | 已拒绝 | 已拒绝 | | 网络 `disabled` 或 `restricted` | 已拒绝 | 已拒绝 | 已拒绝 | | [stdio Plugin MCP](environments.md#plugin-mcp) | 已验证:托管、自托管 | 已验证:自托管;已准入:托管 | 已验证:自托管;已准入:托管 | -| HTTP Plugin MCP | 已准入,字面值头或 HTTPS bearer | 已准入,匿名或 HTTPS bearer | 已验证:自托管;已准入:托管;匿名或 HTTPS bearer | +| HTTP Plugin MCP | 已准入,字面值头或 HTTPS bearer | 已准入,字面值头或 HTTPS bearer | 已验证:自托管;已准入:托管;字面值头或 HTTPS bearer | 外部工作区存储还要求所选 Harness 声明和 Runtime 支持 `retained_native_history`;自带工作区存储不要求此能力。[工作区文件系统协议](../../../docs/zh/workspace-provider.md)定义存储组合准入规则。 diff --git a/contracts/agents-api/zh/index.md b/contracts/agents-api/zh/index.md index cf2eb9b2f..c90995613 100644 --- a/contracts/agents-api/zh/index.md +++ b/contracts/agents-api/zh/index.md @@ -1,7 +1,7 @@ --- title: "OpenAI 兼容性" source: contracts/agents-api/index.md -source_hash: 7665dc7621643822f5144aa2de85f37128f76a44f5b9fe300441634ca37dc347 +source_hash: ad472d852f561a92824ff566e4911b5f92ce09c2cb9a8eee4f531cd22945a04d --- Core 实现下方固定版本的 OpenAI Agents API。本台账列出各项资源的覆盖情况、相对于官方服务的所有差异以及已知缺口。 @@ -77,6 +77,7 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh - 未指定推理强度时会保持为 null,而不是采用模型的默认值。 - Codex 把程序化工具调用映射到原生 `code_mode`,由模型编写的脚本调用其他工具;该功能在上游仍处于开发中。 - Session 的 `agent.tools` 会省略 `tool_search` 声明。 +- Claude 会拒绝包含 `${` 的 MCP `headers` 值,因为原生客户端会从持有 Core 所持凭据的进程环境中展开 `${VAR}`。 - 在 events 202 之后立即删除 Session 会返回 409,因为 Core 会在同一事务中准入该 Turn;官方服务返回 200。 **输入、事件和历史**([Sessions, events and history](sessions-events.md)、[message content](message-content.md)) @@ -113,7 +114,7 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh - 所选 Harness 未声明的 `reasoning.effort`、`reasoning.summary`、`service_tier`、`web_search` 模式或设置、`programmatic_tool_calling` 取值,会被保存,但在 Session 准入时被拒绝([Harness 能力](harness-capabilities.md#execution-and-input))。 - Harness 对工具、结构化输出、延迟发现、subagents 和 MCP 的支持因 Harness 和部署位置而异;请参阅 [Harness capabilities](harness-capabilities.md)。MiniMax Code 不提供公共 functions,也不支持图像输入。 - 由模型推导出的推理默认值不会被解析确定。 -- MCP 工具仅支持 `http` 传输,`stdio` 会被拒绝,Session MCP 传输中的内联 `authorization` 也会被拒绝([HTTP MCP](execution-tools.md#http-mcp))。 +- MCP 工具仅支持 `http` 传输,`stdio` 会被拒绝,Session MCP 传输中的内联 `authorization` 以及 `headers` 中的 `Authorization` 标头也会被拒绝([HTTP MCP](execution-tools.md#http-mcp))。 **执行和历史** diff --git a/contracts/agents-api/zh/wire-semantics.md b/contracts/agents-api/zh/wire-semantics.md index a5f0724bd..0614c1975 100644 --- a/contracts/agents-api/zh/wire-semantics.md +++ b/contracts/agents-api/zh/wire-semantics.md @@ -1,7 +1,7 @@ --- title: "API 行为" source: contracts/agents-api/wire-semantics.md -source_hash: 204cc23703043518da5b2dfcadfb3521be7cce417cf80d0b4fe7356b4dfdfa02 +source_hash: 50998ccc26430755f2a81e634d33def91e96cfe377149aec66dc04909d972cb4 --- 已锁定版本的 OpenAI Python SDK([upstream.json](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/contracts/agents-api/upstream.json))定义了 `/v1` 路由、字段和类型。本页面说明这些类型未作规定之处 Core 的行为,例如状态码、错误字段、默认值和列表边界,以及 Core 与官方服务存在差异的地方。[OpenAI 兼容性](index.md) 列出了这些差异和尚存缺口;[Sessions, events and history](sessions-events.md)、[message content](message-content.md)、[Vaults](vaults.md)、[source Files and Skills](source-files.md) 和 [Environment files and Artifacts](environment-files.md) 分别负责各自资源的规则。 @@ -158,7 +158,7 @@ Agent 创建要求提供 `model`。对于省略的字段,Core 会保存并返 | Function `defer_loading` | `false` | | `programmatic_tool_calling.enabled` | `true` | | `web_search` | 保存每个已锁定模式;请参阅 [tool policy](execution-tools.md#web-search-and-programmatic-tool-calling) | -| HTTP MCP 传输方式 | 以 `headers: {}` 保存;拒绝非空标头。Origin 和 allowlist 默认值见 [public MCP connection origin](environments.md#public-mcp-connection-origin) | +| HTTP MCP 传输方式 | 以 `headers: {}` 保存;非空标头按给定值存储,`Authorization` 标头被拒绝。Origin 和 allowlist 默认值见 [public MCP connection origin](environments.md#public-mcp-connection-origin) | 保存某个值并不会使其可执行。Session 创建允许的配置集合更小;请参阅 [Session admission](#session-admission)。 diff --git a/docs/runtime-protocol.md b/docs/runtime-protocol.md index a9d50a066..b65ca49d3 100644 --- a/docs/runtime-protocol.md +++ b/docs/runtime-protocol.md @@ -306,7 +306,7 @@ Core runs an idle directory read on the Worker's Session scheduling reservation ## MCP connection authority -Every public `MCPHTTPServer` in an `execution_prepare` configuration carries an explicit `connection_origin`; a missing or unknown value rejects rather than selecting a default, and Core freezes the public default before dispatch. The Runtime validates the origin with the common validator before selecting a factory and resolves public and installed MCP into transient effective bindings. The [Environment contract](../contracts/agents-api/environments.md#public-mcp-connection-origin) owns the supported combinations, native limits and failure ownership. +Every public `MCPHTTPServer` in an `execution_prepare` configuration carries an explicit `connection_origin`; a missing or unknown value rejects rather than selecting a default, and Core freezes the public default before dispatch. `MCPHTTPServer.headers` carries non-secret literal headers; credentials travel only in `bearer_token`. The Runtime validates the origin with the common validator before selecting a factory and resolves public and installed MCP into transient effective bindings. The [Environment contract](../contracts/agents-api/environments.md#public-mcp-connection-origin) owns the supported combinations, native limits and failure ownership. ## Timeouts and limits diff --git a/docs/zh/runtime-protocol.md b/docs/zh/runtime-protocol.md index 78b1d98d9..c73ac4475 100644 --- a/docs/zh/runtime-protocol.md +++ b/docs/zh/runtime-protocol.md @@ -1,7 +1,7 @@ --- title: "Core–Runtime 协议" source: docs/runtime-protocol.md -source_hash: d8e2ef6c37e49d482f9eed39490ce5f20dc892c29667fb71070fe88c1321600f +source_hash: 5f735aa86ed4ac1e82cfe028eb10eba50d651df844c10d73f739e5764fc6dc82 --- Core–Runtime 协议连接 Core 与 Runtime daemon,定义 daemon 连接上消息的含义和顺序。托管和自托管 Runtime 使用同一协议。 @@ -308,7 +308,7 @@ Core 在 Worker 的 Session 调度预约上运行空闲目录读取,活动执 ## MCP 连接权限 {#mcp-connection-authority} -`execution_prepare` 配置中每个公开 `MCPHTTPServer` 都携带明确的 `connection_origin`;值缺失或未知时拒绝,不选择默认值,Core 在 dispatch 前冻结公开默认值。Runtime 在选择 factory 前用公共 validator 验证 origin,并将公开和已安装 MCP 解析为临时 effective binding。[Environment 契约](../../contracts/agents-api/zh/environments.md#public-mcp-connection-origin)负责支持的组合、原生限制和故障所有权。 +`execution_prepare` 配置中每个公开 `MCPHTTPServer` 都携带明确的 `connection_origin`;值缺失或未知时拒绝,不选择默认值,Core 在 dispatch 前冻结公开默认值。`MCPHTTPServer.headers` 携带非机密的字面标头;凭据只通过 `bearer_token` 传递。Runtime 在选择 factory 前用公共 validator 验证 origin,并将公开和已安装 MCP 解析为临时 effective binding。[Environment 契约](../../contracts/agents-api/zh/environments.md#public-mcp-connection-origin)负责支持的组合、原生限制和故障所有权。 ## 超时与限制 {#timeouts-and-limits} diff --git a/internal/agentdaemon/proto/mcp.go b/internal/agentdaemon/proto/mcp.go index fcba5f81d..aeb2c1119 100644 --- a/internal/agentdaemon/proto/mcp.go +++ b/internal/agentdaemon/proto/mcp.go @@ -13,6 +13,8 @@ type MCPHTTPServer struct { AllowedTools *[]string `json:"allowed_tools"` Required bool `json:"required,omitempty"` BearerToken *string `json:"bearer_token,omitempty"` + // Headers are non-secret; credentials travel only in BearerToken. + Headers map[string]string `json:"headers,omitempty"` } // ValidateConnectionOrigin rejects relocation before dispatch and adapter setup. diff --git a/internal/agentdaemon/proto/version.go b/internal/agentdaemon/proto/version.go index 926d5b371..072d9e099 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.18.0" +const Version = "0.19.0" // VersionCompatible accepts only this contract. Patch drift, prerelease suffixes // and malformed versions do not select an implicit compatibility path. diff --git a/internal/harnessconfig/claudesdk/declaration.go b/internal/harnessconfig/claudesdk/declaration.go index f27d436c7..eb3c7fb7e 100644 --- a/internal/harnessconfig/claudesdk/declaration.go +++ b/internal/harnessconfig/claudesdk/declaration.go @@ -105,6 +105,9 @@ func validateTools(tools []proto.FunctionTool, servers []proto.MCPHTTPServer) er if !mcpLabel.MatchString(server.ServerLabel) || server.ServerLabel == "functions" || strings.Contains(server.ServerURL, "#") { return errors.New("The configured engine does not support this HTTP MCP declaration.") } + if ExpandsEnvironment(server.Headers) { + return errors.New("The configured engine does not support ${ in MCP header values.") + } } for _, tool := range tools { var schema struct { @@ -190,3 +193,14 @@ func validThinking(thinking map[string]any) bool { } return true } + +// ExpandsEnvironment reports a header value the native client would expand from +// its own environment, which holds Core-held credentials. +func ExpandsEnvironment(headers map[string]string) bool { + for _, value := range headers { + if strings.Contains(value, "${") { + return true + } + } + return false +} diff --git a/internal/harnessconfig/claudesdk/declaration_test.go b/internal/harnessconfig/claudesdk/declaration_test.go index 8b536bd04..be328d6f4 100644 --- a/internal/harnessconfig/claudesdk/declaration_test.go +++ b/internal/harnessconfig/claudesdk/declaration_test.go @@ -36,3 +36,14 @@ func TestDeclarationAdmitsAnyMCPToolName(t *testing.T) { t.Fatal(err) } } + +func TestDeclarationRejectsMCPHeaderExpansion(t *testing.T) { + servers := []proto.MCPHTTPServer{{ServerLabel: "docs", ServerURL: "https://example.test/mcp", Headers: map[string]string{"X-Tenant": "a"}}} + if err := Declaration.ValidateTools(nil, servers); err != nil { + t.Fatal(err) + } + servers[0].Headers["X-Tenant"] = "${ANTHROPIC_API_KEY}" + if err := Declaration.ValidateTools(nil, servers); err == nil { + t.Fatal("header value with ${ admitted") + } +} diff --git a/packages/claude-sdk-adapter/src/mcp.ts b/packages/claude-sdk-adapter/src/mcp.ts index 1eb85de63..f0fe5458f 100644 --- a/packages/claude-sdk-adapter/src/mcp.ts +++ b/packages/claude-sdk-adapter/src/mcp.ts @@ -9,6 +9,7 @@ export type HTTPServer = { allowed_tools: string[] | null; required?: boolean; bearer_token_env_var?: string; + headers?: Record; }; export type ToolIdentity = { server: string; name: string }; @@ -25,8 +26,10 @@ export function parseHTTPServers(value: unknown): HTTPServer[] | undefined { const references = new Set(); for (const server of value) { if (!server || typeof server !== "object" || - Object.keys(server).some(key => !["server_label", "server_url", "allowed_tools", "bearer_token_env_var", "required"].includes(key)) || + Object.keys(server).some(key => !["server_label", "server_url", "allowed_tools", "bearer_token_env_var", "required", "headers"].includes(key)) || (server.required !== undefined && typeof server.required !== "boolean") || + (server.headers !== undefined && (!server.headers || typeof server.headers !== "object" || + Object.values(server.headers).some(value => typeof value !== "string"))) || typeof server.server_label !== "string" || !/^[a-zA-Z0-9_-]+$/.test(server.server_label) || server.server_label === "functions" || labels.has(server.server_label) || typeof server.server_url !== "string" || @@ -92,7 +95,7 @@ export class MCPProfile { // An explicit empty Authorization suppresses native OAuth and automatic auth. // Keep bearer references literal: SDK server configuration enters native argv. this.servers[server.server_label] = { type: "http", url: server.server_url, alwaysLoad: true, - headers: { Authorization: reference ? `Bearer \${${reference}}` : "" } }; + headers: { ...server.headers, Authorization: reference ? `Bearer \${${reference}}` : "" } }; } if (server.allowed_tools === null) this.allowed.push(prefix + "*"); else if (!server.allowed_tools.length) this.denied.push(prefix + "*"); @@ -111,7 +114,7 @@ export class MCPProfile { const client = new Client({ name: "oac-claude-sdk-adapter", version: "0.0.0" }); try { await client.connect(new StreamableHTTPClientTransport(new URL(server.server_url), - { requestInit: reference ? { headers: { Authorization: `Bearer ${process.env[reference]}` } } : {} }), { signal }); + { requestInit: { headers: { ...server.headers, ...(reference && { Authorization: `Bearer ${process.env[reference]}` }) } } }), { signal }); let cursor: string | undefined; const cursors = new Set(); do { diff --git a/packages/claude-sdk-adapter/tests/mcp_bearer.test.mjs b/packages/claude-sdk-adapter/tests/mcp_bearer.test.mjs index 411c16a5e..261aecc0e 100644 --- a/packages/claude-sdk-adapter/tests/mcp_bearer.test.mjs +++ b/packages/claude-sdk-adapter/tests/mcp_bearer.test.mjs @@ -32,13 +32,20 @@ test("bearer references remain literal in native configuration and private statu test("untrusted header expressions, raw tokens and non-HTTPS authenticated declarations are rejected", () => { for (const invalid of [ { ...server, bearer_token: "fixture-secret" }, - { ...server, headers: { Authorization: "Bearer fixture-secret" } }, + { ...server, headers: { "X-Tenant": 1 } }, { ...server, server_url: "http://example.invalid/mcp" }, ...[null, "", "ANTHROPIC_AUTH_TOKEN", "OAC_RUNTIME_MCP_BEARER_", "${OPERATOR_TOKEN}", reference + ":-fallback"].map(bearer_token_env_var => ({ ...server, bearer_token_env_var })), ]) assert.throws(() => parseStart(JSON.stringify(start([invalid])))); assert.throws(() => parseStart(JSON.stringify(start([server, { ...server, server_label: "other" }])))); }); +test("custom headers reach the native configuration beside the bearer", t => { + process.env[reference] = "fixture-private-bearer"; + t.after(() => { delete process.env[reference]; }); + const profile = new MCPProfile([{ ...server, headers: { "X-Tenant": "a" } }], []); + assert.deepEqual(profile.servers.private.headers, { "X-Tenant": "a", Authorization: "Bearer ${" + reference + "}" }); +}); + test("missing credential environment fails before the native query", () => { const missing = "OAC_RUNTIME_MCP_BEARER_" + "B".repeat(26); delete process.env[missing]; diff --git a/services/core/internal/api/mcp_configuration.go b/services/core/internal/api/mcp_configuration.go index 9b3b82769..8295b474e 100644 --- a/services/core/internal/api/mcp_configuration.go +++ b/services/core/internal/api/mcp_configuration.go @@ -50,8 +50,10 @@ func resolveMCPTool(raw json.RawMessage, saved bool) (json.RawMessage, error) { if transport.Authorization != nil { return nil, errors.New("Inline MCP authorization is not supported yet.") } - if len(transport.Headers) != 0 { - return nil, errors.New("Nonempty MCP headers are not supported yet.") + for name := range transport.Headers { + if strings.EqualFold(name, "Authorization") { + return nil, errors.New("MCP headers cannot carry Authorization; use credential_id.") + } } var allowed *[]string if input.AllowedTools != nil { @@ -65,9 +67,10 @@ 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: input.Required != nil && *input.Required, ConnectionOrigin: origin, CredentialID: input.CredentialID, RequestMetadata: map[string]json.RawMessage{}} - if saved { - headers := map[string]string{} - tool.Transport.Headers = &headers + if len(transport.Headers) != 0 { + tool.Transport.Headers = &transport.Headers + } else if saved { + tool.Transport.Headers = &map[string]string{} } return json.Marshal(tool) } diff --git a/services/core/internal/api/mcp_configuration_test.go b/services/core/internal/api/mcp_configuration_test.go index 7b0f79e48..c0404bb62 100644 --- a/services/core/internal/api/mcp_configuration_test.go +++ b/services/core/internal/api/mcp_configuration_test.go @@ -133,6 +133,19 @@ func TestMCPUnsupportedInputsAreSecretSafe(t *testing.T) { } } +func TestMCPHeadersAreStoredAsGiven(t *testing.T) { + for _, saved := range []bool{false, true} { + input := `{"type":"mcp","server_label":"records","transport":{"type":"http","server_url":"https://mcp.example.test","headers":{"X-Tenant":"a"}}}` + raw, err := resolveMCPTool(json.RawMessage(input), saved) + if err != nil || !strings.Contains(string(raw), `"headers":{"X-Tenant":"a"}`) { + t.Fatalf("saved=%v: %s, %v", saved, raw, err) + } + if _, err := resolveMCPTool(json.RawMessage(strings.Replace(input, "X-Tenant", "authorization", 1)), saved); err == nil { + t.Fatal("Authorization header accepted") + } + } +} + func TestSessionMCPKeepsToolOrderAndStripsSavedHeaders(t *testing.T) { saved, err := resolveMCPTool(json.RawMessage(publicMCP), true) if err != nil { diff --git a/services/core/internal/execution/mcp.go b/services/core/internal/execution/mcp.go index c0cf8b93c..15a5d6e75 100644 --- a/services/core/internal/execution/mcp.go +++ b/services/core/internal/execution/mcp.go @@ -76,7 +76,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" && tool.ConnectionOrigin != "environment") || 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" { return executionToolSet{}, errors.New("unsupported execution MCP configuration") } u, err := url.Parse(tool.Transport.ServerURL) @@ -91,8 +91,12 @@ func executionTools(raw []json.RawMessage) (executionToolSet, error) { } } names[tool.ServerLabel] = true + var headers map[string]string + if tool.Transport.Headers != nil { + headers = *tool.Transport.Headers + } servers = append(servers, proto.MCPHTTPServer{ConnectionOrigin: tool.ConnectionOrigin, ServerLabel: tool.ServerLabel, - ServerURL: tool.Transport.ServerURL, AllowedTools: tool.AllowedTools, Required: tool.Required}) + ServerURL: tool.Transport.ServerURL, AllowedTools: tool.AllowedTools, Required: tool.Required, Headers: headers}) } resolved, err := functionTools(functions) return executionToolSet{Functions: resolved, MCP: servers, Search: search, WebSearch: webSearch, ProgrammaticToolCalling: programmatic}, err From 4cf3cd08ef02037b82a6bc70ffe11e960c82b773 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 19 Oct 2026 18:16:01 +0000 Subject: [PATCH 2/2] Send MCP headers literally and fix header review findings --- apps/daemon/internal/agent/claudesdk/mcp.go | 8 +++--- .../daemon/internal/agent/codex/mcp_config.go | 4 +-- .../agent/codex/mcp_environment_test.go | 12 ++++----- apps/daemon/internal/agent/codex/mcp_http.go | 10 +------- .../agent/codex/mcp_http_preflight.go | 4 +-- apps/daemon/internal/agent/mcp_binding.go | 2 +- contracts/agents-api/environments.md | 4 +-- contracts/agents-api/execution-tools.md | 2 +- contracts/agents-api/index.md | 2 +- contracts/agents-api/zh/environments.md | 6 ++--- contracts/agents-api/zh/execution-tools.md | 4 +-- contracts/agents-api/zh/index.md | 4 +-- internal/agentplugin/mcp.go | 25 ++++++++++++++----- .../harnessconfig/claudesdk/declaration.go | 15 ++++++----- .../claudesdk/declaration_test.go | 6 ++++- packages/claude-sdk-adapter/src/mcp.ts | 5 ++-- .../tests/mcp_bearer.test.mjs | 2 ++ .../core/internal/api/mcp_configuration.go | 4 +++ .../internal/api/mcp_configuration_test.go | 2 ++ 19 files changed, 70 insertions(+), 51 deletions(-) diff --git a/apps/daemon/internal/agent/claudesdk/mcp.go b/apps/daemon/internal/agent/claudesdk/mcp.go index af8a08533..1aac9eddd 100644 --- a/apps/daemon/internal/agent/claudesdk/mcp.go +++ b/apps/daemon/internal/agent/claudesdk/mcp.go @@ -40,16 +40,16 @@ func validateMCPServers(servers []proto.MCPHTTPServer) error { if server.BearerToken != nil && (endpoint.Scheme != "https" || !agent.ValidMCPHTTPBearerToken(*server.BearerToken)) { return fmt.Errorf("claudesdk: unsupported HTTPS MCP bearer credential") } - if declaration.ExpandsEnvironment(server.Headers) { - return fmt.Errorf("claudesdk: MCP header values cannot contain ${") + if declaration.ExpandsEnvironment(server) { + return fmt.Errorf("claudesdk: MCP URLs and header values cannot contain ${") } labels[server.ServerLabel] = true } return nil } -// Only generated references cross the private bridge; secrets stay in the owned -// process environment and are expanded by the native HTTP client. +// Bearer tokens cross the private bridge only as generated references; they stay +// in the owned process environment and are expanded by the native HTTP client. type mcpHTTPServer struct { ServerLabel string `json:"server_label"` ServerURL string `json:"server_url,omitempty"` diff --git a/apps/daemon/internal/agent/codex/mcp_config.go b/apps/daemon/internal/agent/codex/mcp_config.go index d0de7d3be..0aeebd19f 100644 --- a/apps/daemon/internal/agent/codex/mcp_config.go +++ b/apps/daemon/internal/agent/codex/mcp_config.go @@ -19,7 +19,7 @@ type mcpServerConfig struct { EnabledTools *[]string Required bool BearerTokenEnvVar string - EnvHTTPHeaders map[string]string + HTTPHeaders map[string]string } // writeCodexMCPConfig writes a `[mcp_servers.]` TOML table per @@ -69,7 +69,7 @@ func writeCodexMCPConfig(codexHome string, servers map[string]mcpServerConfig) e } b.WriteString("]\n") } - writeMCPHeaderMap(&b, "env_http_headers", srv.EnvHTTPHeaders) + writeMCPHeaderMap(&b, "http_headers", srv.HTTPHeaders) b.WriteByte('\n') continue } diff --git a/apps/daemon/internal/agent/codex/mcp_environment_test.go b/apps/daemon/internal/agent/codex/mcp_environment_test.go index 3f12d1c54..147a2e7c3 100644 --- a/apps/daemon/internal/agent/codex/mcp_environment_test.go +++ b/apps/daemon/internal/agent/codex/mcp_environment_test.go @@ -18,7 +18,7 @@ func TestEnvironmentMCPProjectsIsolatedStdioAndPrivateHTTPReferences(t *testing. {InstallationRoot: "/private/runtime/capabilities", WorkspaceRoot: "/private/runtime/workspace", PackageRoot: "plugins/1", BearerToken: &token, Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.com/mcp", HTTPHeaders: map[string]string{"X-Key": "literal-${DO_NOT_EXPAND}"}}}, }} servers, env, err := runtimeMCPServers(proto.PromptRequestPayload{LocalEnvironment: local}) - if err != nil || len(servers) != 2 || len(env) != 2 { + if err != nil || len(servers) != 2 || len(env) != 1 { t.Fatal("environment declarations were not projected", err) } executable, _ := os.Executable() @@ -27,16 +27,15 @@ func TestEnvironmentMCPProjectsIsolatedStdioAndPrivateHTTPReferences(t *testing. t.Fatal("native stdio bypasses the packaged launcher") } remote := servers["remote"] - if remote.BearerTokenEnvVar == "" || remote.EnvHTTPHeaders["X-Key"] == "" || - !slices.Contains(env, remote.BearerTokenEnvVar+"="+token) || - !slices.Contains(env, remote.EnvHTTPHeaders["X-Key"]+"=literal-${DO_NOT_EXPAND}") { + if remote.BearerTokenEnvVar == "" || remote.HTTPHeaders["X-Key"] != "literal-${DO_NOT_EXPAND}" || + !slices.Contains(env, remote.BearerTokenEnvVar+"="+token) { t.Fatal("private header values changed") } fixture := map[string]any{"config": map[string]any{ "mcp_oauth_credentials_store": "file", "mcp_servers": map[string]any{ "local": map[string]any{"command": servers["local"].Command, "args": servers["local"].Args, "environment_id": "local", "enabled": true, "tool_timeout_sec": nil}, - "remote": map[string]any{"url": remote.URL, "environment_id": "local", "enabled": true, "tool_timeout_sec": nil, "bearer_token_env_var": remote.BearerTokenEnvVar, "env_http_headers": remote.EnvHTTPHeaders}, + "remote": map[string]any{"url": remote.URL, "environment_id": "local", "enabled": true, "tool_timeout_sec": nil, "bearer_token_env_var": remote.BearerTokenEnvVar, "http_headers": remote.HTTPHeaders}, }, }} raw, _ := json.Marshal(fixture) @@ -52,8 +51,7 @@ func TestEnvironmentMCPProjectsIsolatedStdioAndPrivateHTTPReferences(t *testing. func TestSessionMCPHeadersReachNativeConfig(t *testing.T) { req := proto.PromptRequestPayload{DisableExecutionEnvironment: true, MCPHTTPServers: &[]proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "remote", ServerURL: "https://example.com/mcp", Headers: map[string]string{"X-Tenant": "a"}}}} servers, env, err := runtimeMCPServers(req) - reference := servers["remote"].EnvHTTPHeaders["X-Tenant"] - if err != nil || reference == "" || !slices.Contains(env, reference+"=a") { + if err != nil || servers["remote"].HTTPHeaders["X-Tenant"] != "a" || len(env) != 0 { t.Fatal("Session headers were not projected", err) } } diff --git a/apps/daemon/internal/agent/codex/mcp_http.go b/apps/daemon/internal/agent/codex/mcp_http.go index 397d103e3..d4f4a5540 100644 --- a/apps/daemon/internal/agent/codex/mcp_http.go +++ b/apps/daemon/internal/agent/codex/mcp_http.go @@ -26,7 +26,7 @@ func runtimeMCPServers(req proto.PromptRequestPayload) (map[string]mcpServerConf if binding.ServerLabel == "codex_apps" { return nil, nil, errors.New("codex: reserved MCP server label") } - server := mcpServerConfig{Name: binding.ServerLabel, URL: binding.ServerURL, Required: binding.Required, EnabledTools: binding.AllowedTools} + server := mcpServerConfig{Name: binding.ServerLabel, URL: binding.ServerURL, Required: binding.Required, EnabledTools: binding.AllowedTools, HTTPHeaders: binding.HTTPHeaders} if binding.Stdio != nil { server.Command, server.Args = localworkspace.MCPStdioCommand(*binding.Stdio) } @@ -34,14 +34,6 @@ func runtimeMCPServers(req proto.PromptRequestPayload) (map[string]mcpServerConf server.BearerTokenEnvVar = "OAC_RUNTIME_MCP_BEARER_" + rand.Text() env = append(env, server.BearerTokenEnvVar+"="+*binding.BearerToken) } - if len(binding.HTTPHeaders) > 0 { - server.EnvHTTPHeaders = map[string]string{} - for name, value := range binding.HTTPHeaders { - reference := "OAC_RUNTIME_MCP_HEADER_" + rand.Text() - server.EnvHTTPHeaders[name] = reference - env = append(env, reference+"="+value) - } - } servers[server.Name] = server } return servers, env, nil diff --git a/apps/daemon/internal/agent/codex/mcp_http_preflight.go b/apps/daemon/internal/agent/codex/mcp_http_preflight.go index 549b8a2c5..ccf97f07d 100644 --- a/apps/daemon/internal/agent/codex/mcp_http_preflight.go +++ b/apps/daemon/internal/agent/codex/mcp_http_preflight.go @@ -66,8 +66,8 @@ func matchesMCPConfig(raw json.RawMessage, declared map[string]mcpServerConfig) } } if server["bearer_token_env_var"] != nilIfEmpty(expected.BearerTokenEnvVar) || - !matchesMCPHeaderMap(server, "env_http_headers", expected.EnvHTTPHeaders) || - !matchesMCPHeaderMap(server, "http_headers", nil) { + !matchesMCPHeaderMap(server, "http_headers", expected.HTTPHeaders) || + !matchesMCPHeaderMap(server, "env_http_headers", nil) { return false } if expected.Required && server["required"] != true { diff --git a/apps/daemon/internal/agent/mcp_binding.go b/apps/daemon/internal/agent/mcp_binding.go index e86eea12d..f00c32311 100644 --- a/apps/daemon/internal/agent/mcp_binding.go +++ b/apps/daemon/internal/agent/mcp_binding.go @@ -12,7 +12,7 @@ import ( // MCPBinding is the Runtime's transient effective declaration. Adapters project // it into their native configuration without resolving credentials or packages. -// Never serialize or log bindings: headers and bearer values may be confidential. +// Never serialize or log bindings: bearer values are confidential. type MCPBinding struct { ServerLabel string ConnectionOrigin string diff --git a/contracts/agents-api/environments.md b/contracts/agents-api/environments.md index a78c2c1d9..5831cc0ff 100644 --- a/contracts/agents-api/environments.md +++ b/contracts/agents-api/environments.md @@ -386,13 +386,13 @@ Inline and referenced Skills use the same confidential snapshot and installer. T A Plugin declares MCP servers with `mcpServers: "./.mcp.json"` in `.codex-plugin/plugin.json`, or through a root `.mcp.json` when the path is omitted. The file holds `mcpServers` keyed by server name. Selecting a Plugin root as a capability directory activates its MCP declarations; selecting a parent directory discovers Skills without activating nested MCP servers. -The shared parser accepts HTTP `url`, `bearer_token_env_var` and literal `http_headers`, and stdio `command`, `args`, selected `env_vars` and a package-relative `cwd`. Public `env_http_headers` is unsupported. The Runtime re-parses the frozen installed packages and resolves selected values only from the initialized env; a missing value fails instead of falling back to a model or daemon variable. +The shared parser accepts HTTP `url`, `bearer_token_env_var` and literal `http_headers`, which are non-secret like public MCP headers, and stdio `command`, `args`, selected `env_vars` and a package-relative `cwd`. Public `env_http_headers` is unsupported. The Runtime re-parses the frozen installed packages and resolves selected values only from the initialized env; a missing value fails instead of falling back to a model or daemon variable. A stdio server starts through the daemon's stdio helper, which resolves the installed declaration and launches the command with the Harness's permissions. On Unix the helper replaces itself with the server; on Windows it forwards stdio inside the owned process tree. Initialized values override the declaration's variables. Process groups and Windows Jobs own cancellation and descendant cleanup, not isolation. [Harness capabilities](./harness-capabilities.md#environment-preparation) owns the supported Plugin transports and per-Harness limits. -Duplicate server identities are rejected. Claude rejects a header value containing `${` ([native differences](./index.md#differences-from-openai)). MiniMax ACP HTTP declarations stay in session-local native memory; tokens never enter native configuration files or process arguments. Required initialization and tool allowlists cannot be set through the Plugin manifest. +Duplicate server identities are rejected. Claude rejects `${` in a URL or header value ([native differences](./index.md#differences-from-openai)). MiniMax ACP HTTP declarations stay in session-local native memory; tokens never enter native configuration files or process arguments. Required initialization and tool allowlists cannot be set through the Plugin manifest. ### Effective bindings diff --git a/contracts/agents-api/execution-tools.md b/contracts/agents-api/execution-tools.md index b1ca669d6..756be7c71 100644 --- a/contracts/agents-api/execution-tools.md +++ b/contracts/agents-api/execution-tools.md @@ -85,7 +85,7 @@ Execution carries the resolved values to the Harness and validates them against } ``` -- `server_label` is nonempty and unique within the Session. Only the `http` transport is accepted; `server_url` is an absolute HTTP or HTTPS URL without credentials or fragment. Nonempty `request_metadata` and an inline `authorization` are rejected. `headers` are non-secret and reach the native Harness unchanged beside any bearer; an `Authorization` header is rejected, because credentials enter only through a Vault credential. Claude rejects a header value containing `${` ([native differences](./index.md#differences-from-openai)). +- `server_label` is nonempty and unique within the Session. Only the `http` transport is accepted; `server_url` is an absolute HTTP or HTTPS URL without credentials or fragment. Nonempty `request_metadata` and an inline `authorization` are rejected. `headers` are non-secret, need valid HTTP names and values with names unique ignoring case, and reach the native Harness unchanged beside any bearer; an `Authorization` header is rejected, because credentials enter only through a Vault credential. Claude rejects `${` in `server_url` or a header value ([native differences](./index.md#differences-from-openai)). - [Public MCP connection origin](./environments.md#public-mcp-connection-origin) owns origin defaults, placement and credential authority; [Harness capabilities](./harness-capabilities.md#tools) owns per-Harness support. - Omitted or null `allowed_tools` permits every server tool; `[]` permits none. - `required: true` makes native thread creation and cold resume wait for the server to initialize; a failure stops execution without replacing retained history. It needs the Runtime's `mcp_http_required` capability. Public work can be accepted or queued during the wait. diff --git a/contracts/agents-api/index.md b/contracts/agents-api/index.md index 45dcb32d9..49d094284 100644 --- a/contracts/agents-api/index.md +++ b/contracts/agents-api/index.md @@ -74,7 +74,7 @@ Each item is Core's deliberate or native behavior where the official service beh - An omitted reasoning effort stays null instead of taking the model's default. - Codex maps programmatic tool calling to its native `code_mode`, whose model-written scripts call the other tools; the feature is under development upstream. - A Session's `agent.tools` omits `tool_search` declarations. -- Claude rejects an MCP `headers` value containing `${`, because the native client expands `${VAR}` from a process environment that holds Core-held credentials. +- Claude rejects an MCP `server_url` or `headers` value containing `${`, because the native client expands `${VAR}` in both from a process environment that holds Core-held credentials. - Deleting a Session right after an events 202 returns 409, because Core admits the Turn in the same transaction; the official service returned 200. **Input, events and history** ([Sessions, events and history](./sessions-events.md), [message content](./message-content.md)) diff --git a/contracts/agents-api/zh/environments.md b/contracts/agents-api/zh/environments.md index 61c7f640c..e74cc0057 100644 --- a/contracts/agents-api/zh/environments.md +++ b/contracts/agents-api/zh/environments.md @@ -1,7 +1,7 @@ --- title: "Environment 与模板" source: contracts/agents-api/environments.md -source_hash: 8433303b1e06c831b272a6714639ff496be24a40e9b1efc8d29b3495a243529c +source_hash: ed0ee94084a111f2451d363086d23ec56758c1e3e1de2e9ae5bf40e41e4512e1 --- Environment 是 Session 的执行资源,包括 Harness 运行所在的机器、工作区以及已完成准备的能力。Session 通过其 `environment` 配置创建 Environment;不存在独立的 create 调用。Environment 模板是 Session 创建时解析的可复用准备配置。本契约涵盖这两类资源、两种放置方式、输入接纳、能力准备、Skills、Plugins 和 MCP 连接来源。 @@ -388,13 +388,13 @@ Skills 及其不可变版本是项目资源,独立于 Session 和原生安装 Plugin 可以在 `.codex-plugin/plugin.json` 中通过 `mcpServers: "./.mcp.json"` 声明 MCP 服务器,也可以在省略路径时使用根目录下的 `.mcp.json`。该文件包含以服务器名称为键的 `mcpServers`。将 Plugin 根目录选为能力目录会激活其 MCP 声明;选择父目录则会发现 Skills,但不会激活嵌套 MCP 服务器。 -共享解析器接受 HTTP `url`、`bearer_token_env_var` 和字面量 `http_headers`,以及 stdio `command`、`args`、选定的 `env_vars` 和包相对路径 `cwd`。不支持公开的 `env_http_headers`。Runtime 会重新解析已冻结的已安装包,并且只从已初始化的 env 中解析选定值;缺少值时会失败,而不会回退到模型或 daemon 变量。 +共享解析器接受 HTTP `url`、`bearer_token_env_var` 和字面量 `http_headers`(与公开 MCP 标头一样为非机密),以及 stdio `command`、`args`、选定的 `env_vars` 和包相对路径 `cwd`。不支持公开的 `env_http_headers`。Runtime 会重新解析已冻结的已安装包,并且只从已初始化的 env 中解析选定值;缺少值时会失败,而不会回退到模型或 daemon 变量。 stdio 服务器通过 daemon 的 stdio helper 启动;该 helper 会解析已安装的声明,并以 Harness 的权限启动命令。在 Unix 上,helper 会将自身替换为服务器;在 Windows 上,它会在所属进程树内部转发 stdio。已初始化的值会覆盖声明中的变量。进程组和 Windows Jobs 负责取消及后代进程清理,而不负责隔离。 [Harness capabilities](harness-capabilities.md#environment-preparation) 负责受支持的 Plugin 传输及每个 Harness 的限制。 -重复的服务器身份会被拒绝。Claude 会拒绝包含 `${` 的标头值([原生差异](index.md#differences-from-openai))。MiniMax ACP HTTP 声明会保留在 Session 本地的原生内存中;令牌绝不会进入原生配置文件或进程参数。无法通过 Plugin 清单设置必需初始化和工具允许列表。 +重复的服务器身份会被拒绝。Claude 会拒绝 URL 或标头值中的 `${`([原生差异](index.md#differences-from-openai))。MiniMax ACP HTTP 声明会保留在 Session 本地的原生内存中;令牌绝不会进入原生配置文件或进程参数。无法通过 Plugin 清单设置必需初始化和工具允许列表。 ### 有效绑定 {#effective-bindings} diff --git a/contracts/agents-api/zh/execution-tools.md b/contracts/agents-api/zh/execution-tools.md index 8a05872bd..8faf7e25c 100644 --- a/contracts/agents-api/zh/execution-tools.md +++ b/contracts/agents-api/zh/execution-tools.md @@ -1,7 +1,7 @@ --- title: "执行工具" source: contracts/agents-api/execution-tools.md -source_hash: 67ebe75e8c46e75aa8f03e0a630992abaa34e5cdcf99f0883232f4bb8f4bf4c9 +source_hash: ada5752faa24616f4fa75c6559028e3b24be0a4db92b77c7baf936d68021b8df --- Agent 在 `tools` 中声明应用函数、控制项和 MCP 服务器,并可在 `text.format` 中声明输出 schema。本契约说明 Core 如何验证声明、哪些内容跨越 Runtime 边界,以及调用方如何恢复待执行操作。[Harness 能力](harness-capabilities.md)列出各 Harness 在不同部署位置支持的操作。原生工作区工具和 Environment Plugin MCP 属于 [Environment](environments.md#skills-plugins-and-environment-mcp)。 @@ -87,7 +87,7 @@ Core 发送 `PromptRequestPayload.ToolSearch` 和每个 `FunctionTool.DeferLoadi } ``` -- `server_label` 非空且在 Session 中唯一。仅接受 `http` 传输;`server_url` 为不带凭据或片段的绝对 HTTP 或 HTTPS URL。非空 `request_metadata` 和内联 `authorization` 被拒绝。`headers` 为非机密标头,会与 bearer 一同原样传给原生 Harness;`Authorization` 标头被拒绝,因为凭据只通过 Vault 凭据进入。Claude 会拒绝包含 `${` 的标头值([原生差异](index.md#differences-from-openai))。 +- `server_label` 非空且在 Session 中唯一。仅接受 `http` 传输;`server_url` 为不带凭据或片段的绝对 HTTP 或 HTTPS URL。非空 `request_metadata` 和内联 `authorization` 被拒绝。`headers` 为非机密标头,名称和值须为有效的 HTTP 名称和值,且名称不区分大小写唯一,会与 bearer 一同原样传给原生 Harness;`Authorization` 标头被拒绝,因为凭据只通过 Vault 凭据进入。Claude 会拒绝 `server_url` 或标头值中的 `${`([原生差异](index.md#differences-from-openai))。 - [公开 MCP 连接来源](environments.md#public-mcp-connection-origin)定义来源默认值、部署位置和凭据权限;[Harness 能力](harness-capabilities.md#tools)定义各 Harness 支持范围。 - 省略或 null 的 `allowed_tools` 允许所有服务器工具;`[]` 不允许任何工具。 - `required: true` 使原生线程创建和冷恢复等待服务器初始化;失败会停止执行,不替换保留历史。它要求 Runtime 的 `mcp_http_required` 能力。等待期间公开工作可被接受或排队。 diff --git a/contracts/agents-api/zh/index.md b/contracts/agents-api/zh/index.md index c90995613..75a0dc6bd 100644 --- a/contracts/agents-api/zh/index.md +++ b/contracts/agents-api/zh/index.md @@ -1,7 +1,7 @@ --- title: "OpenAI 兼容性" source: contracts/agents-api/index.md -source_hash: ad472d852f561a92824ff566e4911b5f92ce09c2cb9a8eee4f531cd22945a04d +source_hash: 57fa589c761bdb16a56dce08b287a61a94d72fdc3ef7f33aacc10a1eb8ba0e62 --- Core 实现下方固定版本的 OpenAI Agents API。本台账列出各项资源的覆盖情况、相对于官方服务的所有差异以及已知缺口。 @@ -77,7 +77,7 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh - 未指定推理强度时会保持为 null,而不是采用模型的默认值。 - Codex 把程序化工具调用映射到原生 `code_mode`,由模型编写的脚本调用其他工具;该功能在上游仍处于开发中。 - Session 的 `agent.tools` 会省略 `tool_search` 声明。 -- Claude 会拒绝包含 `${` 的 MCP `headers` 值,因为原生客户端会从持有 Core 所持凭据的进程环境中展开 `${VAR}`。 +- Claude 会拒绝包含 `${` 的 MCP `server_url` 或 `headers` 值,因为原生客户端会从持有 Core 所持凭据的进程环境中展开这两处的 `${VAR}`。 - 在 events 202 之后立即删除 Session 会返回 409,因为 Core 会在同一事务中准入该 Turn;官方服务返回 200。 **输入、事件和历史**([Sessions, events and history](sessions-events.md)、[message content](message-content.md)) diff --git a/internal/agentplugin/mcp.go b/internal/agentplugin/mcp.go index b85119ae6..a9bfdfe6f 100644 --- a/internal/agentplugin/mcp.go +++ b/internal/agentplugin/mcp.go @@ -82,14 +82,13 @@ func (s *MCPServer) validate() error { (s.BearerTokenEnvVar != "" && !environmentVariable.MatchString(s.BearerTokenEnvVar)) { return ErrInvalid } - seen := map[string]bool{} - for name, value := range s.HTTPHeaders { - lower := strings.ToLower(name) - if !httpguts.ValidHeaderFieldName(name) || !httpguts.ValidHeaderFieldValue(value) || seen[lower] || - (lower == "authorization" && s.BearerTokenEnvVar != "") { + if !ValidHTTPHeaders(s.HTTPHeaders) { + return ErrInvalid + } + for name := range s.HTTPHeaders { + if strings.EqualFold(name, "Authorization") && s.BearerTokenEnvVar != "" { return ErrInvalid } - seen[lower] = true } case "stdio": if strings.TrimSpace(s.Command) == "" || strings.ContainsRune(s.Command, 0) || s.URL != "" || s.BearerTokenEnvVar != "" || s.HTTPHeaders != nil { @@ -120,3 +119,17 @@ func (s *MCPServer) validate() error { } return nil } + +// ValidHTTPHeaders reports whether every MCP header has a valid HTTP name and +// value, with names unique ignoring case. +func ValidHTTPHeaders(headers map[string]string) bool { + seen := map[string]bool{} + for name, value := range headers { + lower := strings.ToLower(name) + if !httpguts.ValidHeaderFieldName(name) || !httpguts.ValidHeaderFieldValue(value) || seen[lower] { + return false + } + seen[lower] = true + } + return true +} diff --git a/internal/harnessconfig/claudesdk/declaration.go b/internal/harnessconfig/claudesdk/declaration.go index eb3c7fb7e..ae25f61e7 100644 --- a/internal/harnessconfig/claudesdk/declaration.go +++ b/internal/harnessconfig/claudesdk/declaration.go @@ -105,8 +105,8 @@ func validateTools(tools []proto.FunctionTool, servers []proto.MCPHTTPServer) er if !mcpLabel.MatchString(server.ServerLabel) || server.ServerLabel == "functions" || strings.Contains(server.ServerURL, "#") { return errors.New("The configured engine does not support this HTTP MCP declaration.") } - if ExpandsEnvironment(server.Headers) { - return errors.New("The configured engine does not support ${ in MCP header values.") + if ExpandsEnvironment(server) { + return errors.New("The configured engine does not support ${ in MCP URLs or header values.") } } for _, tool := range tools { @@ -194,10 +194,13 @@ func validThinking(thinking map[string]any) bool { return true } -// ExpandsEnvironment reports a header value the native client would expand from -// its own environment, which holds Core-held credentials. -func ExpandsEnvironment(headers map[string]string) bool { - for _, value := range headers { +// ExpandsEnvironment reports a URL or header value the native client would +// expand from its own environment, which holds Core-held credentials. +func ExpandsEnvironment(server proto.MCPHTTPServer) bool { + if strings.Contains(server.ServerURL, "${") { + return true + } + for _, value := range server.Headers { if strings.Contains(value, "${") { return true } diff --git a/internal/harnessconfig/claudesdk/declaration_test.go b/internal/harnessconfig/claudesdk/declaration_test.go index be328d6f4..ae532bb63 100644 --- a/internal/harnessconfig/claudesdk/declaration_test.go +++ b/internal/harnessconfig/claudesdk/declaration_test.go @@ -37,7 +37,7 @@ func TestDeclarationAdmitsAnyMCPToolName(t *testing.T) { } } -func TestDeclarationRejectsMCPHeaderExpansion(t *testing.T) { +func TestDeclarationRejectsMCPEnvironmentExpansion(t *testing.T) { servers := []proto.MCPHTTPServer{{ServerLabel: "docs", ServerURL: "https://example.test/mcp", Headers: map[string]string{"X-Tenant": "a"}}} if err := Declaration.ValidateTools(nil, servers); err != nil { t.Fatal(err) @@ -46,4 +46,8 @@ func TestDeclarationRejectsMCPHeaderExpansion(t *testing.T) { if err := Declaration.ValidateTools(nil, servers); err == nil { t.Fatal("header value with ${ admitted") } + servers[0].Headers["X-Tenant"], servers[0].ServerURL = "a", "https://example.test/${ANTHROPIC_API_KEY}" + if err := Declaration.ValidateTools(nil, servers); err == nil { + t.Fatal("URL with ${ admitted") + } } diff --git a/packages/claude-sdk-adapter/src/mcp.ts b/packages/claude-sdk-adapter/src/mcp.ts index f0fe5458f..d0bb6ed23 100644 --- a/packages/claude-sdk-adapter/src/mcp.ts +++ b/packages/claude-sdk-adapter/src/mcp.ts @@ -92,10 +92,11 @@ export class MCPProfile { } else { const reference = server.bearer_token_env_var; if (reference && !process.env[reference]) throw new Error("missing MCP credential environment"); - // An explicit empty Authorization suppresses native OAuth and automatic auth. + // An explicit Authorization, empty unless declared, suppresses native OAuth and automatic auth. // Keep bearer references literal: SDK server configuration enters native argv. + const declared = Object.keys(server.headers ?? {}).some(name => name.toLowerCase() === "authorization"); this.servers[server.server_label] = { type: "http", url: server.server_url, alwaysLoad: true, - headers: { ...server.headers, Authorization: reference ? `Bearer \${${reference}}` : "" } }; + headers: { ...server.headers, ...(!declared && { Authorization: reference ? `Bearer \${${reference}}` : "" }) } }; } if (server.allowed_tools === null) this.allowed.push(prefix + "*"); else if (!server.allowed_tools.length) this.denied.push(prefix + "*"); diff --git a/packages/claude-sdk-adapter/tests/mcp_bearer.test.mjs b/packages/claude-sdk-adapter/tests/mcp_bearer.test.mjs index 261aecc0e..531c9de70 100644 --- a/packages/claude-sdk-adapter/tests/mcp_bearer.test.mjs +++ b/packages/claude-sdk-adapter/tests/mcp_bearer.test.mjs @@ -44,6 +44,8 @@ test("custom headers reach the native configuration beside the bearer", t => { t.after(() => { delete process.env[reference]; }); const profile = new MCPProfile([{ ...server, headers: { "X-Tenant": "a" } }], []); assert.deepEqual(profile.servers.private.headers, { "X-Tenant": "a", Authorization: "Bearer ${" + reference + "}" }); + const literal = new MCPProfile([{ ...server, bearer_token_env_var: undefined, headers: { authorization: "Bearer literal" } }], []); + assert.deepEqual(literal.servers.private.headers, { authorization: "Bearer literal" }); }); test("missing credential environment fails before the native query", () => { diff --git a/services/core/internal/api/mcp_configuration.go b/services/core/internal/api/mcp_configuration.go index 8295b474e..191afb04c 100644 --- a/services/core/internal/api/mcp_configuration.go +++ b/services/core/internal/api/mcp_configuration.go @@ -7,6 +7,7 @@ import ( "strings" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" ) const mcpHTTPOnly = "MCP currently supports HTTP transport only." @@ -55,6 +56,9 @@ func resolveMCPTool(raw json.RawMessage, saved bool) (json.RawMessage, error) { return nil, errors.New("MCP headers cannot carry Authorization; use credential_id.") } } + if !agentplugin.ValidHTTPHeaders(transport.Headers) { + return nil, errors.New("MCP headers require valid HTTP names and values, with names unique ignoring case.") + } var allowed *[]string if input.AllowedTools != nil { for _, name := range input.AllowedTools { diff --git a/services/core/internal/api/mcp_configuration_test.go b/services/core/internal/api/mcp_configuration_test.go index c0404bb62..628bc46f6 100644 --- a/services/core/internal/api/mcp_configuration_test.go +++ b/services/core/internal/api/mcp_configuration_test.go @@ -111,6 +111,8 @@ func TestMCPUnsupportedInputsAreSecretSafe(t *testing.T) { "inline authorization": {"transport": json.RawMessage(`{"type":"http","server_url":"https://mcp.example.test","authorization":"private-marker"}`)}, "headers": {"transport": json.RawMessage(`{"type":"http","server_url":"https://mcp.example.test","headers":{"Authorization":"private-marker"}}`)}, "URL credentials": {"transport": json.RawMessage(`{"type":"http","server_url":"https://private-marker@mcp.example.test"}`)}, + "header name": {"transport": json.RawMessage(`{"type":"http","server_url":"https://mcp.example.test","headers":{"X Tenant":"private-marker"}}`)}, + "duplicate headers": {"transport": json.RawMessage(`{"type":"http","server_url":"https://mcp.example.test","headers":{"X-A":"private-marker","x-a":"b"}}`)}, "stdio": {"transport": json.RawMessage(`{"type":"stdio","command":"private-marker","cwd":"/"}`)}, } { t.Run(name, func(t *testing.T) {