Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/daemon/internal/agent/claudesdk/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ||
Expand Down
21 changes: 17 additions & 4 deletions apps/daemon/internal/agent/claudesdk/mcp_environment.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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}}
Expand Down
21 changes: 19 additions & 2 deletions apps/daemon/internal/agent/claudesdk/mcp_environment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package claudesdk
import (
"encoding/json"
"os"
"slices"
"strings"
"testing"

Expand All @@ -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)
Expand Down Expand Up @@ -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")
}
}
Expand All @@ -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")
}
}
2 changes: 1 addition & 1 deletion apps/daemon/internal/agent/claudesdk/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion apps/daemon/internal/agent/claudesdk/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
54 changes: 31 additions & 23 deletions apps/daemon/internal/agent/codex/mcp_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ type mcpServerConfig struct {
URL string
Command string
Args []string
Cwd string
EnvVars []string
EnabledTools *[]string
Required bool
BearerTokenEnvVar string
Expand Down Expand Up @@ -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')
}
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions apps/daemon/internal/agent/codex/mcp_http.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
17 changes: 14 additions & 3 deletions apps/daemon/internal/agent/codex/mcp_http_preflight.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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)
}
29 changes: 29 additions & 0 deletions apps/daemon/internal/agent/codex/mcp_http_preflight_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
4 changes: 3 additions & 1 deletion apps/daemon/internal/agent/mcode/environment_mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions apps/daemon/internal/agent/mcode/environment_mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
34 changes: 24 additions & 10 deletions apps/daemon/internal/agent/mcp_binding.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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")
}
Expand Down
25 changes: 25 additions & 0 deletions apps/daemon/internal/agent/mcp_binding_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package agent

import (
"reflect"
"testing"

"github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto"
Expand Down Expand Up @@ -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")
}
}
Loading
Loading