From a978f769b173b580799e0100e8fa5c9d2f9851a8 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:19:27 -0700 Subject: [PATCH] Inspired by Cursor: MCP config context variables (${userHome}, ${workspaceFolder}, ...) --- tests/hermes_cli/test_mcp_config.py | 93 +++++++++++++++++++ tools/mcp_tool.py | 59 +++++++++++- .../docs/reference/mcp-config-reference.md | 22 +++++ website/docs/user-guide/features/mcp.md | 6 ++ 4 files changed, 175 insertions(+), 5 deletions(-) diff --git a/tests/hermes_cli/test_mcp_config.py b/tests/hermes_cli/test_mcp_config.py index 6d1b98bc56cf5..776677d0ac566 100644 --- a/tests/hermes_cli/test_mcp_config.py +++ b/tests/hermes_cli/test_mcp_config.py @@ -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) # --------------------------------------------------------------------------- diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index 96d9bc4b4fa87..9ff5a1dea7b99 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -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) diff --git a/website/docs/reference/mcp-config-reference.md b/website/docs/reference/mcp-config-reference.md index f0b96adfd3a64..1a851ae0137df 100644 --- a/website/docs/reference/mcp-config-reference.md +++ b/website/docs/reference/mcp-config-reference.md @@ -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 | diff --git a/website/docs/user-guide/features/mcp.md b/website/docs/user-guide/features/mcp.md index f55e36211508d..66879394b8867 100644 --- a/website/docs/user-guide/features/mcp.md +++ b/website/docs/user-guide/features/mcp.md @@ -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.