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
60 changes: 50 additions & 10 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ Inline and referenced Skill ZIPs use the same confidential initialization snapsh
Core validates portable manifests and bounded regular-file archives, returns only
safe Skill metadata, and freezes content before native preparation. The Runtime
owns `/environment/initialization/capabilities/skills/<name>`; setup and native tools may read but
not modify this tree. Skill-only public Plugin ZIPs preserve their complete package
not modify this tree. Public Plugin ZIPs preserve their complete package
layout and reuse the shared archive and portable Skill parsers. Core keeps safe
Plugin metadata separate from encrypted archives. Templates inherit or replace
Plugin and capability-directory lists through the same hosted resolver.
Expand All @@ -315,11 +315,49 @@ Adapters register only selected Skill roots without changing the execution loop.
Codex uses explicit extra roots; MiniMax projects its native catalog; Claude creates
one controlled envelope per package with real directories and immutable hardlinks
under content. Its explicit paths remain inside that envelope; original native
control files are not activated. Keep native MCP discovery disabled. Unsupported
native activation fails explicitly. Public Plugin MCP remains separate qualification,
not silent partial activation or a generic plugin framework.

Name, enabled/disabled/exact-domain restricted network, initial files, inline/referenced Skills, skill-only Plugins, workspace capability directories and env/setup/system/npm/Python are
control files are not activated. Keep automatic native MCP discovery disabled.
Explicit Environment MCP declarations
follow the separately qualified transport path below; unsupported native activation
fails explicitly, without silent partial activation or a generic plugin framework.

Environment-origin MCP declarations use the shared Plugin parser and frozen
installed packages. The installation manifest retains selected MCP package roots;
Runtime re-parses those protected packages without another configuration copy,
credential cache or lifecycle ledger. Native adapters must explicitly qualify and
project supported declarations before public admission. Parent-directory Skill
discovery does not activate nested Plugin MCP configuration.

The packaged stdio entry enters the existing initialization sandbox before parsing
or executing a server. It mounts only the fixed static daemon helper, which resolves
the selected installed declaration, reads explicitly selected initialized user
variables, applies package-relative cwd and replaces itself with the server. Native
MCP stdin/stdout pass directly to the sandbox; no protocol forwarding loop,
Provider command or second Plugin parser is introduced. The existing Python stdio
entry remains a single-threaded launcher and monitors the native parent process
through Linux pidfds. Native Codex recycles spawning threads, so binding bwrap's
parent-death signal directly to those threads kills live MCP connections. The
launcher gives bwrap a stable parent, retains its parent-death/PID namespace cleanup,
and kills and reaps only that child when the native process exits. A pre-exec
parent-death signal and expected-parent PID check close the reproduced fork-to-exec
orphan window; bind libc before fork and keep this entry single-threaded. Missing
pidfd support fails closed. This fixes the OS process lifetime boundary without adding a
Core execution owner, retry or reconnect loop.
Model/daemon launch variables are never credential sources. Hosted stdio requires
enabled network; native HTTP requires its own qualified network and redirect behavior.
Claude composes the existing MCP identity/observation profile with its workspace
profile. Verify the complete native inventory before admitting tool identities;
MCP allowlist patterns never grant local Bash or file authority. Only declared
random bearer references enter native query env, with native Bash denial retained.
Environment-origin literal HTTP headers remain rejected for the pinned Claude
client because its interpolation and cross-origin forwarding change their meaning.
MiniMax accepts environment stdio only. Its adapter reads the existing Session-private
native runtime-name registry for exact first-frame identities and cross-checks
completed native results. Reuse existing observation and cancellation settlement;
never fabricate a delayed start event, guess normalized identities or add a registry
of our own. Its unqualified HTTP transport remains rejected.
These mechanisms alone do not establish public MCP support or full compatibility.

Name, enabled/disabled/exact-domain restricted network, initial files, inline/referenced Skills, Plugins, workspace capability directories and env/setup/system/npm/Python are
implemented independently of remaining installation fields. Reject unsupported
inputs rather than persisting them for silent
omission; expand inline and template initialization together in separately qualified
Expand Down Expand Up @@ -1485,7 +1523,7 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti
the selected harness. Omitted/null service tier currently uses `auto`; complete
upstream default/error/retry conformance and remaining MCP/web-search variants
remain gaps. Unknown/unsupported variants fail explicitly. No product lookup is permitted.
- Public HTTP MCP uses the native harness client and tool loop. The supported
- Service-origin public HTTP MCP uses the native harness client and tool loop. The supported
execution profiles are Codex with `environment:none` or `self_hosted`, and
Claude SDK with `environment:none`. Both require an explicit `service`
connection origin and a trusted service-side harness. The execution device is
Expand Down Expand Up @@ -2133,7 +2171,8 @@ Claude hosted functions compose the existing SDK function bridge with the native
workspace sandbox. Only declared function tools and the verified native tool
inventory are available. The bundle advertises this combination separately from
basic workspace execution; function preparations require that verified combination.
External hosted MCP remains unqualified. Function callbacks do not change file,
Service-origin hosted MCP remains unqualified; Environment Plugin declarations
use the separately qualified initialization path above. Function callbacks do not change file,
credential, history, subagent or network authority.

### Claude dedicated Docker Runtime
Expand Down Expand Up @@ -2166,8 +2205,9 @@ Registration combines that contract with the verified operator binding. Core use
an explicit accepted engine profile independently of advertisements. This profile
supports native Bash/Read/Edit, preparation, shared Files/Artifacts, cancellation
and same-history continuation. The separately advertised `workspace_functions`
combination supports declared public functions with text results. External HTTP
MCP remains unqualified here; its existing `none` support is retained.
combination supports declared public functions with text results. Service-origin
HTTP MCP remains unqualified here; its existing `none` support is retained.
Environment Plugin MCP follows the separately qualified transport path above.
Native Bash network access uses the harness's HTTP proxy; no alternate networking
or tool loop is implemented by Core.

Expand Down
12 changes: 8 additions & 4 deletions apps/parsar-daemon/internal/agent/claudesdk/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,12 @@ func validateMCP(req proto.PromptRequestPayload) error {
if !req.DisableExecutionEnvironment || req.RemoteEnvironment != nil {
return fmt.Errorf("claudesdk: HTTP MCP requires environment:none")
}
return validateMCPServers(*req.MCPHTTPServers)
}

func validateMCPServers(servers []proto.MCPHTTPServer) error {
labels := map[string]bool{}
for _, server := range *req.MCPHTTPServers {
for _, server := range servers {
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 All @@ -51,7 +55,7 @@ func validateMCP(req proto.PromptRequestPayload) error {
// process environment and are expanded by the native HTTP client.
type mcpHTTPServer struct {
ServerLabel string `json:"server_label"`
ServerURL string `json:"server_url"`
ServerURL string `json:"server_url,omitempty"`
AllowedTools *[]string `json:"allowed_tools"`
Required bool `json:"required,omitempty"`
BearerTokenEnvVar string `json:"bearer_token_env_var,omitempty"`
Expand Down Expand Up @@ -84,12 +88,12 @@ type mcpState struct {

func (m *mcpState) receive(event bridgeEvent, start startRequest, emit func(string, any)) error {
n := event.Observation
if start.MCPHTTPServers == nil || n == nil || n.Kind != "mcp" || event.ID == "" || !json.Valid(n.Arguments) ||
if n == nil || n.Kind != "mcp" || event.ID == "" || !json.Valid(n.Arguments) ||
!json.Valid(n.Output) || !json.Valid(n.Error) {
return fmt.Errorf("claudesdk: invalid MCP observation")
}
declared := false
for _, server := range *start.MCPHTTPServers {
for _, server := range start.declaredMCP() {
if server.ServerLabel == n.Server && n.Name != "" && (server.AllowedTools == nil || slices.Contains(*server.AllowedTools, n.Name)) {
declared = true
break
Expand Down
72 changes: 72 additions & 0 deletions apps/parsar-daemon/internal/agent/claudesdk/mcp_environment.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package claudesdk

import (
"fmt"

"github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace"
"github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto"
)

// Environment servers originate in frozen installed packages. Only native
// transport projection crosses this private bridge, never package configuration.
type environmentMCPServer struct {
mcpHTTPServer
Command string `json:"command,omitempty"`
Args []string `json:"args,omitempty"`
}

func prepareEnvironmentMCP(environment *proto.LocalEnvironment) ([]environmentMCPServer, []string, error) {
if environment == nil || len(environment.MCP) == 0 {
return nil, nil, nil
}
if environment.NetworkAccess != "enabled" {
return nil, nil, fmt.Errorf("claudesdk: environment MCP requires enabled network")
}
var servers []environmentMCPServer
var env []string
labels := map[string]bool{}
for _, item := range environment.MCP {
declaration := item.Server
if !mcpLabel.MatchString(declaration.Name) || declaration.Name == "functions" || labels[declaration.Name] {
return nil, nil, fmt.Errorf("claudesdk: unsupported environment MCP identity")
}
labels[declaration.Name] = true
switch declaration.Type {
case "stdio":
command, args := localworkspace.MCPStdioCommand(item)
servers = append(servers, environmentMCPServer{mcpHTTPServer: mcpHTTPServer{ServerLabel: declaration.Name}, Command: command, Args: args})
case "http":
// The pinned native client expands literal headers again and forwards
// custom headers across origins. Do not reinterpret their public meaning.
if len(declaration.HTTPHeaders) != 0 {
return nil, nil, fmt.Errorf("claudesdk: environment MCP literal HTTP headers are not supported")
}
if declaration.BearerTokenEnvVar != "" && item.BearerToken == nil {
return nil, nil, fmt.Errorf("claudesdk: environment MCP credential unavailable")
}
http := []proto.MCPHTTPServer{{ServerLabel: declaration.Name, ServerURL: declaration.URL, BearerToken: item.BearerToken}}
if err := validateMCPServers(http); err != nil {
return nil, nil, err
}
projected, credentials := prepareMCPHTTP(&http)
servers = append(servers, environmentMCPServer{mcpHTTPServer: (*projected)[0]})
env = append(env, credentials...)
default:
return nil, nil, fmt.Errorf("claudesdk: unsupported environment MCP transport")
}
}
return servers, env, nil
}

func (start startRequest) declaredMCP() []mcpHTTPServer {
var servers []mcpHTTPServer
if start.MCPHTTPServers != nil {
servers = append(servers, (*start.MCPHTTPServers)...)
}
if start.Workspace != nil {
for _, server := range start.Workspace.MCP {
servers = append(servers, server.mcpHTTPServer)
}
}
return servers
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package claudesdk

import (
"encoding/json"
"strings"
"testing"

"github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto"
"github.com/MiniMax-AI-Dev/parsar/internal/agentplugin"
)

func TestEnvironmentMCPUsesInstalledLauncherAndSelectedCredential(t *testing.T) {
config := workspaceFixture(t)
config.Workspace.NetworkAccess = "enabled"
req := workspaceRequest()
token := "selected-user-token"
t.Setenv("MCP_TOKEN", "unselected-native-token")
req.LocalEnvironment = &proto.LocalEnvironment{NetworkAccess: "enabled", MCP: []proto.EnvironmentMCP{
{PackageRoot: "plugins/local", Server: agentplugin.MCPServer{Name: "local", Type: "stdio", Command: "untrusted-package-command", Args: []string{"package-argument"}, EnvVars: []string{"MCP_TOKEN"}}},
{PackageRoot: "plugins/remote", Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.invalid/mcp", BearerTokenEnvVar: "MCP_TOKEN"}, BearerToken: &token},
}}
start, env, err := prepare(config, req)
if err != nil {
t.Fatal(err)
}
if start.MCPHTTPServers != nil || len(start.Workspace.MCP) != 2 {
t.Fatal("environment declarations changed authority")
}
stdio := start.Workspace.MCP[0]
if stdio.Command != "/usr/bin/python3" || len(stdio.Args) != 6 || stdio.Args[4] != "plugins/local" || stdio.Args[5] != "local" {
t.Fatal("stdio bypassed the shared installed entry")
}
raw, _ := json.Marshal(start)
for _, forbidden := range []string{token, "unselected-native-token", "untrusted-package-command", "package-argument", `"MCP_TOKEN"`} {
if strings.Contains(string(raw), forbidden) {
t.Fatal("private request contains package input or credential values")
}
}
reference := start.Workspace.MCP[1].BearerTokenEnvVar
found := false
for _, entry := range env {
if entry == reference+"="+token {
found = true
}
if strings.Contains(entry, "unselected-native-token") {
t.Fatal("inherited native credential")
}
}
if !found || !strings.HasPrefix(reference, "PARSAR_MCP_BEARER_") || len(start.declaredMCP()) != 2 {
t.Fatal("selected credential or observation declarations missing")
}
}

func TestEnvironmentMCPRejectsUnqualifiedCombinations(t *testing.T) {
for _, mutate := range []func(*proto.LocalEnvironment){
func(e *proto.LocalEnvironment) { e.NetworkAccess = "restricted" },
func(e *proto.LocalEnvironment) { e.MCP[0].Server.Name = "functions" },
func(e *proto.LocalEnvironment) { e.MCP = append(e.MCP, e.MCP[0]) },
func(e *proto.LocalEnvironment) { e.MCP[0].Server.Type = "sse" },
func(e *proto.LocalEnvironment) { e.MCP[0].Server.HTTPHeaders = map[string]string{"X-Key": "literal"} },
func(e *proto.LocalEnvironment) { e.MCP[0].Server.BearerTokenEnvVar = "MISSING" },
func(e *proto.LocalEnvironment) {
token := "token"
e.MCP[0].BearerToken = &token
e.MCP[0].Server.URL = "http://example.invalid/mcp"
},
} {
environment := &proto.LocalEnvironment{NetworkAccess: "enabled", MCP: []proto.EnvironmentMCP{{PackageRoot: "plugins/remote", Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.invalid/mcp"}}}}
mutate(environment)
if _, _, err := prepareEnvironmentMCP(environment); err == nil {
t.Fatal("unsupported declaration accepted")
}
}
}

func TestEnvironmentMCPObservationsUseInstalledDeclarations(t *testing.T) {
start := startRequest{Workspace: &workspaceProfile{MCP: []environmentMCPServer{{mcpHTTPServer: mcpHTTPServer{ServerLabel: "installed"}}}}, observeFunctions: true}
state := mcpState{calls: map[string]proto.ToolObservation{}}
observation := proto.ToolObservation{Kind: "mcp", Name: "echo", Server: "installed", Status: "in_progress", Arguments: json.RawMessage(`{}`), Output: json.RawMessage(`null`), Error: json.RawMessage(`null`)}
var emitted []proto.ToolCallPayload
emit := func(_ string, payload any) { emitted = append(emitted, payload.(proto.ToolCallPayload)) }
if err := state.receive(bridgeEvent{ID: "native-call", Stage: "before", Observation: &observation}, start, emit); err != nil {
t.Fatal(err)
}
state.close(start, emit)
if len(emitted) != 2 || emitted[1].ID != "native-call" || emitted[1].Observation.Status != "incomplete" {
t.Fatal("interrupted environment call lost identity")
}
observation.Server = "undeclared"
if err := state.receive(bridgeEvent{ID: "other", Stage: "before", Observation: &observation}, start, emit); err == nil {
t.Fatal("undeclared observation accepted")
}
}
8 changes: 7 additions & 1 deletion apps/parsar-daemon/internal/agent/claudesdk/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ type WorkspaceConfig struct {
}

type workspaceProfile struct {
MCP []environmentMCPServer `json:"mcp,omitempty"`
Skills []agentcapabilities.InstalledSkill `json:"skills,omitempty"`
ToolEnvironment bool `json:"tool_environment,omitempty"`
SystemPackages bool `json:"system_packages,omitempty"`
Expand Down Expand Up @@ -67,7 +68,12 @@ func prepareWorkspace(config Config, req proto.PromptRequestPayload) (*workspace
if req.LocalEnvironment != nil {
profile.Skills = req.LocalEnvironment.Skills
}
return profile, env, nil
servers, credentials, err := prepareEnvironmentMCP(req.LocalEnvironment)
if err != nil {
return nil, nil, err
}
profile.MCP = servers
return profile, append(env, credentials...), nil
}

func workspaceCwd(w *WorkspaceConfig) string {
Expand Down
49 changes: 29 additions & 20 deletions apps/parsar-daemon/internal/agent/codex/mcp_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ type mcpServerConfig struct {
EnabledTools *[]string
Required bool
BearerTokenEnvVar string
EnvHTTPHeaders map[string]string
ApproveTools bool
}

// writeCodexMCPConfig writes a `[mcp_servers.<name>]` TOML table per
Expand Down Expand Up @@ -49,10 +51,13 @@ func writeCodexMCPConfig(codexHome string, servers map[string]mcpServerConfig) e
b.WriteString("[mcp_servers.")
b.WriteString(tomlQuoteString(name))
b.WriteString("]\n")
if srv.ApproveTools {
b.WriteString("default_tools_approval_mode = \"approve\"\n")
}
if srv.Required {
b.WriteString("required = true\n")
}
if srv.URL != "" {
if srv.Required {
b.WriteString("required = true\n")
}
b.WriteString(`url = `)
b.WriteString(tomlQuoteString(srv.URL))
b.WriteByte('\n')
Expand All @@ -71,23 +76,8 @@ func writeCodexMCPConfig(codexHome string, servers map[string]mcpServerConfig) e
}
b.WriteString("]\n")
}
if len(srv.Headers) > 0 {
headerKeys := make([]string, 0, len(srv.Headers))
for key := range srv.Headers {
headerKeys = append(headerKeys, key)
}
sort.Strings(headerKeys)
b.WriteString("http_headers = {")
for index, key := range headerKeys {
if index > 0 {
b.WriteString(", ")
}
b.WriteString(tomlQuoteString(key))
b.WriteString(" = ")
b.WriteString(tomlQuoteString(srv.Headers[key]))
}
b.WriteString("}\n")
}
writeMCPHeaderMap(&b, "http_headers", srv.Headers)
writeMCPHeaderMap(&b, "env_http_headers", srv.EnvHTTPHeaders)
b.WriteByte('\n')
continue
}
Expand Down Expand Up @@ -127,6 +117,25 @@ func writeCodexMCPConfig(codexHome string, servers map[string]mcpServerConfig) e
return appendConfigTOML(path, b.String())
}

func writeMCPHeaderMap(b *strings.Builder, field string, values map[string]string) {
if len(values) == 0 {
return
}
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
b.WriteString(field + " = {")
for i, key := range keys {
if i > 0 {
b.WriteString(", ")
}
b.WriteString(tomlQuoteString(key) + " = " + tomlQuoteString(values[key]))
}
b.WriteString("}\n")
}

// tomlQuoteString returns a TOML basic-string literal (double-quoted)
// with the documented escape set applied — \" \\ \n \r \t plus
// \uXXXX for control chars. Matches the TOML 1.0 spec for basic strings.
Expand Down
Loading
Loading