Inspired by Cursor: MCP config context variables (${userHome}, ${workspaceFolder}, ...)

This commit is contained in:
Teknium 2026-08-06 21:19:27 -07:00
parent 52920747e1
commit a978f769b1
4 changed files with 175 additions and 5 deletions

View File

@ -364,6 +364,99 @@ class TestEnvVarInterpolation:
assert _env_ref_name(" env:API_KEY ") == "API_KEY"
class TestContextVarInterpolation:
"""Cursor-style context variables: ${userHome}, ${workspaceFolder},
${workspaceFolderBasename}, ${pathSeparator}, ${/}."""
def test_user_home(self):
import os
from tools.mcp_tool import _interpolate_env_vars
assert _interpolate_env_vars("${userHome}") == os.path.expanduser("~")
def test_path_separator_and_slash_shorthand(self):
import os
from tools.mcp_tool import _interpolate_env_vars
assert _interpolate_env_vars("${pathSeparator}") == os.sep
assert _interpolate_env_vars("${/}") == os.sep
def test_workspace_folder_and_basename(self, monkeypatch):
import tools.mcp_tool as mcp_tool
monkeypatch.setattr(
mcp_tool, "_workspace_folder", lambda: "/srv/projects/myapp"
)
assert mcp_tool._interpolate_env_vars("${workspaceFolder}") == (
"/srv/projects/myapp"
)
assert mcp_tool._interpolate_env_vars(
"${workspaceFolderBasename}"
) == "myapp"
def test_workspace_folder_falls_back_to_cwd(self, monkeypatch):
import os
import tools.file_tools as file_tools
from tools.mcp_tool import _workspace_folder
monkeypatch.setattr(
file_tools, "_authoritative_workspace_root", lambda task_id="default": None
)
assert _workspace_folder() == os.getcwd()
def test_mixed_string_with_env_and_context_vars(self, monkeypatch):
import os
import tools.mcp_tool as mcp_tool
monkeypatch.setenv("MY_TOKEN", "tok-1")
monkeypatch.setattr(mcp_tool, "_workspace_folder", lambda: "/ws/app")
result = mcp_tool._interpolate_env_vars(
"${userHome}${/}.cache${/}${workspaceFolderBasename}-${MY_TOKEN}"
)
home = os.path.expanduser("~")
assert result == f"{home}{os.sep}.cache{os.sep}app-tok-1"
def test_context_names_are_case_sensitive(self, monkeypatch):
"""${USERHOME} is NOT a context var — it keeps env-var semantics
(literal placeholder when unset)."""
monkeypatch.delenv("USERHOME", raising=False)
from tools.mcp_tool import _interpolate_env_vars
assert _interpolate_env_vars("${USERHOME}") == "${USERHOME}"
def test_unknown_ref_keeps_literal_placeholder(self, monkeypatch):
monkeypatch.delenv("NOT_A_REAL_VAR_XYZ", raising=False)
from tools.mcp_tool import _interpolate_env_vars
assert _interpolate_env_vars("${NOT_A_REAL_VAR_XYZ}") == (
"${NOT_A_REAL_VAR_XYZ}"
)
def test_context_vars_in_nested_config(self, monkeypatch):
import os
import tools.mcp_tool as mcp_tool
monkeypatch.setattr(mcp_tool, "_workspace_folder", lambda: "/ws/app")
cfg = {
"command": "npx",
"args": ["-y", "server-fs", "${workspaceFolder}"],
"cwd": "${workspaceFolder}",
"env": {"CACHE": "${userHome}${/}.cache"},
"headers": {"X-Ws": "${workspaceFolderBasename}"},
}
out = mcp_tool._interpolate_env_vars(cfg)
home = os.path.expanduser("~")
assert out["args"][2] == "/ws/app"
assert out["cwd"] == "/ws/app"
assert out["env"]["CACHE"] == f"{home}{os.sep}.cache"
assert out["headers"]["X-Ws"] == "app"
# ---------------------------------------------------------------------------
# Tests: probe-path env resolution (#37792)
# ---------------------------------------------------------------------------

View File

