From cff9a92a97cc1d7cf36bdb8b6cd64fc91c647cef Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 19 Oct 2026 08:34:32 +0000 Subject: [PATCH 1/2] Let Harnesses own function and MCP URL limits --- apps/daemon/internal/agent/claudesdk/mcp.go | 2 +- .../agent/claudesdk/mcp_bearer_test.go | 2 +- .../internal/agent/claudesdk/mcp_test.go | 4 +-- apps/daemon/internal/agent/codex/functions.go | 3 --- .../internal/agent/codex/mcp_http_test.go | 1 - .../internal/agent/mcode/environment_mcp.go | 2 +- apps/daemon/internal/agent/mcp_binding.go | 2 +- contracts/agents-api/execution-tools.md | 4 +-- contracts/agents-api/wire-semantics.md | 2 +- contracts/agents-api/zh/execution-tools.md | 6 ++--- contracts/agents-api/zh/wire-semantics.md | 4 +-- .../harnessconfig/claudesdk/declaration.go | 2 +- packages/claude-sdk-adapter/src/mcp.ts | 2 +- .../claude-sdk-adapter/tests/mcp.test.mjs | 4 ++- .../api/configuration_validation_test.go | 2 +- .../internal/api/function_configuration.go | 7 ++--- .../core/internal/api/mcp_configuration.go | 4 +-- .../internal/api/mcp_configuration_test.go | 26 ++++++++++++++++++- .../deployment_provider_observations_test.go | 6 ++--- .../core/internal/execution/dispatcher.go | 9 +++---- services/core/internal/execution/functions.go | 5 +--- .../core/internal/execution/functions_test.go | 16 ++++++++++++ services/core/internal/execution/mcp.go | 2 +- services/core/internal/execution/mcp_test.go | 9 +++++++ .../internal/execution/prepared_dispatch.go | 4 +-- 25 files changed, 84 insertions(+), 46 deletions(-) diff --git a/apps/daemon/internal/agent/claudesdk/mcp.go b/apps/daemon/internal/agent/claudesdk/mcp.go index b193136d7..31e9d44ef 100644 --- a/apps/daemon/internal/agent/claudesdk/mcp.go +++ b/apps/daemon/internal/agent/claudesdk/mcp.go @@ -34,7 +34,7 @@ func validateMCPServers(servers []proto.MCPHTTPServer) error { endpoint, err := url.Parse(server.ServerURL) if !mcpLabel.MatchString(server.ServerLabel) || server.ServerLabel == "functions" || labels[server.ServerLabel] || err != nil || (endpoint.Scheme != "http" && endpoint.Scheme != "https") || endpoint.Hostname() == "" || endpoint.User != nil || - strings.ContainsAny(server.ServerURL, "?#") || endpoint.Opaque != "" { + strings.Contains(server.ServerURL, "#") || endpoint.Opaque != "" { return fmt.Errorf("claudesdk: unsupported HTTP MCP declaration") } if server.BearerToken != nil && (endpoint.Scheme != "https" || !agent.ValidMCPHTTPBearerToken(*server.BearerToken)) { diff --git a/apps/daemon/internal/agent/claudesdk/mcp_bearer_test.go b/apps/daemon/internal/agent/claudesdk/mcp_bearer_test.go index 21997b158..69a52785b 100644 --- a/apps/daemon/internal/agent/claudesdk/mcp_bearer_test.go +++ b/apps/daemon/internal/agent/claudesdk/mcp_bearer_test.go @@ -75,7 +75,7 @@ func TestMCPBearerRejectsInvalidCredentialBeforeStateCreation(t *testing.T) { t.Fatal("invalid credential wrote execution state", err) } } - for _, url := range []string{"http://example.invalid/mcp", "https://example.invalid/mcp#", "https://example.invalid/mcp?", "https://user:secret@example.invalid/mcp"} { + for _, url := range []string{"http://example.invalid/mcp", "https://example.invalid/mcp#", "https://user:secret@example.invalid/mcp"} { token := "synthetic-token" servers := []proto.MCPHTTPServer{{ConnectionOrigin: "service", ServerLabel: "fixture", ServerURL: url, BearerToken: &token}} if err := validateMCP(proto.PromptRequestPayload{DisableExecutionEnvironment: true, MCPHTTPServers: &servers}); err == nil { diff --git a/apps/daemon/internal/agent/claudesdk/mcp_test.go b/apps/daemon/internal/agent/claudesdk/mcp_test.go index a8ad18877..d44e4748c 100644 --- a/apps/daemon/internal/agent/claudesdk/mcp_test.go +++ b/apps/daemon/internal/agent/claudesdk/mcp_test.go @@ -35,7 +35,7 @@ func TestHTTPMCPDeclaration(t *testing.T) { case "url-auth": servers[0].ServerURL = "https://user:secret@example.invalid/mcp" case "query": - servers[0].ServerURL += "?" + servers[0].ServerURL += "?tenant=a" case "wildcard": tools = []string{"*"} servers[0].AllowedTools = &tools @@ -47,7 +47,7 @@ func TestHTTPMCPDeclaration(t *testing.T) { req.DisableExecutionEnvironment = false } start, _, err := prepare(config, req) - valid := mode == "unrestricted" || mode == "selected" || mode == "empty" || mode == "nil-slice" || mode == "auth" || mode == "required" + valid := mode == "unrestricted" || mode == "selected" || mode == "empty" || mode == "nil-slice" || mode == "auth" || mode == "required" || mode == "query" if (err == nil) != valid { t.Fatalf("unexpected admission: %v", err) } diff --git a/apps/daemon/internal/agent/codex/functions.go b/apps/daemon/internal/agent/codex/functions.go index e2a359d4b..31e6fdc5e 100644 --- a/apps/daemon/internal/agent/codex/functions.go +++ b/apps/daemon/internal/agent/codex/functions.go @@ -28,9 +28,6 @@ type functionCalls struct { func prepareFunctionTools(tools []proto.FunctionTool) (*functionCalls, error) { state := &functionCalls{names: map[string]bool{}, pending: map[string]*pendingFunction{}} - if len(tools) > 64 { - return nil, errors.New("at most 64 function tools are supported") - } for _, tool := range tools { var schema map[string]any if strings.TrimSpace(tool.Name) == "" || state.names[tool.Name] || json.Unmarshal(tool.Parameters, &schema) != nil || schema == nil { diff --git a/apps/daemon/internal/agent/codex/mcp_http_test.go b/apps/daemon/internal/agent/codex/mcp_http_test.go index 015c0e939..2af2ce76b 100644 --- a/apps/daemon/internal/agent/codex/mcp_http_test.go +++ b/apps/daemon/internal/agent/codex/mcp_http_test.go @@ -81,7 +81,6 @@ func TestPublicMCPHTTPRejectsInvalidProfileAndStoredCredentials(t *testing.T) { for _, server := range []proto.MCPHTTPServer{ {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} diff --git a/apps/daemon/internal/agent/mcode/environment_mcp.go b/apps/daemon/internal/agent/mcode/environment_mcp.go index 867aa8c6f..e63a60d60 100644 --- a/apps/daemon/internal/agent/mcode/environment_mcp.go +++ b/apps/daemon/internal/agent/mcode/environment_mcp.go @@ -39,7 +39,7 @@ func runtimeMCP(req proto.PromptRequestPayload) ([]map[string]any, error) { // pinned native HTTP client can forward them across redirect origins. func environmentHTTPMCP(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.RawQuery != "" || endpoint.ForceQuery || endpoint.Opaque != "" || len(item.HTTPHeaders) != 0 { + if err != nil || (endpoint.Scheme != "http" && endpoint.Scheme != "https") || endpoint.Hostname() == "" || endpoint.User != nil || endpoint.Fragment != "" || endpoint.Opaque != "" || len(item.HTTPHeaders) != 0 { return nil, fmt.Errorf("mcode: unsupported environment HTTP MCP declaration") } headers := []map[string]string{} diff --git a/apps/daemon/internal/agent/mcp_binding.go b/apps/daemon/internal/agent/mcp_binding.go index a59eb8091..2598681f9 100644 --- a/apps/daemon/internal/agent/mcp_binding.go +++ b/apps/daemon/internal/agent/mcp_binding.go @@ -89,7 +89,7 @@ func ResolveMCPBindings(req proto.PromptRequestPayload) ([]MCPBinding, error) { } case "http": endpoint, err := url.Parse(item.ServerURL) - if err != nil || endpoint.Hostname() == "" || (endpoint.Scheme != "https" && endpoint.Scheme != "http") || endpoint.User != nil || endpoint.Fragment != "" || endpoint.Opaque != "" || (item.ConnectionOrigin == "service" && (endpoint.RawQuery != "" || endpoint.ForceQuery)) { + if err != nil || endpoint.Hostname() == "" || (endpoint.Scheme != "https" && endpoint.Scheme != "http") || endpoint.User != nil || endpoint.Fragment != "" || endpoint.Opaque != "" { return nil, errors.New("invalid MCP endpoint") } if item.BearerToken != nil && (endpoint.Scheme != "https" || !ValidMCPHTTPBearerToken(*item.BearerToken)) { diff --git a/contracts/agents-api/execution-tools.md b/contracts/agents-api/execution-tools.md index 5da10f5f7..d3d44fa7c 100644 --- a/contracts/agents-api/execution-tools.md +++ b/contracts/agents-api/execution-tools.md @@ -13,7 +13,7 @@ An Agent declares application functions, controls and MCP servers in `tools`, an ## Functions -A function declaration requires `name`, `description` and a JSON Schema in `parameters`; `defer_loading` defaults to false and cannot be null. Names are nonblank, unique and at most 512 bytes; a Session holds at most 64 function definitions. Saved references resolve into the Session snapshot, and the definitions stay fixed through native preparation and continuation. +A function declaration requires `name`, `description` and a JSON Schema in `parameters`; `defer_loading` defaults to false and cannot be null. Names are nonblank and unique. Saved references resolve into the Session snapshot, and the definitions stay fixed through native preparation and continuation. **Results.** A caller submits `agent.session.input.tool_result` events with `turn_id`, `call_id` and `success`, and optional nullable `error` and `output`. Output is a string or ordered text and image parts, subject to the Harness. Batches are atomic and keep field presence, original content and retry identity; public result Items and events always carry `output` and `error`, null when not submitted. @@ -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, query 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 `headers` and `request_metadata` and an inline `authorization` are rejected. - [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/wire-semantics.md b/contracts/agents-api/wire-semantics.md index e64456471..f039fc833 100644 --- a/contracts/agents-api/wire-semantics.md +++ b/contracts/agents-api/wire-semantics.md @@ -186,7 +186,7 @@ A Session's effective configuration must also pass execution admission, which ap | Configuration | Message | | --- | --- | | A `reasoning.effort`, `reasoning.summary`, `service_tier`, `web_search` mode or setting, `programmatic_tool_calling` value or `text.verbosity` the Harness does not declare | A message naming the field, such as `reasoning.effort "max" is not supported by this Harness.`; see [tool policy](./execution-tools.md#web-search-and-programmatic-tool-calling) and [Harness capabilities](./harness-capabilities.md#execution-and-input) | -| More than 64 functions, or a function name that is blank or longer than 512 bytes | "This service supports at most 64 function tools." or "Function names must be nonempty, unique and at most 512 bytes." | +| A function name that is blank or repeated | "Function names must be nonempty and unique." | | Two MCP servers with one label | "Execution requires distinct MCP server labels." | A per-Session `tools` replacement admits a Session whose saved tools would be rejected. Support for each tool and Harness is in [execution and tools](./execution-tools.md). diff --git a/contracts/agents-api/zh/execution-tools.md b/contracts/agents-api/zh/execution-tools.md index 7364a0839..123e8c6c4 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: 7608c08f707c1970f1365d9b5d5f0979a71af35c4b9ca82d64ec6d2d95344a2a +source_hash: db52d727ddf370a8047089457227584e89faf2132546a164b170e6b475b28abb --- Agent 在 `tools` 中声明应用函数、控制项和 MCP 服务器,并可在 `text.format` 中声明输出 schema。本契约说明 Core 如何验证声明、哪些内容跨越 Runtime 边界,以及调用方如何恢复待执行操作。[Harness 能力](harness-capabilities.md)列出各 Harness 在不同部署位置支持的操作。原生工作区工具和 Environment Plugin MCP 属于 [Environment](environments.md#skills-plugins-and-environment-mcp)。 @@ -15,7 +15,7 @@ Agent 在 `tools` 中声明应用函数、控制项和 MCP 服务器,并可在 ## 函数 {#functions} -函数声明要求 `name`、`description` 和 `parameters` 中的 JSON Schema;`defer_loading` 默认为 false,不能为 null。名称必须非空白、唯一且最多 512 字节;每个 Session 最多有 64 个函数定义。保存的引用解析到 Session 快照,定义在原生准备和继续执行期间保持固定。 +函数声明要求 `name`、`description` 和 `parameters` 中的 JSON Schema;`defer_loading` 默认为 false,不能为 null。名称必须非空白且唯一。保存的引用解析到 Session 快照,定义在原生准备和继续执行期间保持固定。 **结果。** 调用方提交 `agent.session.input.tool_result` 事件,包含 `turn_id`、`call_id`、`success`,以及可选且可空的 `error` 和 `output`。输出为字符串或有序文本与图像部分,受 Harness 支持范围约束。批次原子处理并保留字段存在性、原始内容和重试身份;公开结果 Item 与事件始终包含 `output` 和 `error`,未提交时为 null。 @@ -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。非空 `headers`、`request_metadata` 和内联 `authorization` 被拒绝。 - [公开 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/wire-semantics.md b/contracts/agents-api/zh/wire-semantics.md index fe522ed0f..a5f0724bd 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: c84b3d24f75bdc6fc608a7944bd0b23e7b9d6f969c12691667c72a5013ea59b9 +source_hash: 204cc23703043518da5b2dfcadfb3521be7cce417cf80d0b4fe7356b4dfdfa02 --- 已锁定版本的 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) 分别负责各自资源的规则。 @@ -188,7 +188,7 @@ Session 的有效配置还必须通过执行准入,无论配置来自保存还 | 配置 | 消息 | | --- | --- | | Harness 未声明的 `reasoning.effort`、`reasoning.summary`、`service_tier`、`web_search` 模式或设置、`programmatic_tool_calling` 取值或 `text.verbosity` | 指出字段的消息,例如 `reasoning.effort "max" is not supported by this Harness.`;见 [tool policy](execution-tools.md#web-search-and-programmatic-tool-calling) 和 [Harness 能力](harness-capabilities.md#execution-and-input) | -| 超过 64 个函数,或函数名称为空或超过 512 字节 | "This service supports at most 64 function tools." 或 "Function names must be nonempty, unique and at most 512 bytes." | +| 函数名称为空或重复 | "Function names must be nonempty and unique." | | 两个使用同一标签的 MCP 服务器 | "Execution requires distinct MCP server labels." | 每 Session 的 `tools` 替换可以允许一个其已保存 tools 原本会被拒绝的 Session。每种工具和 Harness 的支持情况见 [execution and tools](execution-tools.md)。 diff --git a/internal/harnessconfig/claudesdk/declaration.go b/internal/harnessconfig/claudesdk/declaration.go index bfdc298c5..66f50403d 100644 --- a/internal/harnessconfig/claudesdk/declaration.go +++ b/internal/harnessconfig/claudesdk/declaration.go @@ -129,7 +129,7 @@ func validateConfiguration(admission harnessconfig.Admission) error { // resolution still owns URL, transport and credential-binding validation. func validateTools(tools []proto.FunctionTool, servers []proto.MCPHTTPServer) error { for _, server := range servers { - if !mcpLabel.MatchString(server.ServerLabel) || server.ServerLabel == "functions" || strings.ContainsAny(server.ServerURL, "?#") { + 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 server.AllowedTools != nil { diff --git a/packages/claude-sdk-adapter/src/mcp.ts b/packages/claude-sdk-adapter/src/mcp.ts index 72118da0d..0d6a07109 100644 --- a/packages/claude-sdk-adapter/src/mcp.ts +++ b/packages/claude-sdk-adapter/src/mcp.ts @@ -36,7 +36,7 @@ export function parseHTTPServers(value: unknown): HTTPServer[] | undefined { } const url = new URL(server.server_url); if (!["http:", "https:"].includes(url.protocol) || !url.hostname || url.username || url.password || - server.server_url.includes("?") || server.server_url.includes("#")) throw new Error("invalid_request"); + server.server_url.includes("#")) throw new Error("invalid_request"); if (server.bearer_token_env_var !== undefined) { const reference = server.bearer_token_env_var; if (url.protocol !== "https:" || typeof reference !== "string" || diff --git a/packages/claude-sdk-adapter/tests/mcp.test.mjs b/packages/claude-sdk-adapter/tests/mcp.test.mjs index 70bc102e2..6029ba41b 100644 --- a/packages/claude-sdk-adapter/tests/mcp.test.mjs +++ b/packages/claude-sdk-adapter/tests/mcp.test.mjs @@ -29,12 +29,14 @@ test("invalid remote declarations and wildcard injection fail at the bridge boun const start = { type: "start", input: [{ content: [{ type: "input_text", text: "hello" }] }], model: "model", system_prompt: "", cwd: "/tmp" }; for (const value of [null, {}, [declaration(["*"])], [{ ...declaration(null), server_label: "functions" }], [{ ...declaration(null), server_url: "https://user:secret@example.invalid/mcp" }], - [{ ...declaration(null), server_url: "https://example.invalid/mcp?" }], + [{ ...declaration(null), server_url: "https://example.invalid/mcp#fragment" }], [{ ...declaration(null), required: "true" }], [{ ...declaration(null), required: null }], [declaration(null), declaration([])]]) { assert.throws(() => parseStart(JSON.stringify({ ...start, mcp_http_servers: value }))); } assert.deepEqual(parseStart(JSON.stringify({ ...start, mcp_http_servers: [declaration(null), { ...declaration([]), server_label: "empty" }] })).mcp_http_servers, [declaration(null), { ...declaration([]), server_label: "empty" }]); + const withQuery = { ...declaration(null), server_url: "https://example.invalid/mcp?tenant=a" }; + assert.deepEqual(parseStart(JSON.stringify({ ...start, mcp_http_servers: [withQuery] })).mcp_http_servers, [withQuery]); }); test("native tool spelling preserves original identity and rejects ambiguous aliases", () => { diff --git a/services/core/internal/api/configuration_validation_test.go b/services/core/internal/api/configuration_validation_test.go index 87a6525bd..2f4c48ef6 100644 --- a/services/core/internal/api/configuration_validation_test.go +++ b/services/core/internal/api/configuration_validation_test.go @@ -261,7 +261,7 @@ func TestAgentConfigurationLocalLimitsKeepCodes(t *testing.T) { for _, tc := range []struct{ fields, message string }{ {`"tools":[{"type":"programmatic_tool_calling","enabled":false},{"type":"programmatic_tool_calling","enabled":false}]`, "Execution requires distinct tool controls."}, {`"multi_agent":{"enabled":true},"tools":[{"type":"programmatic_tool_calling","enabled":true}]`, "Harness codex does not support the requested Agent/environment configuration: programmatic_tool_calling.enabled=true is not supported with multi_agent by this Harness."}, - {`"tools":[{"type":"function","name":"","description":"","parameters":{}}]`, "Function names must be nonempty, unique and at most 512 bytes."}, + {`"tools":[{"type":"function","name":"","description":"","parameters":{}}]`, "Function names must be nonempty and unique."}, } { assertConfigurationError(t, credentialRequest(h, http.MethodPost, session.path, session.body(tc.fields)), "unsupported_or_invalid_configuration", nil, tc.message) } diff --git a/services/core/internal/api/function_configuration.go b/services/core/internal/api/function_configuration.go index 0674a43f5..106c87148 100644 --- a/services/core/internal/api/function_configuration.go +++ b/services/core/internal/api/function_configuration.go @@ -10,13 +10,10 @@ import ( func resolveFunctions(input []v1.FunctionToolInput) ([]json.RawMessage, error) { tools := make([]json.RawMessage, 0, len(input)) - if len(input) > 64 { - return nil, errors.New("This service supports at most 64 function tools.") - } names := make(map[string]bool, len(input)) for _, tool := range input { - if strings.TrimSpace(tool.Name) == "" || len(tool.Name) > 512 || names[tool.Name] { - return nil, errors.New("Function names must be nonempty, unique and at most 512 bytes.") + if strings.TrimSpace(tool.Name) == "" || names[tool.Name] { + return nil, errors.New("Function names must be nonempty and unique.") } value, err := resolveFunction(tool) if err != nil { diff --git a/services/core/internal/api/mcp_configuration.go b/services/core/internal/api/mcp_configuration.go index 6af603b52..9b3b82769 100644 --- a/services/core/internal/api/mcp_configuration.go +++ b/services/core/internal/api/mcp_configuration.go @@ -44,8 +44,8 @@ func resolveMCPTool(raw json.RawMessage, saved bool) (json.RawMessage, error) { return nil, errors.New("Nonempty MCP request_metadata is not supported yet.") } u, err := url.Parse(transport.ServerURL) - if err != nil || u.Hostname() == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.Fragment != "" || u.RawQuery != "" || u.ForceQuery { - return nil, errors.New("MCP server_url must be an absolute HTTP(S) URL without credentials, query or fragment.") + if err != nil || u.Hostname() == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.Fragment != "" { + return nil, errors.New("MCP server_url must be an absolute HTTP(S) URL without credentials or fragment.") } if transport.Authorization != nil { return nil, errors.New("Inline MCP authorization is not supported yet.") diff --git a/services/core/internal/api/mcp_configuration_test.go b/services/core/internal/api/mcp_configuration_test.go index f0013cafb..7b0f79e48 100644 --- a/services/core/internal/api/mcp_configuration_test.go +++ b/services/core/internal/api/mcp_configuration_test.go @@ -1,7 +1,10 @@ package api import ( + "fmt" + "encoding/json" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "reflect" "strings" "testing" @@ -108,7 +111,6 @@ 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"}`)}, - "URL query": {"transport": json.RawMessage(`{"type":"http","server_url":"https://mcp.example.test/?token=private-marker"}`)}, "stdio": {"transport": json.RawMessage(`{"type":"stdio","command":"private-marker","cwd":"/"}`)}, } { t.Run(name, func(t *testing.T) { @@ -164,3 +166,25 @@ func TestPublicEnvironmentMCPPreservesDeclaration(t *testing.T) { } } } + +func TestMCPServerURLKeepsQuery(t *testing.T) { + const serverURL = "https://mcp.example.test/tools?tenant=a%20b" + saved, err := resolveMCPTool(json.RawMessage(strings.Replace(publicMCP, "https://mcp.example.test/tools", serverURL, 1)), true) + if err != nil || !strings.Contains(string(saved), serverURL) { + t.Fatalf("query URL was rejected or changed: %s, %v", saved, err) + } +} + +func TestFunctionsHaveNoCountOrNameLengthLimit(t *testing.T) { + var input []v1.FunctionToolInput + for i := 0; i < 65; i++ { + name := fmt.Sprintf("f%d", i) + if i == 0 { + name = strings.Repeat("n", 600) + } + input = append(input, v1.FunctionToolInput{Name: name, Parameters: json.RawMessage(`{"type":"object"}`)}) + } + if tools, err := resolveFunctions(input); err != nil || len(tools) != 65 { + t.Fatal(len(tools), err) + } +} diff --git a/services/core/internal/execution/deployment_provider_observations_test.go b/services/core/internal/execution/deployment_provider_observations_test.go index b1240173f..1eb032221 100644 --- a/services/core/internal/execution/deployment_provider_observations_test.go +++ b/services/core/internal/execution/deployment_provider_observations_test.go @@ -120,7 +120,7 @@ func TestFinishRunObservesOnlyFinalCommittedOutcome(t *testing.T) { if tc.invalid { result.Error = strings.Repeat("x", 513*1024) } - turn, err := f.dispatcher.finishRun(f.tenant, f.session.ID, receipt.TurnID, "fixture-model", result, tc.status) + turn, err := f.dispatcher.finishRun(f.tenant, f.session.ID, receipt.TurnID, result, tc.status) if err != nil { t.Fatal(err) } @@ -136,7 +136,7 @@ func TestFinishRunObservesOnlyFinalCommittedOutcome(t *testing.T) { if _, err = f.pool.Exec(t.Context(), "UPDATE deployment_model_providers SET last_used_at=NULL,last_error_at=NULL,last_error_code=NULL,recovery_pending=false"); err != nil { t.Fatal(err) } - _, err = f.dispatcher.finishRun(f.tenant, f.session.ID, receipt.TurnID, "fixture-model", result, tc.status) + _, err = f.dispatcher.finishRun(f.tenant, f.session.ID, receipt.TurnID, result, tc.status) if !errors.Is(err, sessions.ErrTurnConflict) { t.Fatal("expected completion conflict", err) } @@ -211,7 +211,7 @@ func TestFinishRunObservationLockTimeoutAndFailureKeepLease(t *testing.T) { defer cleanup() } started := time.Now() - turn, err := f.dispatcher.finishRun(f.tenant, f.session.ID, receipt.TurnID, "fixture-model", Result{AppliedThrough: receipt.Sequence}, sessions.TurnCompleted) + turn, err := f.dispatcher.finishRun(f.tenant, f.session.ID, receipt.TurnID, Result{AppliedThrough: receipt.Sequence}, sessions.TurnCompleted) elapsed := time.Since(started) if err != nil || turn.Status != sessions.TurnCompleted { t.Fatal("observation changed commit result", err, turn.Status) diff --git a/services/core/internal/execution/dispatcher.go b/services/core/internal/execution/dispatcher.go index 6364a9ae9..34005155b 100644 --- a/services/core/internal/execution/dispatcher.go +++ b/services/core/internal/execution/dispatcher.go @@ -119,17 +119,14 @@ func (d *Dispatcher) Run(ctx context.Context, tenantID, sessionID, turnID string } release, err := peer.TrackExecutionDelivery(turnID) if err != nil { - return d.finishRun(tenantID, sessionID, turnID, snapshot.Agent.Model, Result{ErrorCode: "delivery_unknown", AppliedThrough: through}, sessions.TurnFailed) + return d.finishRun(tenantID, sessionID, turnID, Result{ErrorCode: "delivery_unknown", AppliedThrough: through}, sessions.TurnFailed) } defer release() result, status := d.deliver(ctx, tenantID, sessionID, peer, req, turnID, text, through, prepared) - return d.finishRun(tenantID, sessionID, turnID, snapshot.Agent.Model, result, status) + return d.finishRun(tenantID, sessionID, turnID, result, status) } -func (d *Dispatcher) finishRun(tenantID, sessionID, turnID, model string, result Result, status string) (sessions.Turn, error) { - if result.Done.Usage.Model == "" { - result.Done.Usage.Model = model - } +func (d *Dispatcher) finishRun(tenantID, sessionID, turnID string, result Result, status string) (sessions.Turn, error) { nativeID, _ := result.Done.Metadata[proto.DoneMetaAgentSessionID].(string) finishCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() diff --git a/services/core/internal/execution/functions.go b/services/core/internal/execution/functions.go index f3804de11..2fcb5b2e4 100644 --- a/services/core/internal/execution/functions.go +++ b/services/core/internal/execution/functions.go @@ -17,9 +17,6 @@ import ( func functionTools(raw []json.RawMessage) ([]proto.FunctionTool, error) { tools := make([]proto.FunctionTool, 0, len(raw)) names := map[string]bool{} - if len(raw) > 64 { - return nil, errors.New("at most 64 function tools are supported") - } for _, value := range raw { var tool struct { Type string `json:"type"` @@ -34,7 +31,7 @@ func functionTools(raw []json.RawMessage) ([]proto.FunctionTool, error) { return nil, err } var schema map[string]json.RawMessage - if tool.Type != "function" || strings.TrimSpace(tool.Name) == "" || len(tool.Name) > 512 || names[tool.Name] || json.Unmarshal(tool.Parameters, &schema) != nil || schema == nil { + if tool.Type != "function" || strings.TrimSpace(tool.Name) == "" || names[tool.Name] || json.Unmarshal(tool.Parameters, &schema) != nil || schema == nil { return nil, errors.New("execution requires unique functions with object schemas") } names[tool.Name] = true diff --git a/services/core/internal/execution/functions_test.go b/services/core/internal/execution/functions_test.go index 87ed54ee6..5940ed2a7 100644 --- a/services/core/internal/execution/functions_test.go +++ b/services/core/internal/execution/functions_test.go @@ -2,7 +2,9 @@ package execution import ( "encoding/json" + "fmt" "reflect" + "strings" "testing" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" @@ -25,6 +27,20 @@ func TestFunctionDefinitionsRejectUnsupportedConfiguration(t *testing.T) { } } +func TestFunctionDefinitionsHaveNoCountOrNameLengthLimit(t *testing.T) { + var raw []json.RawMessage + for i := 0; i < 65; i++ { + name := fmt.Sprintf("f%d", i) + if i == 0 { + name = strings.Repeat("n", 600) + } + raw = append(raw, json.RawMessage(`{"type":"function","name":"`+name+`","description":"","parameters":{"type":"object"},"defer_loading":false}`)) + } + if tools, err := functionTools(raw); err != nil || len(tools) != 65 { + t.Fatal(len(tools), err) + } +} + func TestFunctionResultPreservesCompleteContent(t *testing.T) { text := func(value string) proto.InputContent { return proto.InputContent{Type: "input_text", Text: &value} diff --git a/services/core/internal/execution/mcp.go b/services/core/internal/execution/mcp.go index eb17b443f..c0cf8b93c 100644 --- a/services/core/internal/execution/mcp.go +++ b/services/core/internal/execution/mcp.go @@ -80,7 +80,7 @@ func executionTools(raw []json.RawMessage) (executionToolSet, error) { return executionToolSet{}, errors.New("unsupported execution MCP configuration") } u, err := url.Parse(tool.Transport.ServerURL) - if err != nil || u.Hostname() == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.Fragment != "" || u.RawQuery != "" || u.ForceQuery { + if err != nil || u.Hostname() == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.Fragment != "" { return executionToolSet{}, errors.New("unsupported execution MCP URL") } if tool.AllowedTools != nil { diff --git a/services/core/internal/execution/mcp_test.go b/services/core/internal/execution/mcp_test.go index d4bd63d6c..b0fb86e2f 100644 --- a/services/core/internal/execution/mcp_test.go +++ b/services/core/internal/execution/mcp_test.go @@ -5,6 +5,15 @@ import ( "testing" ) +func TestMCPExecutionKeepsServerURLQuery(t *testing.T) { + const serverURL = "https://mcp.example/mcp?tenant=a%20b&x=" + tool := json.RawMessage(`{"type":"mcp","server_label":"tickets","transport":{"type":"http","server_url":"` + serverURL + `"},"connection_origin":"service"}`) + tools, err := executionTools([]json.RawMessage{tool}) + if err != nil || len(tools.MCP) != 1 || tools.MCP[0].ServerURL != serverURL { + t.Fatal(tools.MCP, err) + } +} + func TestMCPRequiresSupportedServicePlacement(t *testing.T) { tool := json.RawMessage(`{"type":"mcp","server_label":"tickets","transport":{"type":"http","server_url":"https://mcp.example/mcp"},"connection_origin":"service","allowed_tools":[]}`) for _, profile := range []struct { diff --git a/services/core/internal/execution/prepared_dispatch.go b/services/core/internal/execution/prepared_dispatch.go index d48d00890..7fb9ffca2 100644 --- a/services/core/internal/execution/prepared_dispatch.go +++ b/services/core/internal/execution/prepared_dispatch.go @@ -110,12 +110,12 @@ func (d *Dispatcher) RunEnvironmentInput(ctx context.Context, lease Ownership, t through := run.Reservation.Receipts[len(run.Reservation.Receipts)-1].Sequence releaseDelivery, err := peer.TrackExecutionDelivery(turnID) if err != nil { - run.Turn, err = d.finishRun(tenantID, sessionID, turnID, snapshot.Agent.Model, Result{ErrorCode: "delivery_unknown", AppliedThrough: through}, sessions.TurnFailed) + run.Turn, err = d.finishRun(tenantID, sessionID, turnID, Result{ErrorCode: "delivery_unknown", AppliedThrough: through}, sessions.TurnFailed) return run, err } defer releaseDelivery() result, status := d.deliver(owner, tenantID, sessionID, peer, req, turnID, messages, through, prepared) result, status = d.captureCompletedArtifacts(owner, peer, session, environment, bound.Device, turnID, result, status) - run.Turn, err = d.finishRun(tenantID, sessionID, turnID, snapshot.Agent.Model, result, status) + run.Turn, err = d.finishRun(tenantID, sessionID, turnID, result, status) return run, err } From d381c6bbffc25477b4d6bf87aceec30df4636aeb Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 19 Oct 2026 08:43:19 +0000 Subject: [PATCH 2/2] Admit MCP query URLs in the official-client check --- services/core/tests/official_mcp.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/services/core/tests/official_mcp.py b/services/core/tests/official_mcp.py index b41d95ffb..20971294b 100644 --- a/services/core/tests/official_mcp.py +++ b/services/core/tests/official_mcp.py @@ -71,8 +71,7 @@ def verify_mcp_configuration(client, other, expect_error): {**tool, "request_metadata": {"x": "y"}}, {**tool, "allowed_tools": [None]}] for changes in ({"headers": {"Authorization": "synthetic-private"}}, - {"authorization": "synthetic-private"}, - {"server_url": "https://mcp.example.invalid/mcp?token=synthetic-private"}): + {"authorization": "synthetic-private"}): invalid.append({**tool, "transport": {**transport, **changes}}) # A pinned stdio transport stays unsupported with or without an origin; a # malformed one gets the official field error first.