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:
esengine committed 2026-09-26 10:25:06 -07:00
1 parent d921615a0c
commit 0a0e1989a7
10 files changed
+221 -7

No files matched your search

+7
View File
@@ -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
+7
View File
@@ -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 授权与
+3
View File
@@ -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
+43 -5
View File
@@ -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)
+65
View File
@@ -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)
}
+1 -1
View File
@@ -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)
}
}
+1 -1
View File
@@ -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)
}
}