mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
feat(mcp): tell a local stdio server the extra writable folders
This commit is contained in:
1 parent
e7ce2480c3
commit
e6f70622b5
8 files changed
+233
-4
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user