mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
feat(config): expand the workspace root in MCP server entries
A global [[plugins]] entry had no way to name the workspace it runs for:
${VAR} expansion read only the process environment and the project
.env. Servers that key on the project path in a header or argument
(IntelliJ's IJ_MCP_SERVER_PROJECT_PATH) therefore needed one entry per
project. Installed plugin packages already got ${CLAUDE_PROJECT_DIR};
user and .mcp.json entries did not.
ExpandedPluginForRoot resolves ${REASONIX_WORKSPACE_ROOT} and
${CLAUDE_PROJECT_DIR} to the workspace, and both places that turn an
entry into a spec call it with the root they already hold:
pluginspec.FromEntry (session boot, capdiag, mcp oauth) and the
controller's mcpIdentitySpec (live add, install, reconnect). The host
owns these values, so they outrank a process or .env variable of the
same name; a hook's child process inherits its parent session's
CLAUDE_PROJECT_DIR. Config path fields (sandbox roots, skill paths,
proxy) keep plain environment expansion. WorkspaceRootValue is the one
canonical form (absolute, cleaned, symlinks as opened), also used for
the plugin-package substitution.
Tests (the first three fail before the fix and pass after):
- TestAddMCPServerExpandsWorkspaceRootInLiveHeaders: live AddMCPServer
against an httptest MCP server. Before: live headers = ("",
"/inherited/from/a/parent/hook").
- TestLoadForRootExpandsWorkspaceRootInPluginEntries: two workspaces,
one global http entry and one .mcp.json entry, with a conflicting
CLAUDE_PROJECT_DIR in process env and .env. Before: header = "".
- TestPluginSpecsForRootExpandWorkspaceRoot covers the boot mapping, and
TestWorkspaceRootVarsStayOutOfConfigPathExpansion keeps sandbox paths
out of scope.
This commit is contained in:
1 parent
d921615a0c
commit
0a0e1989a7
10 files changed
+221
-7
No files matched your search
@@ -916,6 +916,13 @@ Reasonix is an MCP client. A `[[plugins]]` entry's `type` selects the transport:
|
||||
of the file); `sse` connects to servers that still use the legacy persistent
|
||||
GET + announced POST endpoint transport.
|
||||
|
||||
`${REASONIX_WORKSPACE_ROOT}` (or `${CLAUDE_PROJECT_DIR}`) expands to the current
|
||||
workspace's absolute path, so one global entry can name the project:
|
||||
|
||||
```toml
|
||||
headers = { IJ_MCP_SERVER_PROJECT_PATH = "${REASONIX_WORKSPACE_ROOT}" }
|
||||
```
|
||||
|
||||
For a remote HTTP server without a static `Authorization` header, an
|
||||
authentication challenge is shown as **Sign in**. Run
|
||||
`reasonix mcp auth <name>` in the CLI, or click **Sign in** for that server in
|
||||
|
||||
@@ -725,6 +725,13 @@ Reasonix 是一个 MCP 客户端。`[[plugins]]` 的 `type` 选择传输:`stdi
|
||||
`headers`(`${VAR}` / `${VAR:-default}` 从环境展开,密钥不入文件)。
|
||||
`sse` 则兼容仍使用持久 GET 与 server 公布 POST endpoint 的旧版远程 server。
|
||||
|
||||
`${REASONIX_WORKSPACE_ROOT}`(或 `${CLAUDE_PROJECT_DIR}`)展开为当前工作区的绝对路径,
|
||||
一条全局配置即可指明项目:
|
||||
|
||||
```toml
|
||||
headers = { IJ_MCP_SERVER_PROJECT_PATH = "${REASONIX_WORKSPACE_ROOT}" }
|
||||
```
|
||||
|
||||
远程 HTTP server 未配置静态 `Authorization` header 时,认证要求会显示为 **登录**。
|
||||
CLI 可运行 `reasonix mcp auth <name>`,桌面端则在 MCP 面板点击该 server 的 **登录**。
|
||||
Reasonix 会执行 OAuth 元数据发现、动态客户端注册、PKCE S256 授权与
|
||||
|
||||
@@ -159,6 +159,9 @@ interface (`call` / `notify` / `close`) abstracts that, so the MCP-level logic
|
||||
cannot leak.
|
||||
- `${VAR}` / `${VAR:-default}` are expanded in `command`, `args`, `env`, `url`,
|
||||
and `headers` so secrets come from the environment, not the config file.
|
||||
- `${REASONIX_WORKSPACE_ROOT}` and `${CLAUDE_PROJECT_DIR}` expand, in those
|
||||
fields only, to the server's absolute workspace path and outrank an
|
||||
environment or `.env` variable of the same name.
|
||||
- Lifecycle: `initialize` → `notifications/initialized` → `tools/list`;
|
||||
invocation via `tools/call {name, arguments}`.
|
||||
- When a workspace root exists, initialize advertises `roots` and transports
|
||||
|
||||
@@ -2,7 +2,9 @@ package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -59,11 +61,47 @@ func (c *Config) expandVars(s string) string {
|
||||
return expandVarsWithLookup(s, scopedEnvLookup(c.expansionEnv))
|
||||
}
|
||||
|
||||
// ExpandedPlugin returns a copy of e with ${VAR} references expanded across the
|
||||
// command, args, env values, url, and header values — the fields Claude Code
|
||||
// also expands. The entry itself is left untouched.
|
||||
func (e PluginEntry) ExpandedPlugin() PluginEntry {
|
||||
lookup := scopedEnvLookup(e.expansionEnv)
|
||||
// workspaceRootVars name the workspace a server runs for. The host owns their
|
||||
// value, so it outranks a process variable of the same name: a hook's child
|
||||
// process inherits its parent session's CLAUDE_PROJECT_DIR.
|
||||
var workspaceRootVars = []string{"REASONIX_WORKSPACE_ROOT", "CLAUDE_PROJECT_DIR"}
|
||||
|
||||
// WorkspaceRootValue is the form of root a plugin entry sees for the workspace
|
||||
// variables: absolute and cleaned, symlinks left as the workspace was opened.
|
||||
func WorkspaceRootValue(root string) string {
|
||||
root = strings.TrimSpace(root)
|
||||
if root == "" {
|
||||
return ""
|
||||
}
|
||||
abs, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return filepath.Clean(root)
|
||||
}
|
||||
return abs
|
||||
}
|
||||
|
||||
func workspaceEnvLookup(scoped map[string]string, root string) envLookup {
|
||||
base := scopedEnvLookup(scoped)
|
||||
root = WorkspaceRootValue(root)
|
||||
if root == "" {
|
||||
return base
|
||||
}
|
||||
return func(name string) (string, bool) {
|
||||
if slices.Contains(workspaceRootVars, name) {
|
||||
return root, true
|
||||
}
|
||||
return base(name)
|
||||
}
|
||||
}
|
||||
|
||||
// ExpandedPlugin is ExpandedPluginForRoot with no workspace.
|
||||
func (e PluginEntry) ExpandedPlugin() PluginEntry { return e.ExpandedPluginForRoot("") }
|
||||
|
||||
// ExpandedPluginForRoot returns a copy of e with ${VAR} references expanded
|
||||
// across command, args, env values, url, and header values. With a root,
|
||||
// ${REASONIX_WORKSPACE_ROOT} and ${CLAUDE_PROJECT_DIR} name that workspace.
|
||||
func (e PluginEntry) ExpandedPluginForRoot(root string) PluginEntry {
|
||||
lookup := workspaceEnvLookup(e.expansionEnv, root)
|
||||
out := e
|
||||
out.Command = expandVarsWithLookup(e.Command, lookup)
|
||||
out.URL = expandVarsWithLookup(e.URL, lookup)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
@@ -107,3 +108,67 @@ func TestWriteRootsForRootExpandsMavenAllowWrite(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadForRootExpandsWorkspaceRootInPluginEntries(t *testing.T) {
|
||||
_, userConfig, _ := legacyHome(t)
|
||||
t.Setenv("CLAUDE_PROJECT_DIR", "/inherited/from/a/parent/hook")
|
||||
if err := os.MkdirAll(filepath.Dir(userConfig), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(userConfig, []byte(`
|
||||
[[plugins]]
|
||||
name = "idea"
|
||||
type = "http"
|
||||
url = "http://127.0.0.1:64342/stream"
|
||||
headers = { IJ_MCP_SERVER_PROJECT_PATH = "${REASONIX_WORKSPACE_ROOT}" }
|
||||
`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, root := range []string{testenv.TempDir(t), testenv.TempDir(t)} {
|
||||
if err := os.WriteFile(filepath.Join(root, mcpJSONFile), []byte(`{
|
||||
"mcpServers": {
|
||||
"local": { "command": "server", "args": ["--project", "${CLAUDE_PROJECT_DIR}"] }
|
||||
}
|
||||
}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, ".env"), []byte("CLAUDE_PROJECT_DIR=/from/dotenv\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := LoadForRoot(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
idea, ok := pluginEntryByName(cfg.Plugins, "idea")
|
||||
if !ok {
|
||||
t.Fatalf("global plugin missing: %+v", cfg.Plugins)
|
||||
}
|
||||
if got := idea.ExpandedPluginForRoot(root).Headers["IJ_MCP_SERVER_PROJECT_PATH"]; got != want {
|
||||
t.Fatalf("header = %q, want workspace root %q", got, want)
|
||||
}
|
||||
local, ok := pluginEntryByName(cfg.Plugins, "local")
|
||||
if !ok {
|
||||
t.Fatalf(".mcp.json plugin missing: %+v", cfg.Plugins)
|
||||
}
|
||||
if got := local.ExpandedPluginForRoot(root).Args[1]; got != want {
|
||||
t.Fatalf("arg = %q, want workspace root %q", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceRootVarsStayOutOfConfigPathExpansion(t *testing.T) {
|
||||
t.Setenv("REASONIX_WORKSPACE_ROOT", "")
|
||||
root := testenv.TempDir(t)
|
||||
cfg := Default()
|
||||
cfg.Sandbox.ForbidRead = []string{"${REASONIX_WORKSPACE_ROOT}/secret"}
|
||||
got := cfg.ForbidReadRootsForRoot(root)
|
||||
if len(got) != 1 || got[0] == filepath.Join(root, "secret") {
|
||||
t.Fatalf("ForbidReadRootsForRoot = %v, want the variable left to the environment", got)
|
||||
}
|
||||
}
|
||||
@@ -191,6 +191,7 @@ func pluginPackageCommand(root, command string) string {
|
||||
|
||||
func pluginPackageEnv(installed InstalledPackage, workspaceRoot string, env map[string]string) map[string]string {
|
||||
root := installed.Root
|
||||
workspaceRoot = WorkspaceRootValue(workspaceRoot)
|
||||
out := pluginPackageWorkspaceMap(root, workspaceRoot, env)
|
||||
if out == nil {
|
||||
out = map[string]string{}
|
||||
@@ -208,6 +209,7 @@ func pluginPackageEnv(installed InstalledPackage, workspaceRoot string, env map[
|
||||
|
||||
func pluginPackageWorkspaceValue(root, workspaceRoot, value string) string {
|
||||
value = pluginPackageValue(root, value)
|
||||
workspaceRoot = WorkspaceRootValue(workspaceRoot)
|
||||
value = expandPluginPathVar(value, "${CLAUDE_PROJECT_DIR}", workspaceRoot)
|
||||
return expandPluginPathVar(value, "$CLAUDE_PROJECT_DIR", workspaceRoot)
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ func ForRootWithOptions(entries []config.PluginEntry, workspaceRoot string, opts
|
||||
|
||||
// FromEntry maps one configured entry, including MCP isolation.
|
||||
func FromEntry(e config.PluginEntry, workspaceRoot string, opts Options) plugin.Spec {
|
||||
e = e.ExpandedPlugin() // resolve ${VAR} / ${VAR:-default} from the environment
|
||||
e = e.ExpandedPluginForRoot(workspaceRoot)
|
||||
configSource := strings.TrimSpace(string(e.Source))
|
||||
if configSource == "" {
|
||||
configSource = opts.ConfigSource
|
||||
|
||||
@@ -155,3 +155,16 @@ func TestPluginSpecsForRootDoesNotPinHTTPCodeGraph(t *testing.T) {
|
||||
t.Fatalf("http codegraph WorkspaceRoot = %q, want /workspace", specs[0].WorkspaceRoot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginSpecsForRootExpandWorkspaceRoot(t *testing.T) {
|
||||
t.Setenv("CLAUDE_PROJECT_DIR", "/inherited/from/a/parent/hook")
|
||||
root := t.TempDir()
|
||||
specs := ForRoot([]config.PluginEntry{{
|
||||
Name: "idea", Type: "http", URL: "http://127.0.0.1:64342/stream",
|
||||
Headers: map[string]string{"X-Project": "${REASONIX_WORKSPACE_ROOT}", "X-Claude": "${CLAUDE_PROJECT_DIR}"},
|
||||
}}, root)
|
||||
want := config.WorkspaceRootValue(root)
|
||||
if got := specs[0].Headers; got["X-Project"] != want || got["X-Claude"] != want {
|
||||
t.Fatalf("headers = %v, want both %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -312,7 +312,7 @@ func (c *Controller) ClearMCPServerOverride(name string, scope config.Activation
|
||||
// about its identity, so the schema cache — which is keyed on exactly these
|
||||
// fields — can be reached from a project the session is not pointed at.
|
||||
func mcpIdentitySpec(e config.PluginEntry, root string) plugin.Spec {
|
||||
exp := e.ExpandedPlugin()
|
||||
exp := e.ExpandedPluginForRoot(root)
|
||||
spec := plugin.ApplyKnownOverrides(plugin.Spec{
|
||||
Name: exp.Name,
|
||||
Type: exp.Type,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package control
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/contract/tool"
|
||||
"reasonix/internal/ext/plugin"
|
||||
)
|
||||
|
||||
func TestAddMCPServerExpandsWorkspaceRootInLiveHeaders(t *testing.T) {
|
||||
isolateControlConfigHome(t)
|
||||
t.Setenv("CLAUDE_PROJECT_DIR", "/inherited/from/a/parent/hook")
|
||||
workspace := testenv.TempDir(t)
|
||||
want, err := filepath.Abs(workspace)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
var project, claude string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
ID json.RawMessage `json:"id"`
|
||||
Method string `json:"method"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Method == "initialize" {
|
||||
mu.Lock()
|
||||
project, claude = r.Header.Get("X-Project"), r.Header.Get("X-Claude")
|
||||
mu.Unlock()
|
||||
}
|
||||
if len(req.ID) == 0 || string(req.ID) == "null" {
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
return
|
||||
}
|
||||
result := any(map[string]any{})
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
result = map[string]any{
|
||||
"protocolVersion": "2025-03-26",
|
||||
"serverInfo": map[string]any{"name": "idea", "version": "1"},
|
||||
}
|
||||
case "tools/list":
|
||||
result = map[string]any{"tools": []map[string]any{{
|
||||
"name": "search", "description": "Search.", "inputSchema": map[string]any{"type": "object"},
|
||||
}}}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": req.ID, "result": result})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
host := plugin.NewHost()
|
||||
defer host.Close()
|
||||
ctrl := New(Options{Host: host, Registry: tool.NewRegistry(), PluginCtx: context.Background(), WorkspaceRoot: workspace})
|
||||
entry := config.PluginEntry{Name: "idea", Type: "http", URL: server.URL, Headers: map[string]string{
|
||||
"X-Project": "${REASONIX_WORKSPACE_ROOT}",
|
||||
"X-Claude": "${CLAUDE_PROJECT_DIR}",
|
||||
}}
|
||||
if n, err := ctrl.AddMCPServer(entry); err != nil || n != 1 {
|
||||
t.Fatalf("AddMCPServer(idea) = (%d, %v), want one connected tool", n, err)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if project != want || claude != want {
|
||||
t.Fatalf("live headers = (%q, %q), want the workspace root %q for both", project, claude, want)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user