diff --git a/apps/daemon/internal/agent/claudesdk/mcp.go b/apps/daemon/internal/agent/claudesdk/mcp.go index 1aac9eddd..c71b83b24 100644 --- a/apps/daemon/internal/agent/claudesdk/mcp.go +++ b/apps/daemon/internal/agent/claudesdk/mcp.go @@ -31,6 +31,13 @@ func validateMCP(req proto.PromptRequestPayload) error { func validateMCPServers(servers []proto.MCPHTTPServer) error { labels := map[string]bool{} for _, server := range servers { + if server.Stdio != nil { + if !mcpLabel.MatchString(server.ServerLabel) || server.ServerLabel == "functions" || labels[server.ServerLabel] { + return fmt.Errorf("claudesdk: unsupported MCP stdio declaration") + } + labels[server.ServerLabel] = true + continue + } 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 || diff --git a/apps/daemon/internal/agent/claudesdk/mcp_environment.go b/apps/daemon/internal/agent/claudesdk/mcp_environment.go index 76cdaf4db..704d71f6a 100644 --- a/apps/daemon/internal/agent/claudesdk/mcp_environment.go +++ b/apps/daemon/internal/agent/claudesdk/mcp_environment.go @@ -2,6 +2,7 @@ package claudesdk import ( "fmt" + "path/filepath" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/localworkspace" @@ -12,11 +13,15 @@ import ( // transport projection crosses this private bridge, never package configuration. type environmentMCPServer struct { mcpHTTPServer - Command string `json:"command,omitempty"` - Args []string `json:"args,omitempty"` + Command string `json:"command,omitempty"` + // A pointer keeps an empty stdio argument list, which the bridge requires. + Args *[]string `json:"args,omitempty"` + // Declared marks a public stdio server: its command is the caller's, not the installed launcher. + Declared bool `json:"declared,omitempty"` } -func prepareRuntimeMCP(req proto.PromptRequestPayload) ([]environmentMCPServer, []string, error) { +// cwd is the directory the native process starts in; native starts stdio servers there. +func prepareRuntimeMCP(req proto.PromptRequestPayload, cwd string) ([]environmentMCPServer, []string, error) { bindings, err := agent.ResolveMCPBindings(req) if err != nil { return nil, nil, err @@ -27,9 +32,17 @@ func prepareRuntimeMCP(req proto.PromptRequestPayload) ([]environmentMCPServer, if !mcpLabel.MatchString(binding.ServerLabel) || binding.ServerLabel == "functions" { return nil, nil, fmt.Errorf("claudesdk: unsupported MCP identity") } + if process := binding.Process; process != nil { + if filepath.Clean(process.Cwd) != cwd { + return nil, nil, fmt.Errorf("claudesdk: MCP stdio cwd must be the workspace directory") + } + args := append([]string{}, process.Args...) + servers = append(servers, environmentMCPServer{mcpHTTPServer: mcpHTTPServer{ServerLabel: binding.ServerLabel, Required: binding.Required}, Command: process.Command, Args: &args, Declared: true}) + continue + } if binding.Stdio != nil { command, args := localworkspace.MCPStdioCommand(*binding.Stdio) - servers = append(servers, environmentMCPServer{mcpHTTPServer: mcpHTTPServer{ServerLabel: binding.ServerLabel}, Command: command, Args: args}) + servers = append(servers, environmentMCPServer{mcpHTTPServer: mcpHTTPServer{ServerLabel: binding.ServerLabel}, Command: command, Args: &args}) continue } declarations := []proto.MCPHTTPServer{{ServerLabel: binding.ServerLabel, ServerURL: binding.ServerURL, AllowedTools: binding.AllowedTools, Required: binding.Required, BearerToken: binding.BearerToken, Headers: binding.HTTPHeaders}} diff --git a/apps/daemon/internal/agent/claudesdk/mcp_environment_test.go b/apps/daemon/internal/agent/claudesdk/mcp_environment_test.go index b6d3b3ce7..b3620bb53 100644 --- a/apps/daemon/internal/agent/claudesdk/mcp_environment_test.go +++ b/apps/daemon/internal/agent/claudesdk/mcp_environment_test.go @@ -3,6 +3,7 @@ package claudesdk import ( "encoding/json" "os" + "slices" "strings" "testing" @@ -28,7 +29,7 @@ func TestEnvironmentMCPUsesInstalledLauncherAndSelectedCredential(t *testing.T) } stdio := start.Workspace.MCP[0] executable, _ := os.Executable() - if stdio.Command != executable || len(stdio.Args) != 4 || stdio.Args[0] != "runtime-mcp-exec" || stdio.Args[1] != "/private/runtime/capabilities" || stdio.Args[2] != "plugins/local" || stdio.Args[3] != "local" { + if stdio.Command != executable || stdio.Args == nil || !slices.Equal(*stdio.Args, []string{"runtime-mcp-exec", "/private/runtime/capabilities", "plugins/local", "local"}) { t.Fatal("stdio bypassed the shared installed entry") } raw, _ := json.Marshal(start) @@ -64,7 +65,7 @@ func TestEnvironmentMCPRejectsUnqualifiedCombinations(t *testing.T) { } { environment := &proto.LocalEnvironment{MCP: []proto.EnvironmentMCP{{InstallationRoot: "/private/runtime/capabilities", WorkspaceRoot: "/private/runtime/workspace", PackageRoot: "plugins/remote", Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.invalid/mcp"}}}} mutate(environment) - if _, _, err := prepareRuntimeMCP(proto.PromptRequestPayload{LocalEnvironment: environment}); err == nil { + if _, _, err := prepareRuntimeMCP(proto.PromptRequestPayload{LocalEnvironment: environment}, ""); err == nil { t.Fatal("unsupported declaration accepted") } } @@ -88,3 +89,19 @@ func TestEnvironmentMCPObservationsUseInstalledDeclarations(t *testing.T) { t.Fatal("undeclared observation accepted") } } + +// Native starts a stdio server in the directory of its own process, so the +// declared cwd must be that workspace directory. +func TestPublicMCPStdioStartsInTheWorkspaceDirectory(t *testing.T) { + servers := []proto.MCPHTTPServer{{ConnectionOrigin: "environment", ServerLabel: "local", + Stdio: &proto.MCPStdio{Command: "run", Cwd: "/work", EnvVars: []string{"TOKEN"}}}} + req := proto.PromptRequestPayload{LocalEnvironment: &proto.LocalEnvironment{}, MCPHTTPServers: &servers} + projected, _, err := prepareRuntimeMCP(req, "/work") + raw, _ := json.Marshal(projected) + if err != nil || string(raw) != `[{"server_label":"local","allowed_tools":null,"command":"run","args":[],"declared":true}]` { + t.Fatal("public stdio server was not projected", string(raw), err) + } + if _, _, err := prepareRuntimeMCP(req, "/elsewhere"); err == nil { + t.Fatal("stdio cwd outside the workspace directory was accepted") + } +} diff --git a/apps/daemon/internal/agent/claudesdk/options.go b/apps/daemon/internal/agent/claudesdk/options.go index 0151741fa..c689423d0 100644 --- a/apps/daemon/internal/agent/claudesdk/options.go +++ b/apps/daemon/internal/agent/claudesdk/options.go @@ -164,7 +164,7 @@ func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startR } env := withProvider(append(append([]string{}, os.Environ()...), config.Env...), provider) env = append(env, "CLAUDE_CONFIG_DIR="+config.StateDir, "TMPDIR="+filepath.Join(config.StateDir, "tmp")) - projectedMCP, mcpEnv, err := prepareRuntimeMCP(req) + projectedMCP, mcpEnv, err := prepareRuntimeMCP(req, "") if err != nil { return startRequest{}, nil, err } diff --git a/apps/daemon/internal/agent/claudesdk/workspace.go b/apps/daemon/internal/agent/claudesdk/workspace.go index de032d717..7169c3851 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace.go +++ b/apps/daemon/internal/agent/claudesdk/workspace.go @@ -56,7 +56,7 @@ func prepareWorkspace(config Config, req proto.PromptRequestPayload) (*workspace profile.Skills = req.LocalEnvironment.Skills profile.CapabilityRoot = req.LocalEnvironment.CapabilityRoot } - servers, credentials, err := prepareRuntimeMCP(req) + servers, credentials, err := prepareRuntimeMCP(req, workspaceCwd(config.Workspace)) if err != nil { return nil, nil, err } diff --git a/apps/daemon/internal/agent/codex/mcp_config.go b/apps/daemon/internal/agent/codex/mcp_config.go index 0aeebd19f..2fcc05120 100644 --- a/apps/daemon/internal/agent/codex/mcp_config.go +++ b/apps/daemon/internal/agent/codex/mcp_config.go @@ -16,6 +16,8 @@ type mcpServerConfig struct { URL string Command string Args []string + Cwd string + EnvVars []string EnabledTools *[]string Required bool BearerTokenEnvVar string @@ -59,32 +61,20 @@ func writeCodexMCPConfig(codexHome string, servers map[string]mcpServerConfig) e b.WriteString(tomlQuoteString(srv.BearerTokenEnvVar)) b.WriteByte('\n') } - if srv.EnabledTools != nil { - b.WriteString("enabled_tools = [") - for i, name := range *srv.EnabledTools { - if i > 0 { - b.WriteString(", ") - } - b.WriteString(tomlQuoteString(name)) - } - b.WriteString("]\n") - } writeMCPHeaderMap(&b, "http_headers", srv.HTTPHeaders) + } else { + b.WriteString(`command = `) + b.WriteString(tomlQuoteString(srv.Command)) b.WriteByte('\n') - continue - } - b.WriteString(`command = `) - b.WriteString(tomlQuoteString(srv.Command)) - b.WriteByte('\n') - if len(srv.Args) > 0 { - b.WriteString("args = [") - for i, a := range srv.Args { - if i > 0 { - b.WriteString(", ") - } - b.WriteString(tomlQuoteString(a)) + writeMCPList(&b, "args", srv.Args) + if srv.Cwd != "" { + b.WriteString("cwd = " + tomlQuoteString(srv.Cwd) + "\n") } - b.WriteString("]\n") + writeMCPList(&b, "env_vars", srv.EnvVars) + } + if srv.EnabledTools != nil { + b.WriteString("enabled_tools = ") + writeMCPList(&b, "", *srv.EnabledTools) } b.WriteByte('\n') } @@ -93,6 +83,24 @@ func writeCodexMCPConfig(codexHome string, servers map[string]mcpServerConfig) e return appendConfigTOML(path, b.String()) } +// writeMCPList writes a TOML string array, as a "name = " line unless name is empty. +func writeMCPList(b *strings.Builder, name string, values []string) { + if name != "" { + if len(values) == 0 { + return + } + b.WriteString(name + " = ") + } + b.WriteByte('[') + for i, value := range values { + if i > 0 { + b.WriteString(", ") + } + b.WriteString(tomlQuoteString(value)) + } + b.WriteString("]\n") +} + func writeMCPHeaderMap(b *strings.Builder, field string, values map[string]string) { if len(values) == 0 { return diff --git a/apps/daemon/internal/agent/codex/mcp_http.go b/apps/daemon/internal/agent/codex/mcp_http.go index d4f4a5540..11358de3a 100644 --- a/apps/daemon/internal/agent/codex/mcp_http.go +++ b/apps/daemon/internal/agent/codex/mcp_http.go @@ -30,6 +30,9 @@ func runtimeMCPServers(req proto.PromptRequestPayload) (map[string]mcpServerConf if binding.Stdio != nil { server.Command, server.Args = localworkspace.MCPStdioCommand(*binding.Stdio) } + if process := binding.Process; process != nil { + server.Command, server.Args, server.Cwd, server.EnvVars = process.Command, process.Args, process.Cwd, process.EnvVars + } if binding.BearerToken != nil { server.BearerTokenEnvVar = "OAC_RUNTIME_MCP_BEARER_" + rand.Text() env = append(env, server.BearerTokenEnvVar+"="+*binding.BearerToken) diff --git a/apps/daemon/internal/agent/codex/mcp_http_preflight.go b/apps/daemon/internal/agent/codex/mcp_http_preflight.go index ccf97f07d..6c257777e 100644 --- a/apps/daemon/internal/agent/codex/mcp_http_preflight.go +++ b/apps/daemon/internal/agent/codex/mcp_http_preflight.go @@ -59,9 +59,8 @@ func matchesMCPConfig(raw json.RawMessage, declared map[string]mcpServerConfig) return false } } else { - args, err := json.Marshal(expected.Args) - var want any - if err != nil || json.Unmarshal(args, &want) != nil || server["command"] != expected.Command || !reflect.DeepEqual(server["args"], want) { + if server["command"] != expected.Command || !matchesMCPList(server["args"], expected.Args) || + server["cwd"] != nilIfEmpty(expected.Cwd) || !matchesMCPList(server["env_vars"], expected.EnvVars) { return false } } @@ -119,3 +118,15 @@ func matchesMCPHeaderMap(server map[string]any, field string, expected map[strin } return reflect.DeepEqual(actual, want) } + +// matchesMCPList compares an ordered string list; native omits an empty one. +func matchesMCPList(actual any, expected []string) bool { + if len(expected) == 0 { + return actual == nil || reflect.DeepEqual(actual, []any{}) + } + want := make([]any, len(expected)) + for i, value := range expected { + want[i] = value + } + return reflect.DeepEqual(actual, want) +} diff --git a/apps/daemon/internal/agent/codex/mcp_http_preflight_test.go b/apps/daemon/internal/agent/codex/mcp_http_preflight_test.go index ea16a6a5a..297725749 100644 --- a/apps/daemon/internal/agent/codex/mcp_http_preflight_test.go +++ b/apps/daemon/internal/agent/codex/mcp_http_preflight_test.go @@ -212,3 +212,32 @@ func TestPublicMCPHTTPPreparationChecksBeforeNewAndResumedThread(t *testing.T) { }) } } + +func TestPublicMCPStdioReachesNativeConfiguration(t *testing.T) { + tools := []string{"lookup"} + servers := []proto.MCPHTTPServer{{ConnectionOrigin: "environment", ServerLabel: "local", AllowedTools: &tools, + Stdio: &proto.MCPStdio{Command: "run", Args: []string{"-x"}, Cwd: "/work", EnvVars: []string{"TOKEN"}}}} + declared, _, err := runtimeMCPServers(proto.PromptRequestPayload{LocalEnvironment: &proto.LocalEnvironment{}, MCPHTTPServers: &servers}) + if err != nil { + t.Fatal(err) + } + home := t.TempDir() + if err := writeCodexMCPConfig(home, declared); err != nil { + t.Fatal(err) + } + config, err := os.ReadFile(filepath.Join(home, "config.toml")) + if err != nil { + t.Fatal(err) + } + for _, expected := range []string{`command = "run"`, `args = ["-x"]`, `cwd = "/work"`, `env_vars = ["TOKEN"]`, `enabled_tools = ["lookup"]`} { + if !strings.Contains(string(config), expected) { + t.Fatalf("missing native config %q", expected) + } + } + read := func(cwd string) json.RawMessage { + return json.RawMessage(`{"config":{"mcp_oauth_credentials_store":"file","mcp_servers":{"local":{"environment_id":"local","command":"run","args":["-x"],"cwd":"` + cwd + `","env_vars":["TOKEN"],"enabled_tools":["lookup"]}}}}`) + } + if !matchesMCPConfig(read("/work"), declared) || matchesMCPConfig(read("/elsewhere"), declared) { + t.Fatal("effective native stdio configuration was not verified") + } +} diff --git a/apps/daemon/internal/agent/mcode/environment_mcp.go b/apps/daemon/internal/agent/mcode/environment_mcp.go index a2a9907c0..310d0cfc7 100644 --- a/apps/daemon/internal/agent/mcode/environment_mcp.go +++ b/apps/daemon/internal/agent/mcode/environment_mcp.go @@ -23,9 +23,11 @@ func runtimeMCP(req proto.PromptRequestPayload) ([]map[string]any, error) { return nil, err } servers = append(servers, server) - } else { + } else if binding.Stdio != nil { command, args := localworkspace.MCPStdioCommand(*binding.Stdio) servers = append(servers, map[string]any{"name": binding.ServerLabel, "command": command, "args": args, "env": []map[string]string{}}) + } else { + return nil, fmt.Errorf("mcode: public MCP stdio servers are unsupported") } } return servers, nil diff --git a/apps/daemon/internal/agent/mcode/environment_mcp_test.go b/apps/daemon/internal/agent/mcode/environment_mcp_test.go index e0749054a..677d6ced2 100644 --- a/apps/daemon/internal/agent/mcode/environment_mcp_test.go +++ b/apps/daemon/internal/agent/mcode/environment_mcp_test.go @@ -280,3 +280,10 @@ func TestServiceOriginMCPOnNonePlacement(t *testing.T) { t.Fatalf("MCP = %v, %v", servers, err) } } + +func TestPublicMCPStdioIsRejected(t *testing.T) { + servers := []proto.MCPHTTPServer{{ConnectionOrigin: "environment", ServerLabel: "local", Stdio: &proto.MCPStdio{Command: "run", Cwd: "/work"}}} + if _, err := runtimeMCP(proto.PromptRequestPayload{LocalEnvironment: &proto.LocalEnvironment{}, MCPHTTPServers: &servers}); err == nil { + t.Fatal("native starts session stdio servers outside the declared cwd") + } +} diff --git a/apps/daemon/internal/agent/mcp_binding.go b/apps/daemon/internal/agent/mcp_binding.go index f00c32311..a1f5413f3 100644 --- a/apps/daemon/internal/agent/mcp_binding.go +++ b/apps/daemon/internal/agent/mcp_binding.go @@ -25,6 +25,8 @@ type MCPBinding struct { HTTPHeaders map[string]string // Stdio retains the installed package identity for the fixed Runtime launcher. Stdio *proto.EnvironmentMCP + // Process is a public stdio declaration, started natively by the Harness. + Process *proto.MCPStdio } // ResolveMCPBindings combines public declarations with the frozen installation. @@ -38,7 +40,16 @@ 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, HTTPHeaders: maps.Clone(server.Headers)} + transport := "http" + if server.Stdio != nil { + transport = "stdio" + } + item := MCPBinding{ServerLabel: server.ServerLabel, ConnectionOrigin: server.ConnectionOrigin, CredentialAuthority: "none", Transport: transport, ServerURL: server.ServerURL, Required: server.Required, BearerToken: server.BearerToken, HTTPHeaders: maps.Clone(server.Headers)} + if server.Stdio != nil { + process := *server.Stdio + process.Args, process.EnvVars = slices.Clone(process.Args), slices.Clone(process.EnvVars) + item.Process = &process + } if server.AllowedTools != nil { names := append([]string{}, (*server.AllowedTools)...) item.AllowedTools = &names @@ -79,10 +90,20 @@ func ResolveMCPBindings(req proto.PromptRequestPayload) ([]MCPBinding, error) { value := *item.BearerToken item.BearerToken = &value } + if item.AllowedTools != nil { + for _, name := range *item.AllowedTools { + if name == "" || strings.TrimSpace(name) != name { + return nil, errors.New("invalid MCP tool allowlist") + } + } + } switch item.Transport { case "stdio": - if item.Stdio == nil || item.BearerToken != nil || len(item.HTTPHeaders) > 0 || item.ServerURL != "" { - return nil, errors.New("invalid installed MCP transport") + if (item.Stdio == nil) == (item.Process == nil) || item.BearerToken != nil || len(item.HTTPHeaders) > 0 || item.ServerURL != "" { + return nil, errors.New("invalid MCP stdio transport") + } + if process := item.Process; process != nil && !process.Valid() { + return nil, errors.New("invalid MCP stdio declaration") } case "http": endpoint, err := url.Parse(item.ServerURL) @@ -92,13 +113,6 @@ func ResolveMCPBindings(req proto.PromptRequestPayload) ([]MCPBinding, error) { if item.BearerToken != nil && (endpoint.Scheme != "https" || !ValidMCPHTTPBearerToken(*item.BearerToken)) { return nil, errors.New("invalid HTTPS MCP bearer credential") } - if item.AllowedTools != nil { - for _, name := range *item.AllowedTools { - if name == "" || strings.TrimSpace(name) != name { - return nil, errors.New("invalid MCP tool allowlist") - } - } - } default: return nil, errors.New("unsupported MCP transport") } diff --git a/apps/daemon/internal/agent/mcp_binding_test.go b/apps/daemon/internal/agent/mcp_binding_test.go index d9094f011..c205380ed 100644 --- a/apps/daemon/internal/agent/mcp_binding_test.go +++ b/apps/daemon/internal/agent/mcp_binding_test.go @@ -1,6 +1,7 @@ package agent import ( + "reflect" "testing" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" @@ -102,3 +103,27 @@ func TestPublicEnvironmentMCPRetainsVaultAuthority(t *testing.T) { t.Fatal("unprepared environment accepted") } } + +func TestMCPBindingsPublicStdio(t *testing.T) { + stdio := proto.MCPStdio{Command: "run", Args: []string{"-x"}, Cwd: "/work", EnvVars: []string{"TOKEN"}} + resolve := func(origin string, stdio proto.MCPStdio) ([]MCPBinding, error) { + servers := []proto.MCPHTTPServer{{ConnectionOrigin: origin, ServerLabel: "local", Stdio: &stdio}} + return ResolveMCPBindings(proto.PromptRequestPayload{LocalEnvironment: &proto.LocalEnvironment{}, MCPHTTPServers: &servers}) + } + got, err := resolve("environment", stdio) + if err != nil || len(got) != 1 || got[0].Transport != "stdio" || got[0].Stdio != nil || !reflect.DeepEqual(*got[0].Process, stdio) { + t.Fatal("public stdio binding unavailable", got, err) + } + got[0].Process.Args[0] = "mutated" + if stdio.Args[0] != "-x" { + t.Fatal("binding shares the declaration") + } + for name, invalid := range map[string]proto.MCPStdio{"command": {Command: " "}, "variable": {Command: "run", EnvVars: []string{"1=2"}}} { + if _, err := resolve("environment", invalid); err == nil { + t.Fatal("invalid stdio declaration accepted: " + name) + } + } + if _, err := resolve("service", stdio); err == nil { + t.Fatal("service-origin stdio accepted") + } +} diff --git a/apps/daemon/internal/localworkspace/capabilities.go b/apps/daemon/internal/localworkspace/capabilities.go index 051f35b74..5c02a4a35 100644 --- a/apps/daemon/internal/localworkspace/capabilities.go +++ b/apps/daemon/internal/localworkspace/capabilities.go @@ -56,6 +56,15 @@ func (b *Binding) Prepare(ctx context.Context, r proto.PromptRequestPayload) (pr return r, err } } + if r.MCPHTTPServers != nil { + values, readErr := b.ReadToolEnvironment() + if readErr == nil { + readErr = requireDeclaredVariables(*r.MCPHTTPServers, values) + } + if readErr != nil { + return r, readErr + } + } if len(manifest.MCP) != 0 { values, readErr := b.ReadToolEnvironment() if readErr != nil { diff --git a/apps/daemon/internal/localworkspace/mcp.go b/apps/daemon/internal/localworkspace/mcp.go index 820c852e4..75725f694 100644 --- a/apps/daemon/internal/localworkspace/mcp.go +++ b/apps/daemon/internal/localworkspace/mcp.go @@ -45,3 +45,19 @@ func resolveEnvironmentMCP(installed []agentcapabilities.InstalledMCP, values ma } return result, nil } + +// requireDeclaredVariables fails a public stdio server that names a variable the +// initialized env does not set, as resolveEnvironmentMCP does for installed servers. +func requireDeclaredVariables(servers []proto.MCPHTTPServer, values map[string]string) error { + for _, server := range servers { + if server.Stdio == nil { + continue + } + for _, name := range server.Stdio.EnvVars { + if _, exists := values[name]; !exists { + return errors.New("declared environment MCP variable unavailable") + } + } + } + return nil +} diff --git a/apps/daemon/internal/localworkspace/mcp_test.go b/apps/daemon/internal/localworkspace/mcp_test.go index d0e52e3cc..1ff8b336b 100644 --- a/apps/daemon/internal/localworkspace/mcp_test.go +++ b/apps/daemon/internal/localworkspace/mcp_test.go @@ -28,6 +28,14 @@ func TestEnvironmentMCPCredentialsNeverFallBackToNativeEnv(t *testing.T) { } } +func TestDeclaredMCPVariablesNeverFallBackToNativeEnv(t *testing.T) { + t.Setenv("SERVER_TOKEN", "native-private-value") + servers := []proto.MCPHTTPServer{{ServerLabel: "local", Stdio: &proto.MCPStdio{Command: "run", Cwd: "/", EnvVars: []string{"SERVER_TOKEN"}}}} + if requireDeclaredVariables(servers, nil) == nil || requireDeclaredVariables(servers, map[string]string{"SERVER_TOKEN": ""}) != nil { + t.Fatal("env_vars did not resolve only from the initialized env") + } +} + func TestMCPStdioLauncherContainsOnlyInstalledIdentity(t *testing.T) { server := proto.EnvironmentMCP{InstallationRoot: "/private/runtime/capabilities", WorkspaceRoot: "/private/runtime/workspace", PackageRoot: "plugins/0", Server: agentplugin.MCPServer{ Name: "package_tool", Type: "stdio", Command: "untrusted-command", Args: []string{"private-argument"}, diff --git a/contracts/agents-api/environments.md b/contracts/agents-api/environments.md index 477f5df21..573f2ebec 100644 --- a/contracts/agents-api/environments.md +++ b/contracts/agents-api/environments.md @@ -400,7 +400,7 @@ The Runtime resolves public HTTP declarations and installed Plugin MCP through ` ### Public MCP connection origin -An Agent's HTTP MCP tool ([declaration](./execution-tools.md#http-mcp)) has a `connection_origin`. Omitted or null means `service`, including on `self_hosted` Sessions. The origin is kept from saved configuration through the Session snapshot to the Runtime request, which requires the exact wire version; a `service` request never becomes an `environment` connection. +An Agent's HTTP MCP tool ([declaration](./execution-tools.md#mcp)) has a `connection_origin`. Omitted or null means `service`, including on `self_hosted` Sessions, except for a `stdio` server, whose only origin is `environment`. The origin is kept from saved configuration through the Session snapshot to the Runtime request, which requires the exact wire version; a `service` request never becomes an `environment` connection. | Origin | Connects from | Allowed placements | | --- | --- | --- | @@ -413,4 +413,4 @@ Core's Harness profile declares `MCPOrigins`; admission and dispatch check the o Both origins support anonymous HTTP and HTTPS bearer credentials. The attached-Vault selection freezes the credential identity, including a unique implicit URL match or an anonymous selection. Only that Project-authorized credential enters the transient Runtime request; Core defaults and unrelated Vaults are never searched. A decryption failure or missing credential fails execution without an anonymous fallback. Public Environment MCP keeps `project_vault` authority and Plugin credentials keep `environment_configuration` authority; neither overrides a duplicate server label. Bearers never enter persisted native configuration or process arguments. -Tool allowlists and required initialization follow the [HTTP MCP contract](./execution-tools.md#http-mcp). +Tool allowlists and required initialization follow the [HTTP MCP contract](./execution-tools.md#mcp). diff --git a/contracts/agents-api/execution-tools.md b/contracts/agents-api/execution-tools.md index 45a08ee78..a2d6635c1 100644 --- a/contracts/agents-api/execution-tools.md +++ b/contracts/agents-api/execution-tools.md @@ -77,7 +77,7 @@ Execution carries the resolved values to the Harness and validates them against | Claude Code | `disabled` and `live`, with `context_size: "medium"` and no `allowed_domains` or `location`. On a workspace placement the native `claude_code` tool set runs without ask-user, scheduling, workflow, background and `ListAgents` tools; `disabled` removes `WebSearch` and `WebFetch`, and `live` keeps them. `environment: none` keeps no built-in tools, and `live` adds only `WebSearch`, a provider-side tool. `WebFetch` stays off there because it would fetch from the Runtime host without an Environment | `false` only: native REPL calls tools from model-written code, outside the tool records that function receipts and tool observations read | | MiniMax Code | `disabled` only, through the native `features.webSearch=false` because native search needs a MiniMax account credential. On a workspace placement the native tools run, and `disabled` also removes `web_fetch` while an omitted control keeps it. `environment: none` has no built-in tools | `false` only | -## HTTP MCP +## MCP ```json { @@ -90,11 +90,18 @@ 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, 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)). +- `server_label` is nonempty and unique within the Session. The `http` and `stdio` transports are accepted; `server_url` is an absolute HTTP or HTTPS URL without credentials or fragment. Nonempty `request_metadata` is rejected with "Nonempty MCP request_metadata is not supported yet." and an inline `authorization` with "Inline MCP authorization is not supported; use credential_id.". `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. - Bearer authentication uses an attached static or OAuth Vault credential. [Vault credentials](./vaults.md) owns selection, and [MCP credential authority](./environments.md#public-mcp-connection-origin) owns the frozen Runtime binding. Authenticated execution requires `mcp_http_bearer_auth`. - The Runtime must advertise `mcp_http_tools`. The native Harness owns discovery, calls and results; public `mcp_call` Items use the original server and tool names and keep the observed native result. +A `stdio` server carries `command`, `args`, `cwd` and `env_vars`, runs as a process of the Environment and needs a workspace placement (`openai_hosted` or `self_hosted`); on `environment: none` admission returns a 400. Its `connection_origin` defaults to and must be `environment`, and it takes no `credential_id`. A nonempty Session `env` is rejected with "Inline MCP env is not supported; declare env_vars and set the values in the Environment."; `command` is nonempty, `cwd` is an absolute path, `env_vars` holds variable names and no part contains NUL. `env_vars` names variables of the Environment, whose values never reach Core; as for [Plugin MCP](./environments.md#plugin-mcp), the start fails when the Environment does not set one. The Runtime must advertise `mcp_http_tools`, which covers every public MCP server. + +- Codex writes `command`, `args`, `cwd` and `env_vars` to its `[mcp_servers]` table and verifies them with the rest of the declared configuration before any native connection. +- Claude Code starts the server with `command` and `args` in the directory of its own process, which is the workspace directory, so the Runtime rejects any other `cwd`. Core rejects non-null `allowed_tools` for a stdio server, because the adapter can hide unselected tools only on HTTP servers. +- MiniMax Code starts session stdio servers in its private home directory, which no `cwd` can name, so admission rejects every stdio server with "The configured engine cannot start MCP stdio servers in the declared cwd." +- Codex passes `env_vars` to its restricted native environment. Claude Code starts the server with its whole process environment, so it inherits more than `env_vars` lists. + Codex trusts the workspace natively, so its project configuration loads on start and cold resume, and undeclared servers, apps and plugins keep their native behavior. Before any native connection that carries a Core credential, it verifies that each declared server keeps its URL or command, bearer reference and headers, and fails the start or resume with an error otherwise. It rejects reserved native labels and stored native MCP credentials. Claude accepts labels of ASCII letters, digits, underscore and hyphen except `functions`, any tool name (the native client normalizes it, and the adapter rejects normalized collisions), and requires connected servers with static inventories. MiniMax Code serves public functions as the MCP server `functions`, so an Agent with functions cannot use that label for a public or Plugin server. Anonymous Claude requests send a blank Authorization header to suppress native OAuth injection. Native OAuth login is not supported. diff --git a/contracts/agents-api/go-bindings.json b/contracts/agents-api/go-bindings.json index 3e2178e08..9b4f16d06 100644 --- a/contracts/agents-api/go-bindings.json +++ b/contracts/agents-api/go-bindings.json @@ -215,20 +215,26 @@ "sources": ["#/components/schemas/SessionItemListResource"], "order": ["object", "first_id", "last_id", "data", "has_more"] }, - "MCPHTTPTransport": { - "sources": ["#/components/schemas/PersistedMcpTransportConfigParamHttp"], - "fields": { - "headers": {"type": "*map[string]string"} - } - }, "MCPTool": { "sources": ["#/components/schemas/AgentToolResourceMcp"], "fields": { - "transport": {"type": "MCPHTTPTransport"}, + "transport": {"type": "MCPTransport"}, "allowed_tools": {"type": "*[]string"} }, "order": ["type", "server_label", "transport", "allowed_tools", "connection_origin", "credential_id", "request_metadata", "required"] }, + "MCPTransport": { + "marshal_union": true, + "sources": ["#/components/schemas/PersistedMcpTransportConfigParam"], + "fields": { + "server_url": {"type": "string"}, + "headers": {"type": "*map[string]string"}, + "command": {"type": "string"}, + "args": {"type": "*[]string"}, + "cwd": {"type": "string"}, + "env_vars": {"type": "*[]string"} + } + }, "MCPToolInput": { "sources": ["#/components/schemas/AgentToolConfigParamMcp"] }, diff --git a/contracts/agents-api/harness-capabilities.md b/contracts/agents-api/harness-capabilities.md index c3886e48b..b5a7a859d 100644 --- a/contracts/agents-api/harness-capabilities.md +++ b/contracts/agents-api/harness-capabilities.md @@ -42,7 +42,8 @@ Native model parameters and provider protocols per Harness are in [model executi | Web search `cached` or `live` and programmatic tool calling `enabled` | Admitted on all placements | `live` admitted on all placements; `cached` and `enabled` rejected | Rejected | | [Service-origin HTTP MCP](./environments.md#public-mcp-connection-origin) | Verified: `none`; rejected elsewhere | Verified: `none`; rejected elsewhere | Verified: `none`, with `allowed_tools` null and `required` false only; rejected elsewhere | | [Environment-origin HTTP MCP](./environments.md#public-mcp-connection-origin) | Verified: hosted, self-hosted | Verified: hosted, self-hosted | Verified: hosted, self-hosted; `allowed_tools` null and `required` false only | -| [HTTP MCP bearer credentials](./execution-tools.md#http-mcp) | Verified | Verified | Verified | +| [Stdio MCP](./execution-tools.md#mcp) | Admitted: hosted, self-hosted | Admitted: hosted, self-hosted; `cwd` must be the workspace directory and `allowed_tools` null | Rejected | +| [HTTP MCP bearer credentials](./execution-tools.md#mcp) | Verified | Verified | Verified | | Required MCP initialization | Verified | Verified | Rejected | | [Subagents](./subagents.md) | Verified: hosted; admitted: `none`, self-hosted | Verified: hosted; admitted: `none`, self-hosted | Verified: hosted; admitted: `none`, self-hosted | | Subagents together with functions or HTTP MCP | Verified: `none`; admitted: hosted, self-hosted | Rejected | HTTP MCP admitted; functions rejected for every Session | diff --git a/contracts/agents-api/index.md b/contracts/agents-api/index.md index f54576d90..c1e12fc5a 100644 --- a/contracts/agents-api/index.md +++ b/contracts/agents-api/index.md @@ -77,6 +77,8 @@ 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. +- Core rejects an inline `authorization` on a Session MCP transport, an `Authorization` entry in `headers` and a nonempty stdio `env`: credentials come only from Vaults (`credential_id`) and environment values only from the Environment (`env_vars`) ([MCP](./execution-tools.md#mcp)). +- A `stdio` MCP server needs a workspace placement and the origin `environment`. Claude Code starts the server with its whole process environment, which holds more than `env_vars` lists, in its workspace directory, so `cwd` must equal it, and it needs `allowed_tools` null. MiniMax Code starts the server in its private home directory, so it rejects every `stdio` server ([MCP](./execution-tools.md#mcp)). - 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. @@ -117,7 +119,6 @@ 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 image input. - Model-derived reasoning defaults are not resolved. -- 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/v1/official.gen.go b/contracts/agents-api/v1/official.gen.go index d74771d9c..92b49fcb5 100644 --- a/contracts/agents-api/v1/official.gen.go +++ b/contracts/agents-api/v1/official.gen.go @@ -356,18 +356,11 @@ type ItemList struct { HasMore bool `json:"has_more" binding:"required"` } -// MCPHTTPTransport projects PersistedMcpTransportConfigParamHttp. -type MCPHTTPTransport struct { - Type string `json:"type" binding:"required" enums:"http"` - ServerURL string `json:"server_url" binding:"required"` - Headers *map[string]string `json:"headers,omitempty" extensions:"x-nullable"` -} - // MCPTool projects AgentToolResourceMcp. type MCPTool struct { Type string `json:"type" binding:"required" enums:"mcp"` ServerLabel string `json:"server_label" binding:"required"` - Transport MCPHTTPTransport `json:"transport" binding:"required"` + Transport MCPTransport `json:"transport" binding:"required"` AllowedTools *[]string `json:"allowed_tools" binding:"required" extensions:"x-nullable"` ConnectionOrigin string `json:"connection_origin" binding:"required" enums:"service,environment"` CredentialID *string `json:"credential_id" binding:"required" extensions:"x-nullable"` @@ -387,6 +380,48 @@ type MCPToolInput struct { ConnectionOrigin *string `json:"connection_origin,omitempty" extensions:"x-nullable" enums:"service,environment"` } +// MCPTransport projects PersistedMcpTransportConfigParam. +type MCPTransport struct { + Type string `json:"type" binding:"required" enums:"http,stdio"` + ServerURL string `json:"server_url,omitempty"` + Headers *map[string]string `json:"headers,omitempty" extensions:"x-nullable"` + Command string `json:"command,omitempty"` + Args *[]string `json:"args,omitempty" extensions:"x-nullable"` + Cwd string `json:"cwd,omitempty"` + EnvVars *[]string `json:"env_vars,omitempty" extensions:"x-nullable"` +} + +func (value MCPTransport) MarshalJSON() ([]byte, error) { + switch value.Type { + case "http": + return json.Marshal(struct { + Type string `json:"type"` + ServerURL string `json:"server_url"` + Headers *map[string]string `json:"headers,omitempty"` + }{ + Type: value.Type, + ServerURL: value.ServerURL, + Headers: value.Headers, + }) + case "stdio": + return json.Marshal(struct { + Type string `json:"type"` + Command string `json:"command"` + Args *[]string `json:"args,omitempty"` + Cwd string `json:"cwd"` + EnvVars *[]string `json:"env_vars,omitempty"` + }{ + Type: value.Type, + Command: value.Command, + Args: value.Args, + Cwd: value.Cwd, + EnvVars: value.EnvVars, + }) + default: + return nil, fmt.Errorf("invalid MCPTransport type %q", value.Type) + } +} + // MultiAgentConfig projects MultiAgentConfigResource. type MultiAgentConfig struct { Enabled bool `json:"enabled" binding:"required"` diff --git a/contracts/agents-api/zh/environments.md b/contracts/agents-api/zh/environments.md index a00888065..9b03039ab 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: 4e216b512f4ef62bd092d60c9d773f77485bd9cbc949f4273de23e5390078f34 +source_hash: 3408e2c811042732d35227150533cf12a85cfcff8a5118d7eb62a5d68549cc8b --- Environment 是 Session 的执行资源,包括 Harness 运行所在的机器、工作区以及已完成准备的能力。Session 通过其 `environment` 配置创建 Environment;不存在独立的 create 调用。Environment 模板是 Session 创建时解析的可复用准备配置。本契约涵盖这两类资源、两种放置方式、输入接纳、能力准备、Skills、Plugins 和 MCP 连接来源。 @@ -402,7 +402,7 @@ Runtime 会在适配器投影之前,通过 `agent.ResolveMCPBindings` 解析 ### 公开 MCP 连接来源 {#public-mcp-connection-origin} -Agent 的 HTTP MCP 工具([declaration](execution-tools.md#http-mcp))具有 `connection_origin`。省略或传入 null 表示 `service`,在 `self_hosted` Session 中也是如此。该来源会从已保存配置经过 Session 快照一直保留到 Runtime 请求,后者要求精确的线上版本;`service` 请求绝不会变为 `environment` 连接。 +Agent 的 HTTP MCP 工具([declaration](execution-tools.md#mcp))具有 `connection_origin`。省略或传入 null 表示 `service`,在 `self_hosted` Session 中也是如此,`stdio` 服务器除外,其唯一来源是 `environment`。该来源会从已保存配置经过 Session 快照一直保留到 Runtime 请求,后者要求精确的线上版本;`service` 请求绝不会变为 `environment` 连接。 | 来源 | 连接发起位置 | 允许的放置方式 | | --- | --- | --- | @@ -415,4 +415,4 @@ Core 的 Harness profile 会声明 `MCPOrigins`;接纳和分派会将来源与 两种来源都支持匿名 HTTP 和 HTTPS bearer 凭据。所附加 Vault 的选择会冻结凭据身份,包括唯一的隐式 URL 匹配或匿名选择。只有该凭据经项目授权后,才会进入瞬时 Runtime 请求;绝不会搜索 Core 默认值或无关 Vault。解密失败或凭据缺失时,执行会失败且不会回退到匿名。公开 Environment MCP 保留 `project_vault` 权限,Plugin 凭据保留 `environment_configuration` 权限;两者都不会覆盖重复的服务器标签。Bearer 令牌绝不会进入持久化的原生配置或进程参数。 -工具允许列表和必需初始化遵循 [HTTP MCP contract](execution-tools.md#http-mcp)。 +工具允许列表和必需初始化遵循 [HTTP MCP contract](execution-tools.md#mcp)。 diff --git a/contracts/agents-api/zh/execution-tools.md b/contracts/agents-api/zh/execution-tools.md index 14f07d051..8acdfaddc 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: d6b67ca7e0de7c77346f806e41b0e7803537e2a23f76a915472ce086106b4399 +source_hash: cf4c1fa4611afd0d238206e9e87f08079f1d1a1bbcef64d08c2174d28668abb5 --- Agent 在 `tools` 中声明应用函数、控制项和 MCP 服务器,并可在 `text.format` 中声明输出 schema。本契约说明 Core 如何验证声明、哪些内容跨越 Runtime 边界,以及调用方如何恢复待执行操作。[Harness 能力](harness-capabilities.md)列出各 Harness 在不同部署位置支持的操作。原生工作区工具和 Environment Plugin MCP 属于 [Environment](environments.md#skills-plugins-and-environment-mcp)。 @@ -79,7 +79,7 @@ Core 发送 `PromptRequestPayload.ToolSearch` 和每个 `FunctionTool.DeferLoadi | Claude Code | `disabled` 和 `live`,需使用 `context_size: "medium"`,且不支持 `allowed_domains` 或 `location`。在工作区位置运行原生 `claude_code` 工具集,但移除向用户提问、定时、工作流、后台和 `ListAgents` 工具;`disabled` 会移除 `WebSearch` 和 `WebFetch`,`live` 保留它们。`environment: none` 不保留内置工具,`live` 只增加提供方侧工具 `WebSearch`。`WebFetch` 在此保持关闭,因为没有 Environment 时它会从 Runtime 主机发起抓取 | 仅 `false`:原生 REPL 在模型编写的代码中调用工具,不经过函数回执和工具观察所读取的工具记录 | | MiniMax Code | 仅 `disabled`,通过原生 `features.webSearch=false` 实现,因为原生搜索需要 MiniMax 账号凭据。在工作区位置上运行原生工具,`disabled` 还会移除 `web_fetch`,省略该控制项则保留。`environment: none` 没有内置工具 | 仅 `false` | -## HTTP MCP {#http-mcp} +## MCP {#mcp} ```json { @@ -92,11 +92,18 @@ Core 发送 `PromptRequestPayload.ToolSearch` 和每个 `FunctionTool.DeferLoadi } ``` -- `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))。 +- `server_label` 非空且在 Session 中唯一。接受 `http` 和 `stdio` 传输;`server_url` 为不带凭据或片段的绝对 HTTP 或 HTTPS URL。非空 `request_metadata` 被拒绝,消息为 "Nonempty MCP request_metadata is not supported yet.";内联 `authorization` 被拒绝,消息为 "Inline MCP authorization is not supported; use credential_id."。`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` 能力。等待期间公开工作可被接受或排队。 - Bearer 认证使用附加的静态或 OAuth Vault 凭据。[Vault 凭据](vaults.md)定义选择规则,[MCP 凭据权限](environments.md#public-mcp-connection-origin)定义冻结的 Runtime 绑定。认证执行要求 `mcp_http_bearer_auth`。 - Runtime 必须声明 `mcp_http_tools`。原生 Harness 管理发现、调用和结果;公开 `mcp_call` Item 使用原始服务器与工具名称,保留观察到的原生结果。 +`stdio` 服务器携带 `command`、`args`、`cwd` 和 `env_vars`,作为 Environment 的进程运行,需要工作区部署位置(`openai_hosted` 或 `self_hosted`);在 `environment: none` 上,准入返回 400。其 `connection_origin` 默认且只能为 `environment`,不带 `credential_id`。非空的 Session `env` 被拒绝,消息为 "Inline MCP env is not supported; declare env_vars and set the values in the Environment.";`command` 非空,`cwd` 为绝对路径,`env_vars` 只含变量名,且任何部分都不含 NUL。`env_vars` 指定 Environment 中的变量名,其值不会到达 Core;与 [Plugin MCP](environments.md#plugin-mcp) 相同,Environment 未设置其中某个变量时启动失败。Runtime 必须声明 `mcp_http_tools`,它涵盖所有公开 MCP 服务器。 + +- Codex 把 `command`、`args`、`cwd` 和 `env_vars` 写入其 `[mcp_servers]` 表,并在任何原生连接之前与其余已声明配置一并验证。 +- Claude Code 以 `command` 和 `args` 在其自身进程所在目录(即工作区目录)启动服务器,因此 Runtime 拒绝其他 `cwd`。Core 拒绝 stdio 服务器的非 null `allowed_tools`,因为适配器只能在 HTTP 服务器上隐藏未选工具。 +- MiniMax Code 在其私有主目录中启动会话 stdio 服务器,任何 `cwd` 都无法指向该目录,因此准入拒绝所有 stdio 服务器,消息为 "The configured engine cannot start MCP stdio servers in the declared cwd."。 +- Codex 把 `env_vars` 交给其受限的原生环境。Claude Code 以整个进程环境启动服务器,因此继承的变量多于 `env_vars` 所列。 + Codex 以原生方式信任工作区,因此工作区的项目配置在启动和冷恢复时加载,未声明的服务器、apps 和 plugins 保持原生行为。在任何携带 Core 凭据的原生连接之前,它验证每个已声明服务器保持其 URL 或命令、bearer 引用和请求头,否则启动或恢复以错误失败。它拒绝原生保留标签和已存储原生 MCP 凭据。Claude 接受 ASCII 字母、数字、下划线和连字符组成的标签,但不允许 `functions`;接受任意工具名称(原生客户端会将其规范化,适配器拒绝规范化后的冲突),并要求已连接服务器具有静态工具清单。MiniMax Code 以 MCP 服务器 `functions` 提供公共函数,因此声明了函数的 Agent 不能让公共或 Plugin 服务器使用该标签。匿名 Claude 请求发送空 Authorization 头以阻止原生 OAuth 注入。不支持原生 OAuth 登录。 diff --git a/contracts/agents-api/zh/harness-capabilities.md b/contracts/agents-api/zh/harness-capabilities.md index 530feb756..dd8317115 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: 868d7db54a63ab0247b8112e96e9064a233fe52e5b7ce5047f50c7d7c937d0cf +source_hash: aa7386a79b936f3415f12e394740fbbe538c6e41c32835a67cb2fbd2694dad9f --- 本页列出每个 Harness 在每种部署位置支持的能力。Core 根据 `internal/harnessconfig//declaration.go` 中 Harness 的声明决定准入,运行 Session 的 Runtime 也必须声明操作。所链接契约定义各操作;[Harness 接入](harness-onboarding.md#qualify-the-adapter)说明资格验证方法。 @@ -44,7 +44,8 @@ source_hash: 868d7db54a63ab0247b8112e96e9064a233fe52e5b7ce5047f50c7d7c937d0cf | Web 搜索 `cached` 或 `live`,以及程序化工具调用 `enabled` | 所有位置已准入 | `live` 在所有位置已准入;`cached` 和 `enabled` 已拒绝 | 已拒绝 | | [服务端来源 HTTP MCP](environments.md#public-mcp-connection-origin) | 已验证:`none`;其他位置拒绝 | 已验证:`none`;其他位置拒绝 | 已验证:`none`,仅 `allowed_tools` null、`required` false;其他位置拒绝 | | [Environment 来源 HTTP MCP](environments.md#public-mcp-connection-origin) | 已验证:托管、自托管 | 已验证:托管、自托管 | 已验证:托管、自托管;仅 `allowed_tools` null、`required` false | -| [HTTP MCP bearer 凭据](execution-tools.md#http-mcp) | 已验证 | 已验证 | 已验证 | +| [stdio MCP](execution-tools.md#mcp) | 已准入:托管、自托管 | 已准入:托管、自托管;`cwd` 须为工作区目录,`allowed_tools` 须为 null | 已拒绝 | +| [HTTP MCP bearer 凭据](execution-tools.md#mcp) | 已验证 | 已验证 | 已验证 | | 必需 MCP 初始化 | 已验证 | 已验证 | 已拒绝 | | [Subagent](subagents.md) | 已验证:托管;已准入:`none`、自托管 | 已验证:托管;已准入:`none`、自托管 | 已验证:托管;已准入:`none`、自托管 | | Subagent 与函数或 HTTP MCP 同时使用 | 已验证:`none`;已准入:托管、自托管 | 已拒绝 | HTTP MCP 已准入;每个 Session 都拒绝函数 | diff --git a/contracts/agents-api/zh/index.md b/contracts/agents-api/zh/index.md index 06cf35833..28435b286 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: 9065120db89337c474d34a1840010b04c94f89dea9ecc96caded14fc552e2c17 +source_hash: 0be7a0fb3f65a4a652a9b7eb0cc02e2be1f563fab68ae34aad833dc877226b0e --- Core 实现下方固定版本的 OpenAI Agents API。本台账列出各项资源的覆盖情况、相对于官方服务的所有差异以及已知缺口。 @@ -80,6 +80,8 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh - 未指定推理强度时会保持为 null,而不是采用模型的默认值。 - Codex 把程序化工具调用映射到原生 `code_mode`,由模型编写的脚本调用其他工具;该功能在上游仍处于开发中。 - Session 的 `agent.tools` 会省略 `tool_search` 声明。 +- Core 拒绝 Session MCP 传输中的内联 `authorization`、`headers` 中的 `Authorization` 条目以及非空的 stdio `env`:凭据只来自 Vault(`credential_id`),环境值只来自 Environment(`env_vars`)([MCP](execution-tools.md#mcp))。 +- `stdio` MCP 服务器需要工作区部署位置和 `environment` 来源。Claude Code 以其整个进程环境(多于 `env_vars` 所列)在其工作区目录中启动服务器,因此 `cwd` 必须与之相同,且 `allowed_tools` 须为 null。MiniMax Code 在其私有主目录中启动服务器,因此拒绝所有 `stdio` 服务器([MCP](execution-tools.md#mcp))。 - Claude 会拒绝包含 `${` 的 MCP `server_url` 或 `headers` 值,因为原生客户端会从持有 Core 所持凭据的进程环境中展开这两处的 `${VAR}`。 - 在 events 202 之后立即删除 Session 会返回 409,因为 Core 会在同一事务中准入该 Turn;官方服务返回 200。 @@ -120,7 +122,6 @@ 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 不支持图像输入。 - 由模型推导出的推理默认值不会被解析确定。 -- MCP 工具仅支持 `http` 传输,`stdio` 会被拒绝,Session MCP 传输中的内联 `authorization` 以及 `headers` 中的 `Authorization` 标头也会被拒绝([HTTP MCP](execution-tools.md#http-mcp))。 **执行和历史** diff --git a/docs/runtime-protocol.md b/docs/runtime-protocol.md index b906a10e2..0417c1294 100644 --- a/docs/runtime-protocol.md +++ b/docs/runtime-protocol.md @@ -77,7 +77,7 @@ A declaration describes what the Runtime can do. Core admits a public feature on | `tool_search` | The Agent enables tool search or defers function loading | | `function_tools` | The Agent declares function tools | | `message_images`, `function_result_images` | A message, or a function result, carries an image | -| `mcp_http_tools`, `mcp_http_required`, `mcp_http_bearer_auth` | The Agent declares HTTP MCP servers; one is `required`; a Vault credential is selected for one | +| `mcp_http_tools`, `mcp_http_required`, `mcp_http_bearer_auth` | The Agent declares MCP servers (HTTP or stdio); one is `required`; a Vault credential is selected for one | Core has no admission rule for `usage` and `resume`. @@ -310,7 +310,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. `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. +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`. `MCPHTTPServer.stdio` (`command`, `args`, `cwd`, `env_vars`) declares a process of the Environment instead of a URL: it requires origin `environment` and carries no headers or bearer, and `env_vars` names variables the process inherits, never their values. 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 3d1f205fa..8934f0232 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: 8c334fe24521cf20a300c447b6b81d4aa0f7bc786bd89bc45722187d0c004acf +source_hash: b65ff793e777225c871367f2f48608458acf78104d0f831f2be7633a534e9768 --- Core–Runtime 协议连接 Core 与 Runtime daemon,定义 daemon 连接上消息的含义和顺序。托管和自托管 Runtime 使用同一协议。 @@ -79,7 +79,7 @@ wire 上每个字段都是 JSON boolean,所有字段都必须出现,包括 ` | `tool_search` | Agent 启用 tool search 或延迟 function 加载 | | `function_tools` | Agent 声明 function tool | | `message_images`, `function_result_images` | 消息或 function result 携带图像 | -| `mcp_http_tools`, `mcp_http_required`, `mcp_http_bearer_auth` | Agent 声明 HTTP MCP server;其中一个为 `required`;其中一个选用了 Vault 凭据 | +| `mcp_http_tools`, `mcp_http_required`, `mcp_http_bearer_auth` | Agent 声明 MCP server(HTTP 或 stdio);其中一个为 `required`;其中一个选用了 Vault 凭据 | `retained_native_history` 表示 Runtime 与 Harness 组合可在确认原生进程关闭后,从私有持久状态重新打开同一个原生 Session。它不保证恢复进程内存、后台进程或外部连接。所选文件系统还必须独立提供持久存储。Core 根据旧设备已持久化的声明判断能否释放计算而不终结 Session,并在准入执行前验证新 Runtime。原生历史缺失或无效时明确失败,绝不授权新建原生会话或重放此前输入。 @@ -312,7 +312,7 @@ Core 在 Worker 的 Session 调度预约上运行空闲目录读取,活动执 ## MCP 连接权限 {#mcp-connection-authority} -`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)负责支持的组合、原生限制和故障所有权。 +`execution_prepare` 配置中每个公开 `MCPHTTPServer` 都携带明确的 `connection_origin`;值缺失或未知时拒绝,不选择默认值,Core 在 dispatch 前冻结公开默认值。`MCPHTTPServer.headers` 携带非机密的字面标头;凭据只通过 `bearer_token` 传递。`MCPHTTPServer.stdio`(`command`、`args`、`cwd`、`env_vars`)声明 Environment 的进程而非 URL:它要求 `environment` 来源,不带标头或 bearer,`env_vars` 只列进程继承的变量名,不含其值。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 aeb2c1119..4bbf4f9a5 100644 --- a/internal/agentdaemon/proto/mcp.go +++ b/internal/agentdaemon/proto/mcp.go @@ -1,9 +1,16 @@ package proto -import "errors" +import ( + "errors" + "regexp" + "strings" -// MCPHTTPServer declares HTTP tools with an explicit outbound connection origin. Send only to a -// peer advertising mcp_http_tools; a transient BearerToken additionally requires + "github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities" +) + +// MCPHTTPServer declares public MCP tools with an explicit outbound connection origin. It is an HTTP +// server by ServerURL, or a process in the Environment when Stdio is set (origin "environment", +// ServerURL empty, no headers or bearer). Send only to a peer advertising mcp_http_tools; a transient BearerToken additionally requires // mcp_http_bearer_auth. Required initialization requires mcp_http_required. // Never persist or log this private request as configuration. type MCPHTTPServer struct { @@ -15,12 +22,42 @@ type MCPHTTPServer struct { BearerToken *string `json:"bearer_token,omitempty"` // Headers are non-secret; credentials travel only in BearerToken. Headers map[string]string `json:"headers,omitempty"` + Stdio *MCPStdio `json:"stdio,omitempty"` +} + +// MCPStdio starts an MCP server as a process of the Runtime's Environment. EnvVars +// name variables the process inherits from that Environment; values never cross the wire. +type MCPStdio struct { + Command string `json:"command"` + Args []string `json:"args"` + Cwd string `json:"cwd"` + EnvVars []string `json:"env_vars"` +} + +var mcpEnvironmentVariable = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +// Valid reports a startable declaration: a nonempty command, an absolute cwd, +// variable names in EnvVars and no NUL in the command or its arguments. +func (s MCPStdio) Valid() bool { + if strings.TrimSpace(s.Command) == "" || strings.ContainsRune(s.Command+strings.Join(s.Args, ""), 0) || + agentcapabilities.ValidateSourceDirectories([]string{s.Cwd}) != nil { + return false + } + for _, name := range s.EnvVars { + if !mcpEnvironmentVariable.MatchString(name) { + return false + } + } + return true } // ValidateConnectionOrigin rejects relocation before dispatch and adapter setup. func (server MCPHTTPServer) ValidateConnectionOrigin(req PromptRequestPayload) error { switch server.ConnectionOrigin { case "service": + if server.Stdio != nil { + return errors.New("MCP stdio requires an execution environment") + } if !req.DisableExecutionEnvironment || req.LocalEnvironment != nil { return errors.New("service-origin MCP requires a service execution host") } diff --git a/internal/agentdaemon/proto/version.go b/internal/agentdaemon/proto/version.go index 7280dfe4c..0a1fc835c 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.20.0" +const Version = "0.21.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 d3978e937..88af159c4 100644 --- a/internal/harnessconfig/claudesdk/declaration.go +++ b/internal/harnessconfig/claudesdk/declaration.go @@ -92,6 +92,10 @@ 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.") } + // Native hides unselected tools only for HTTP servers, which the adapter lists itself. + if server.Stdio != nil && server.AllowedTools != nil { + return errors.New("The configured engine requires allowed_tools=null for MCP stdio servers.") + } if ExpandsEnvironment(server) { return errors.New("The configured engine does not support ${ in MCP URLs or header values.") } diff --git a/internal/harnessconfig/mcode/declaration.go b/internal/harnessconfig/mcode/declaration.go index a2c6a3baf..c572b94ec 100644 --- a/internal/harnessconfig/mcode/declaration.go +++ b/internal/harnessconfig/mcode/declaration.go @@ -81,6 +81,10 @@ var Declaration = harnessconfig.Declaration{ } } for _, server := range mcp { + // Native starts session stdio servers in its home directory, which is private state, never the cwd a declaration names. + if server.Stdio != nil { + return errors.New("The configured engine cannot start MCP stdio servers in the declared cwd.") + } if server.AllowedTools != nil || server.Required { return errors.New("The configured engine requires allowed_tools=null and required=false.") } diff --git a/packages/claude-sdk-adapter/src/mcp_environment.ts b/packages/claude-sdk-adapter/src/mcp_environment.ts index b949b6e7c..8dffa8ad0 100644 --- a/packages/claude-sdk-adapter/src/mcp_environment.ts +++ b/packages/claude-sdk-adapter/src/mcp_environment.ts @@ -6,6 +6,9 @@ export type StdioServer = { command: string; args: string[]; allowed_tools: null; + required?: boolean; + // A public stdio server runs the caller's command; installed servers run the Runtime launcher. + declared?: true; }; export type EnvironmentMCPServer = HTTPServer | StdioServer; @@ -17,13 +20,18 @@ export function parseEnvironmentMCP(value: unknown): EnvironmentMCPServer[] | un for (const server of value) { if (!server || typeof server !== "object" || labels.has(server.server_label)) throw new Error("invalid_request"); if ("command" in server) { - if (Object.keys(server).some(key => !["server_label", "command", "args", "allowed_tools"].includes(key)) || + const declared = server.declared === true; + if (Object.keys(server).some(key => !["server_label", "command", "args", "allowed_tools", ...(declared ? ["declared", "required"] : [])].includes(key)) || + (server.declared !== undefined && !declared) || (server.required !== undefined && typeof server.required !== "boolean") || typeof server.server_label !== "string" || !/^[a-zA-Z0-9_-]+$/.test(server.server_label) || server.server_label === "functions" || server.allowed_tools !== null || - typeof server.command !== "string" || !isAbsolute(server.command) || normalize(server.command) !== server.command || - !Array.isArray(server.args) || server.args.length !== 4 || server.args[0] !== "runtime-mcp-exec" || - typeof server.args[1] !== "string" || !isAbsolute(server.args[1]) || normalize(server.args[1]) !== server.args[1] || server.args[1] === parse(server.args[1]).root || - typeof server.args[2] !== "string" || !server.args[2] || /[\\]/.test(server.args[2]) || server.args[2].split("/").some((part: string) => !part || part === "." || part === "..") || - server.args[3] !== server.server_label || [server.command, ...server.args].some((part: string) => /[\x00-\x1f\x7f]/.test(part))) throw new Error("invalid_request"); + typeof server.command !== "string" || !Array.isArray(server.args) || + [server.command, ...server.args].some((part: unknown) => typeof part !== "string" || (declared ? part.includes("\0") : /[\x00-\x1f\x7f]/.test(part)))) throw new Error("invalid_request"); + if (!declared && (!isAbsolute(server.command) || normalize(server.command) !== server.command || + server.args.length !== 4 || server.args[0] !== "runtime-mcp-exec" || + !isAbsolute(server.args[1]) || normalize(server.args[1]) !== server.args[1] || server.args[1] === parse(server.args[1]).root || + !server.args[2] || /[\\]/.test(server.args[2]) || server.args[2].split("/").some((part: string) => !part || part === "." || part === "..") || + server.args[3] !== server.server_label)) throw new Error("invalid_request"); + if (declared && !server.command.trim()) throw new Error("invalid_request"); labels.add(server.server_label); } else { diff --git a/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs b/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs index 98c867ee5..ac71b2018 100644 --- a/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs +++ b/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs @@ -136,3 +136,12 @@ test("public workspace HTTP preserves required startup and null versus empty all mcp.close(); } }); + +test("declared stdio MCP runs the caller's command natively", () => { + const declared = { server_label: "local", command: "run", args: ["-c", "a\nb"], allowed_tools: null, required: true, declared: true }; + assert.deepEqual(parseEnvironmentMCP([declared]), [declared]); + for (const value of [{ ...declared, allowed_tools: ["x"] }, { ...declared, declared: false }, { ...declared, command: " " }, { ...declared, env: {} }]) { + assert.throws(() => parseEnvironmentMCP([value]), /invalid_request/); + } + assert.deepEqual(new MCPProfile([declared], []).servers.local, { type: "stdio", command: "run", args: ["-c", "a\nb"], env: {}, alwaysLoad: true }); +}); diff --git a/services/core/internal/api/configuration_validation_test.go b/services/core/internal/api/configuration_validation_test.go index 2f4c48ef6..0c4fc121f 100644 --- a/services/core/internal/api/configuration_validation_test.go +++ b/services/core/internal/api/configuration_validation_test.go @@ -269,7 +269,7 @@ func TestAgentConfigurationLocalLimitsKeepCodes(t *testing.T) { assertConfigurationError(t, credentialRequest(h, http.MethodPost, op.path, op.body(`"multi_agent":{"enabled":true,"max_concurrent_subagents":4294967296}`)), "unsupported_or_invalid_configuration", nil, "max_concurrent_subagents must be an integer from 1 to 4294967295.") } for _, op := range configurationOperations()[:3] { - assertConfigurationError(t, credentialRequest(h, http.MethodPost, op.path, op.body(`"tools":[{"type":"mcp","server_label":"x","transport":{"type":"stdio","command":"run","cwd":"/"}}]`)), "unsupported_or_invalid_configuration", nil, "MCP currently supports HTTP transport only.") + assertConfigurationError(t, credentialRequest(h, http.MethodPost, op.path, op.body(`"tools":[{"type":"mcp","server_label":"x","connection_origin":"service","transport":{"type":"stdio","command":"run","cwd":"/"}}]`)), "unsupported_or_invalid_configuration", nil, "MCP stdio servers run in the Environment; connection_origin must be environment.") } if s.writes != 0 { t.Fatal("rejected configuration reached storage") diff --git a/services/core/internal/api/mcp_configuration.go b/services/core/internal/api/mcp_configuration.go index 191afb04c..cc9927111 100644 --- a/services/core/internal/api/mcp_configuration.go +++ b/services/core/internal/api/mcp_configuration.go @@ -7,13 +7,12 @@ import ( "strings" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/agentplugin" ) -const mcpHTTPOnly = "MCP currently supports HTTP transport only." - // resolveMCPTool canonicalizes a declaration whose pinned shape was checked at -// decode. Core executes HTTP servers only and keeps its local limits. +// decode. Core keeps its local limits. func resolveMCPTool(raw json.RawMessage, saved bool) (json.RawMessage, error) { var input v1.MCPToolInput var transport struct { @@ -21,6 +20,11 @@ func resolveMCPTool(raw json.RawMessage, saved bool) (json.RawMessage, error) { ServerURL string `json:"server_url"` Authorization *string `json:"authorization"` Headers map[string]string `json:"headers"` + Command string `json:"command"` + Args []string `json:"args"` + Cwd string `json:"cwd"` + EnvVars []string `json:"env_vars"` + Env map[string]string `json:"env"` } if err := errors.Join(json.Unmarshal(raw, &input), json.Unmarshal(input.Transport, &transport)); err != nil { return nil, &storedDataError{err} @@ -28,28 +32,58 @@ func resolveMCPTool(raw json.RawMessage, saved bool) (json.RawMessage, error) { if strings.TrimSpace(input.ServerLabel) == "" { return nil, errors.New("MCP tools require type=mcp and a nonempty server_label.") } - if transport.Type != "http" { - return nil, errors.New(mcpHTTPOnly) - } + stdio := transport.Type == "stdio" // The official service saves an omitted or null origin on an HTTP server as // "service" (MV-01). Defaulting it here makes the stored and frozen - // configuration identical to an explicit declaration. + // configuration identical to an explicit declaration. A stdio server runs + // in the Environment, so "environment" is its default and only origin. origin := "service" + if stdio { + origin = "environment" + } if input.ConnectionOrigin != nil { origin = *input.ConnectionOrigin } - if input.CredentialID != nil && *input.CredentialID == "" { - return nil, errors.New("MCP credential_id must be null or a nonempty string.") + if stdio && origin != "environment" { + return nil, errors.New("MCP stdio servers run in the Environment; connection_origin must be environment.") + } + if input.CredentialID != nil && (*input.CredentialID == "" || stdio) { + return nil, errors.New("MCP credential_id must be null or a nonempty string, and stdio servers take none.") } if len(input.RequestMetadata) != 0 { return nil, errors.New("Nonempty MCP request_metadata is not supported yet.") } + var allowed *[]string + if input.AllowedTools != nil { + for _, name := range input.AllowedTools { + if name == "" { + return nil, errors.New("MCP allowed_tools requires nonempty string names.") + } + } + allowed = &input.AllowedTools + } + tool := v1.MCPTool{Type: "mcp", ServerLabel: input.ServerLabel, AllowedTools: allowed, Required: input.Required != nil && *input.Required, + ConnectionOrigin: origin, CredentialID: input.CredentialID, RequestMetadata: map[string]json.RawMessage{}} + if stdio { + if len(transport.Env) != 0 { + return nil, errors.New("Inline MCP env is not supported; declare env_vars and set the values in the Environment.") + } + args, envVars := append([]string{}, transport.Args...), append([]string{}, transport.EnvVars...) + if !(proto.MCPStdio{Command: transport.Command, Args: args, Cwd: transport.Cwd, EnvVars: envVars}).Valid() { + return nil, errors.New("MCP stdio servers require a nonempty command, an absolute cwd, variable names in env_vars and no NUL characters.") + } + tool.Transport = v1.MCPTransport{Type: "stdio", Command: transport.Command, Args: &args, Cwd: transport.Cwd, EnvVars: &envVars} + return json.Marshal(tool) + } + if transport.Type != "http" { + return nil, errors.New("MCP supports the http and stdio transports only.") + } u, err := url.Parse(transport.ServerURL) 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.") + return nil, errors.New("Inline MCP authorization is not supported; use credential_id.") } for name := range transport.Headers { if strings.EqualFold(name, "Authorization") { @@ -59,18 +93,7 @@ func resolveMCPTool(raw json.RawMessage, saved bool) (json.RawMessage, error) { 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 { - if name == "" { - return nil, errors.New("MCP allowed_tools requires nonempty string names.") - } - } - allowed = &input.AllowedTools - } - 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{}} + tool.Transport = v1.MCPTransport{Type: "http", ServerURL: transport.ServerURL} if len(transport.Headers) != 0 { tool.Transport.Headers = &transport.Headers } else if saved { diff --git a/services/core/internal/api/mcp_configuration_test.go b/services/core/internal/api/mcp_configuration_test.go index 628bc46f6..9314cf0a0 100644 --- a/services/core/internal/api/mcp_configuration_test.go +++ b/services/core/internal/api/mcp_configuration_test.go @@ -71,11 +71,42 @@ func TestMCPOmittedOriginIsService(t *testing.T) { } } } - // Other transports report the transport restriction with any origin. - for _, origin := range []string{"", `,"connection_origin":null`, `,"connection_origin":"service"`} { - input := `{"type":"mcp","server_label":"records"` + origin + `,"transport":{"type":"stdio","command":"run","cwd":"/"}}` - if _, err := resolveMCPTool(json.RawMessage(input), true); err == nil || err.Error() != "MCP currently supports HTTP transport only." { - t.Fatalf("stdio origin %q: %v", origin, err) +} + +// A stdio server runs in the Environment: the origin defaults to it, an inline +// env is rejected, and the saved and effective forms match the pinned resource. +func TestMCPStdioDeclaration(t *testing.T) { + const stdio = `{"type":"stdio","command":"run","args":["-x"],"cwd":"/work","env_vars":["TOKEN"]` + input := func(origin, transport string) json.RawMessage { + return json.RawMessage(`{"type":"mcp","server_label":"records"` + origin + `,"transport":` + transport + `}`) + } + for _, saved := range []bool{false, true} { + for _, origin := range []string{"", `,"connection_origin":null`, `,"connection_origin":"environment"`} { + raw, err := resolveMCPTool(input(origin, stdio+`,"env":{}}`), saved) + var tool map[string]any + if err != nil || json.Unmarshal(raw, &tool) != nil || tool["connection_origin"] != "environment" || + !reflect.DeepEqual(tool["transport"], map[string]any{"type": "stdio", "command": "run", "args": []any{"-x"}, "cwd": "/work", "env_vars": []any{"TOKEN"}}) { + t.Fatalf("saved=%v origin %q: %s, %v", saved, origin, raw, err) + } + } + } + raw, err := resolveMCPTool(input("", `{"type":"stdio","command":"run","args":null,"cwd":"/","env_vars":null}`), true) + if err != nil || !strings.Contains(string(raw), `"args":[]`) || !strings.Contains(string(raw), `"env_vars":[]`) { + t.Fatalf("null lists: %s, %v", raw, err) + } + for message, raw := range map[string]json.RawMessage{ + "Inline MCP env is not supported; declare env_vars and set the values in the Environment.": input("", stdio+`,"env":{"TOKEN":"private-marker"}}`), + "MCP stdio servers run in the Environment; connection_origin must be environment.": input(`,"connection_origin":"service"`, stdio+`}`), + } { + if _, err := resolveMCPTool(raw, false); err == nil || err.Error() != message { + t.Fatalf("want %q, got %v", message, err) + } + } + const invalid = "MCP stdio servers require a nonempty command, an absolute cwd, variable names in env_vars and no NUL characters." + for _, transport := range []string{`{"type":"stdio","command":" ","cwd":"/"}`, `{"type":"stdio","command":"run","cwd":"work"}`, + `{"type":"stdio","command":"run","cwd":"/","env_vars":["A-B"]}`, `{"type":"stdio","command":"run","args":["\u0000"],"cwd":"/"}`} { + if _, err := resolveMCPTool(input("", transport), true); err == nil || err.Error() != invalid { + t.Fatalf("%s: %v", transport, err) } } } @@ -104,8 +135,6 @@ func TestMCPAllowedToolsAndOptionalFields(t *testing.T) { // The pinned shape is checked at decode; these inputs match it. func TestMCPUnsupportedInputsAreSecretSafe(t *testing.T) { for name, replacement := range map[string]map[string]json.RawMessage{ - "stdio origin missing": {"connection_origin": nil, "transport": json.RawMessage(`{"type":"stdio","command":"private-marker","cwd":"/"}`)}, - "stdio origin null": {"connection_origin": json.RawMessage("null"), "transport": json.RawMessage(`{"type":"stdio","command":"private-marker","cwd":"/"}`)}, "empty credential": {"credential_id": json.RawMessage(`""`)}, "metadata": {"request_metadata": json.RawMessage(`{"private-marker":"value"}`)}, "inline authorization": {"transport": json.RawMessage(`{"type":"http","server_url":"https://mcp.example.test","authorization":"private-marker"}`)}, @@ -113,7 +142,7 @@ func TestMCPUnsupportedInputsAreSecretSafe(t *testing.T) { "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":"/"}`)}, + "stdio credential": {"credential_id": json.RawMessage(`"private-marker"`), "transport": json.RawMessage(`{"type":"stdio","command":"run","cwd":"/"}`)}, } { t.Run(name, func(t *testing.T) { var input map[string]json.RawMessage diff --git a/services/core/internal/api/session_credentials_test.go b/services/core/internal/api/session_credentials_test.go index 7401b5d1f..d291f6a6f 100644 --- a/services/core/internal/api/session_credentials_test.go +++ b/services/core/internal/api/session_credentials_test.go @@ -67,7 +67,7 @@ func TestSessionVaultTypesAndCreationIntent(t *testing.T) { func TestSessionProjectionShowsSelectedMCPCredential(t *testing.T) { vault, credential := uuid.NewString(), uuid.NewString() tool := func(label, credentialID string) json.RawMessage { - value := v1.MCPTool{Type: "mcp", ServerLabel: label, Transport: v1.MCPHTTPTransport{Type: "http", ServerURL: "https://mcp.example.test/" + label}, + value := v1.MCPTool{Type: "mcp", ServerLabel: label, Transport: v1.MCPTransport{Type: "http", ServerURL: "https://mcp.example.test/" + label}, ConnectionOrigin: "service", RequestMetadata: map[string]json.RawMessage{}} if credentialID != "" { value.CredentialID = &credentialID diff --git a/services/core/internal/execution/mcp.go b/services/core/internal/execution/mcp.go index 15a5d6e75..6cf51518a 100644 --- a/services/core/internal/execution/mcp.go +++ b/services/core/internal/execution/mcp.go @@ -76,13 +76,9 @@ 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" { + if decoder.Decode(&tool) != nil || strings.TrimSpace(tool.ServerLabel) == "" || names[tool.ServerLabel] || (tool.ConnectionOrigin != "service" && tool.ConnectionOrigin != "environment") || len(tool.RequestMetadata) != 0 { 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 != "" { - return executionToolSet{}, errors.New("unsupported execution MCP URL") - } if tool.AllowedTools != nil { for _, name := range *tool.AllowedTools { if name == "" { @@ -90,13 +86,27 @@ 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 + server := proto.MCPHTTPServer{ConnectionOrigin: tool.ConnectionOrigin, ServerLabel: tool.ServerLabel, AllowedTools: tool.AllowedTools, Required: tool.Required} + switch transport := tool.Transport; transport.Type { + case "stdio": + if tool.ConnectionOrigin != "environment" || transport.Command == "" || transport.Args == nil || transport.EnvVars == nil || tool.CredentialID != nil { + return executionToolSet{}, errors.New("unsupported execution MCP configuration") + } + server.Stdio = &proto.MCPStdio{Command: transport.Command, Args: *transport.Args, Cwd: transport.Cwd, EnvVars: *transport.EnvVars} + case "http": + u, err := url.Parse(transport.ServerURL) + if err != nil || u.Hostname() == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.Fragment != "" { + return executionToolSet{}, errors.New("unsupported execution MCP URL") + } + server.ServerURL = transport.ServerURL + if transport.Headers != nil { + server.Headers = *transport.Headers + } + default: + return executionToolSet{}, errors.New("unsupported execution MCP configuration") } - servers = append(servers, proto.MCPHTTPServer{ConnectionOrigin: tool.ConnectionOrigin, ServerLabel: tool.ServerLabel, - ServerURL: tool.Transport.ServerURL, AllowedTools: tool.AllowedTools, Required: tool.Required, Headers: headers}) + names[tool.ServerLabel] = true + servers = append(servers, server) } resolved, err := functionTools(functions) return executionToolSet{Functions: resolved, MCP: servers, Search: search, WebSearch: webSearch, ProgrammaticToolCalling: programmatic}, err diff --git a/services/core/internal/execution/mcp_credentials_test.go b/services/core/internal/execution/mcp_credentials_test.go index ee8fea781..de47480b1 100644 --- a/services/core/internal/execution/mcp_credentials_test.go +++ b/services/core/internal/execution/mcp_credentials_test.go @@ -14,7 +14,7 @@ func TestMCPFrozenCredentialAdmission(t *testing.T) { vault, credential := uuid.NewString(), uuid.NewString() for _, mode := range []string{"implicit", "explicit", "oauth implicit", "oauth explicit", "anonymous", "missing", "unattached", "wrong URL", "wrong auth", "changed selection", "HTTP", "self-hosted explicit", "self-hosted implicit", "self-hosted anonymous"} { t.Run(mode, func(t *testing.T) { - tool := v1.MCPTool{Type: "mcp", ServerLabel: "tools", ConnectionOrigin: "service", Transport: v1.MCPHTTPTransport{Type: "http", ServerURL: "https://mcp.example/tools"}} + tool := v1.MCPTool{Type: "mcp", ServerLabel: "tools", ConnectionOrigin: "service", Transport: v1.MCPTransport{Type: "http", ServerURL: "https://mcp.example/tools"}} binding := vaults.MCPCredentialBinding{ServerLabel: "tools", ServerURL: tool.Transport.ServerURL, VaultID: vault, CredentialID: credential, AuthType: "static_bearer"} snapshot := Snapshot{Agent: v1.Agent{Model: "model"}, Environment: v1.Environment{Type: "none"}, VaultIDs: []string{vault}} switch mode { diff --git a/services/core/internal/execution/mcp_support_test.go b/services/core/internal/execution/mcp_support_test.go index ea697bd0b..7d3921e3a 100644 --- a/services/core/internal/execution/mcp_support_test.go +++ b/services/core/internal/execution/mcp_support_test.go @@ -3,6 +3,7 @@ package execution import ( "context" "encoding/json" + "fmt" "reflect" "testing" @@ -160,3 +161,30 @@ func TestMCPAnonymousExecutionPreservesFrozenDecision(t *testing.T) { } } } + +// A stdio server runs in the Environment, so only workspace placements admit it, +// and each Harness declaration applies its own limits. +func TestMCPStdioAdmission(t *testing.T) { + tool := `{"type":"mcp","server_label":"local","connection_origin":"environment","allowed_tools":%s,"transport":{"type":"stdio","command":"run","args":[],"cwd":"/work","env_vars":["TOKEN"]}}` + for _, tc := range []struct { + engine, placement, allowed string + admitted bool + }{ + {"codex", "self_hosted", "null", true}, + {"codex", "openai_hosted", `["lookup"]`, true}, + {"codex", "none", "null", false}, + {"claude_sdk", "self_hosted", "null", true}, + {"claude_sdk", "self_hosted", `["lookup"]`, false}, + {"mcode", "self_hosted", "null", false}, + } { + snapshot := Snapshot{Agent: v1.Agent{Model: "model", Tools: []json.RawMessage{json.RawMessage(fmt.Sprintf(tool, tc.allowed))}}, Environment: v1.Environment{Type: tc.placement}} + declaration, _ := (Policy{}).Harnesses().Lookup(tc.engine) + if err := validateDeclaredConfiguration(declaration, snapshot); (err == nil) != tc.admitted { + t.Fatalf("%+v: %v", tc, err) + } + } + servers, err := executionTools([]json.RawMessage{json.RawMessage(fmt.Sprintf(tool, "null"))}) + if err != nil || !reflect.DeepEqual(servers.MCP[0].Stdio, &proto.MCPStdio{Command: "run", Args: []string{}, Cwd: "/work", EnvVars: []string{"TOKEN"}}) { + t.Fatal(err, servers.MCP) + } +} diff --git a/services/core/tests/official_mcp.py b/services/core/tests/official_mcp.py index 20971294b..2e0f553a7 100644 --- a/services/core/tests/official_mcp.py +++ b/services/core/tests/official_mcp.py @@ -73,10 +73,10 @@ def verify_mcp_configuration(client, other, expect_error): for changes in ({"headers": {"Authorization": "synthetic-private"}}, {"authorization": "synthetic-private"}): invalid.append({**tool, "transport": {**transport, **changes}}) - # A pinned stdio transport stays unsupported with or without an origin; a + # A stdio server runs in the Environment, so a service origin is rejected; a # malformed one gets the official field error first. stdio, malformed = {"type": "stdio", "command": "synthetic-private", "cwd": "/"}, {"type": "stdio", "command": "synthetic-private"} - invalid += [{**minimal, "transport": stdio}, {**tool, "transport": stdio}, {**tool, "transport": malformed}] + invalid += [{**tool, "transport": stdio}, {**tool, "transport": malformed}] for declaration in invalid: for prefix, operation in ( ("", lambda: agents.create(model="requested-model", tools=[declaration])), @@ -86,7 +86,7 @@ def verify_mcp_configuration(client, other, expect_error): error = expect_error(BadRequestError, operation) assert "synthetic-private" not in str(error.body) if declaration["transport"] is stdio: - assert error.body["message"] == "MCP currently supports HTTP transport only." + assert error.body["message"] == "MCP stdio servers run in the Environment; connection_origin must be environment." if declaration["transport"] is malformed: assert error.body["message"] == f"Missing required parameter: '{prefix}tools[0].transport.cwd'." assert {item.id for item in sessions.list()} == before