Inspired by Cursor: MCP config context variables (${userHome}, ${workspaceFolder}, ...)
This commit is contained in:
parent
52920747e1
commit
a978f769b1
|
|
@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue