feat(mcp): tell a local stdio server the extra writable folders

This commit is contained in:
esengine committed 2026-10-14 00:40:52 -07:00
1 parent e7ce2480c3
commit e6f70622b5
8 files changed
+233 -4

No files matched your search

+1
View File
@@ -400,6 +400,7 @@ func (b *builder) wireMCP() {
ConfigSource: "workspace_config",
StateHome: b.roots.Home(),
WriterRoots: t.env.writeRoots,
WritableRoots: t.env.writeRoots,
ForbidReadRoots: t.env.forbidReadRoots,
Network: t.env.network,
PackageOwners: pluginspec.PackageOwners(cfg),
@@ -0,0 +1,130 @@
package boot
import (
"bufio"
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/ext/plugin"
)
const metaLogEnv = "REASONIX_TEST_MCP_META_LOG"
// TestMetaRecordingMCPHelper is a stdio MCP server that records the _meta of
// every tools/call it receives, one JSON object per line, in the log its
// environment names.
func TestMetaRecordingMCPHelper(t *testing.T) {
logPath := os.Getenv(metaLogEnv)
if logPath == "" || os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
return
}
in := bufio.NewScanner(os.Stdin)
in.Buffer(make([]byte, 1<<20), 1<<20)
out := json.NewEncoder(os.Stdout)
for in.Scan() {
var req struct {
ID *int `json:"id"`
Method string `json:"method"`
Params struct {
Meta map[string]any `json:"_meta"`
} `json:"params"`
}
if json.Unmarshal(in.Bytes(), &req) != nil || req.ID == nil {
continue
}
var result any
switch req.Method {
case "initialize":
result = map[string]any{"protocolVersion": "2024-11-05", "serverInfo": map[string]any{"name": "meta", "version": "1"}, "capabilities": map[string]any{"tools": map[string]any{}}}
case "tools/list":
result = map[string]any{"tools": []map[string]any{{"name": "echo", "description": "Echo.", "inputSchema": map[string]any{"type": "object"}, "annotations": map[string]any{"readOnlyHint": true}}}}
case "tools/call":
if f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600); err == nil {
_ = json.NewEncoder(f).Encode(req.Params.Meta)
_ = f.Close()
}
result = map[string]any{"content": []map[string]any{{"type": "text", "text": "ok"}}}
}
_ = out.Encode(map[string]any{"jsonrpc": "2.0", "id": *req.ID, "result": result})
}
os.Exit(0)
}
// The folders a person let tools write beside the project reach a local stdio
// server on a real call through Build, and only those: not the project, not the
// temp directory. A session confined to its workspace tells nothing.
func TestEffectLocalMCPServerIsToldTheExtraWritableFolders(t *testing.T) {
var rec *capabilityCallProvider
provider.Register("boot-mcp-writable-roots", func(provider.Config) (provider.Provider, error) { return rec, nil })
for _, tc := range []struct {
name string
workspaceOnly bool
want bool
}{{"configured", false, true}, {"workspace-only", true, false}} {
t.Run(tc.name, func(t *testing.T) {
isolateConfigHome(t)
dir := robustTempDir(t)
extra := robustTempDir(t)
t.Chdir(dir)
rec = &capabilityCallProvider{direct: "mcp__meta__echo"}
writeFile(t, dir, "reasonix.toml", `
default_model = "test-model"
[agent]
system_prompt = "BASE"
[codegraph]
enabled = false
[[providers]]
name = "test-model"
kind = "boot-mcp-writable-roots"
model = "x"
`)
approveWorkspace(t, dir)
logPath := filepath.Join(robustTempDir(t), "meta.jsonl")
ctrl, err := Build(context.Background(), Options{
Sink: event.Discard,
WorkspaceOnly: tc.workspaceOnly,
AdditionalDirs: []string{extra},
ExtraPlugins: []plugin.Spec{{
Name: "meta", Type: "stdio", Command: os.Args[0], Args: []string{"-test.run=^TestMetaRecordingMCPHelper$"},
Env: map[string]string{"GO_WANT_HELPER_PROCESS": "1", metaLogEnv: logPath}, Authorized: true,
}},
})
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
if err := ctrl.Run(context.Background(), "echo"); err != nil {
t.Fatalf("Run: %v", err)
}
raw, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("the server saw no call: %v", err)
}
var meta map[string]any
if err := json.Unmarshal(raw, &meta); err != nil {
t.Fatalf("meta log %q: %v", raw, err)
}
roots, has := meta["io.reasonix/writableRoots"].([]any)
if has != tc.want {
t.Fatalf("writableRoots present = %v (%v), want %v; _meta %v", has, roots, tc.want, meta)
}
if tc.want && (len(roots) != 1 || !sameDir(roots[0].(string), extra)) {
t.Fatalf("writableRoots = %v, want only %s", roots, extra)
}
})
}
}
func sameDir(a, b string) bool {
ra, errA := filepath.EvalSymlinks(a)
rb, errB := filepath.EvalSymlinks(b)
return errA == nil && errB == nil && ra == rb
}
+3
View File
@@ -43,6 +43,9 @@ func resolveMCPSpecs(opts Options, cfg *config.Config, root string, specOptions
if strings.TrimSpace(extra[i].WorkspaceRoot) == "" {
extra[i].WorkspaceRoot = root
}
if len(extra[i].WritableRoots) == 0 {
extra[i].WritableRoots = append([]string(nil), specOptions.WritableRoots...)
}
if extra[i].LaunchManager == nil {
extra[i].LaunchManager = specOptions.LaunchManager
}
+42
View File
@@ -3,6 +3,7 @@ package plugin
import (
"context"
"maps"
"path/filepath"
"strings"
"reasonix/internal/contract/planmode"
@@ -16,6 +17,42 @@ const metaCwdKey = "io.reasonix/cwd"
// now (plan mode), so the server can refuse its own.
const metaReadOnlyKey = "io.reasonix/readOnly"
// metaWritableRootsKey carries the folders the user chose to let tools write
// beside the workspace (allow_write, granted extra roots, --add-dir), so a local
// server can scope its own writes to them. It never carries the workspace, the
// temp directory, a read-forbidden path or anything the host keeps for itself.
const metaWritableRootsKey = "io.reasonix/writableRoots"
// maxMetaWritableRoots bounds what one call tells; the list is configuration a
// person typed, so a long one is a mistake and not something to forward whole.
const maxMetaWritableRoots = 32
// metaWritableRoots is the absolute, de-duplicated folders of roots other than
// the workspace itself, in the order they were configured.
func metaWritableRoots(workspace string, roots []string) []string {
skip := map[string]bool{}
if w := strings.TrimSpace(workspace); w != "" {
skip[filepath.Clean(w)] = true
}
var out []string
for _, root := range roots {
root = strings.TrimSpace(root)
if root == "" || !filepath.IsAbs(root) {
continue
}
root = filepath.Clean(root)
if skip[root] {
continue
}
skip[root] = true
out = append(out, root)
if len(out) == maxMetaWritableRoots {
break
}
}
return out
}
// withHostContext adds what a local stdio server may know about the host to a
// tool call. A remote server is told nothing: the workspace path would leave the
// machine, and a server nobody started here has no use for it. The caller's map
@@ -36,6 +73,11 @@ func (c *Client) withHostContext(ctx context.Context, method string, params any)
if _, set := meta[metaCwdKey]; !set && strings.TrimSpace(c.spec.WorkspaceRoot) != "" {
meta[metaCwdKey] = c.spec.WorkspaceRoot
}
if _, set := meta[metaWritableRootsKey]; !set {
if roots := metaWritableRoots(c.spec.WorkspaceRoot, c.spec.WritableRoots); len(roots) > 0 {
meta[metaWritableRootsKey] = roots
}
}
if _, set := meta[metaReadOnlyKey]; !set {
meta[metaReadOnlyKey] = planmode.Active(ctx)
}
+45
View File
@@ -4,7 +4,9 @@ import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"slices"
"testing"
"reasonix/internal/contract/planmode"
@@ -141,3 +143,46 @@ func TestHostContextSaysWhetherTheHostIsRefusingWrites(t *testing.T) {
t.Errorf("a remote server was told the host's posture: %v", meta)
}
}
// The folders the user chose to let tools write, beside the workspace, are told
// to a local stdio server in one namespaced key; the workspace itself is the
// cwd's business and a remote server is told neither.
func TestHostContextCarriesTheExtraWritableFoldersToLocalServersOnly(t *testing.T) {
spec := Spec{Type: "stdio", WorkspaceRoot: "/work/space", WritableRoots: []string{"/work/space", "/data/shared", "/data/shared", "", "relative/dir", "/mnt/out"}}
got := metaOf(t, (&Client{spec: spec}).withHostContext(context.Background(), "tools/call", map[string]any{"name": "t"}))[metaWritableRootsKey]
want := []string{"/data/shared", "/mnt/out"}
roots, _ := got.([]string)
if !slices.Equal(roots, want) {
t.Fatalf("_meta[%s] = %v, want %v: workspace, duplicates, empties and relative paths stay out", metaWritableRootsKey, got, want)
}
spec.Type = "http"
if meta := metaOf(t, (&Client{spec: spec}).withHostContext(context.Background(), "tools/call", map[string]any{"name": "t"})); meta != nil {
t.Fatalf("a remote server was told %v", meta)
}
}
func TestHostContextSaysNothingOfWritableFoldersWhenThereAreNone(t *testing.T) {
client := &Client{spec: Spec{WorkspaceRoot: "/w", WritableRoots: []string{"/w"}}}
if _, has := metaOf(t, client.withHostContext(context.Background(), "tools/call", map[string]any{"name": "t"}))[metaWritableRootsKey]; has {
t.Fatal("an empty list was sent as a key")
}
}
func TestHostContextKeepsAWritableRootsKeyTheCallAlreadyCarries(t *testing.T) {
client := &Client{spec: Spec{WritableRoots: []string{"/data"}}}
meta := metaOf(t, client.withHostContext(context.Background(), "tools/call", map[string]any{"name": "t", "_meta": map[string]any{metaWritableRootsKey: "server-set"}}))
if meta[metaWritableRootsKey] != "server-set" {
t.Fatalf("overwrote the call's own value: %v", meta)
}
}
func TestHostContextCapsHowManyWritableFoldersAreTold(t *testing.T) {
var many []string
for i := range 100 {
many = append(many, fmt.Sprintf("/data/%03d", i))
}
got, _ := metaOf(t, (&Client{spec: Spec{WritableRoots: many}}).withHostContext(context.Background(), "tools/call", map[string]any{"name": "t"}))[metaWritableRootsKey].([]string)
if len(got) != maxMetaWritableRoots || got[0] != "/data/000" {
t.Fatalf("told %d folders, want the first %d in order", len(got), maxMetaWritableRoots)
}
}
+3
View File
@@ -105,6 +105,9 @@ type Spec struct {
// stdio servers keep inheriting Reasonix's cwd while still receiving the
// explicit workspace root when they ask for roots/list.
WorkspaceRoot string
// WritableRoots are the folders the session lets tools write, the workspace
// among them. A local stdio server is told the others per call; runtime-only.
WritableRoots []string
// Stderr receives byte-count summaries because raw child logs can carry credentials.
Stderr io.Writer
// LaunchManager owns exact project launch grants and mutable launcher locks.
+7 -4
View File
@@ -26,10 +26,12 @@ type Options struct {
ConfigSource string
StateHome string
WriterRoots []string
ForbidReadRoots []string
Network bool
PackageOwners map[string]string
OAuthHTTPClient *http.Client
// WritableRoots are what the session lets tools write; a local server is told them.
WritableRoots []string
ForbidReadRoots []string
Network bool
PackageOwners map[string]string
OAuthHTTPClient *http.Client
}
// ForRoot maps entries against a workspace root without runtime policy.
@@ -71,6 +73,7 @@ func FromEntry(e config.PluginEntry, workspaceRoot string, opts Options) plugin.
CallTimeout: secondsDuration(e.CallTimeoutSeconds),
ToolTimeouts: toolTimeoutDurations(e.ToolTimeoutSeconds),
WorkspaceRoot: strings.TrimSpace(workspaceRoot),
WritableRoots: append([]string(nil), opts.WritableRoots...),
LaunchManager: opts.LaunchManager,
ConfigSource: configSource,
Authorized: e.Source.UserAuthorized(),
+2
View File
@@ -0,0 +1,2 @@
- 本机 stdio MCP 服务器现在会在 `_meta["io.reasonix/writableRoots"]` 里收到你额外允许写入的目录;远程服务器不会收到。关联 #13053。
- Local stdio MCP servers now receive the extra folders you allowed tools to write in `_meta["io.reasonix/writableRoots"]`; remote ones do not. Refs #13053.