@ -444,6 +444,48 @@ def _env_ref_name(ref: str) -> str:
return ref
def _workspace_folder() -> str:
"""Best-effort absolute workspace root for ``${workspaceFolder}``.
Resolution order:
1. ``tools.file_tools._authoritative_workspace_root()`` the session's
recorded terminal cwd, a registered task/session cwd override, or a
sentinel-free absolute ``$TERMINAL_CWD`` (in that order).
2. ``os.getcwd()`` as the final fallback when no session anchor exists.
"""
try:
from tools.file_tools import _authoritative_workspace_root
root = _authoritative_workspace_root()
if root:
return root
except Exception:
pass
return os.getcwd()
def _context_var_value(ref: str) -> Optional[str]:
"""Resolve Cursor-style context variables in ``${...}`` references.
Supports the case-sensitive names Cursor's ``mcp.json`` interpolation
understands beyond env vars: ``${userHome}``, ``${workspaceFolder}``,
``${workspaceFolderBasename}``, ``${pathSeparator}`` and its ``${/}``
shorthand. Returns ``None`` for anything else so unknown references keep
the existing env-var lookup semantics.
"""
if ref == "userHome":
return os.path.expanduser("~")
if ref == "workspaceFolder":
return _workspace_folder()
if ref == "workspaceFolderBasename":
root = _workspace_folder()
return os.path.basename(root.rstrip("/\\")) or root
if ref in ("pathSeparator", "/"):
return os.sep
return None
# ---------------------------------------------------------------------------
# Security helpers
# ---------------------------------------------------------------------------
@ -4783,16 +4825,23 @@ def _interpolate_env_vars(value):
Both ``${VAR}`` and Cursor-style ``${env:VAR}`` are accepted the
``env:`` prefix is stripped so a doc copied from a Cursor / Claude MCP
config resolves the same secret. Resolves from the active profile's secret
scope when multiplexing is on (so an MCP server config's ``${API_KEY}``
picks up the routed profile's value, not the process-global ``os.environ``
which may hold another profile's), falling back to ``os.environ``
otherwise. Unset vars keep the literal placeholder, as before.
config resolves the same secret. Cursor's context variables are also
supported (case-sensitive): ``${userHome}``, ``${workspaceFolder}``,
``${workspaceFolderBasename}``, ``${pathSeparator}`` and ``${/}`` see
:func:`_context_var_value` / :func:`_workspace_folder` for resolution.
Env refs resolve from the active profile's secret scope when multiplexing
is on (so an MCP server config's ``${API_KEY}`` picks up the routed
profile's value, not the process-global ``os.environ`` which may hold
another profile's), falling back to ``os.environ`` otherwise. Unset vars
keep the literal placeholder, as before.
"""
from agent.secret_scope import get_secret as _get_secret
if isinstance(value, str):
def _replace(m):
ctx = _context_var_value(m.group(1).strip())
if ctx is not None:
return ctx
name = _env_ref_name(m.group(1))
return _get_secret(name, m.group(0)) or m.group(0)
return _ENV_VAR_PATTERN.sub(_replace, value)

View File

@ -83,6 +83,28 @@ mcp_servers:
Values resolve from the active profile's secret scope (falling back to the process environment), so put the secret in `~/.hermes/.env`. An unset variable keeps its literal placeholder.
### Context variables
Beyond env vars, the Cursor-style context variables are interpolated too (names are case-sensitive):
| Variable | Resolves to |
|---|---|
| `${userHome}` | The current user's home directory |
| `${workspaceFolder}` | The session workspace root (the session's terminal cwd when known, else the process cwd) |
| `${workspaceFolderBasename}` | The basename of `${workspaceFolder}` |
| `${pathSeparator}` / `${/}` | The OS path separator (`os.sep`) |
```yaml
mcp_servers:
filesystem:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-filesystem", "${workspaceFolder}"]
env:
CACHE_DIR: "${userHome}${/}.cache${/}mcp"
```
Any other `${...}` reference falls through to the env-var lookup above.
## `tools` policy keys
| Key | Type | Meaning |

View File

@ -154,6 +154,12 @@ from environment variables (which include everything in `~/.hermes/.env`).
This is useful when a catalog entry wants to reference a value the user
configured elsewhere — e.g. `${HOME}/foo` or `${MY_PROVIDER_TOKEN}`.
Cursor-style context variables are also substituted (case-sensitive):
`${userHome}` (home directory), `${workspaceFolder}` (session workspace
root), `${workspaceFolderBasename}`, and `${pathSeparator}` / `${/}`
(the OS path separator). See the
[MCP config reference](/docs/reference/mcp-config-reference) for details.
Note this is distinct from `${INSTALL_DIR}` in catalog manifests, which is
substituted at install-time with the path the catalog cloned the entry's
repo into.