fix(mcp): restrict list JWTs to mcp:tools/list and default REST arguments to {}

- List-only JWTs (call_type=list_mcp_tools) no longer carry the broad
  mcp:tools/call scope. _build_scope() now emits only mcp:tools/list
  when no tool name is provided, mirroring the existing least-privilege
  rule that tool-call JWTs omit mcp:tools/list.
- REST /tools/call now defaults a missing 'arguments' field to {} so
  execute_mcp_tool() and downstream **arguments / .keys() calls don't
  receive None and crash with TypeError/AttributeError.

Co-authored-by: Yassin Kortam <yassin@berri.ai>
This commit is contained in:
Cursor Agent 2026-05-19 08:22:40 +00:00
parent 93db9e0dad
commit 5499662926
No known key found for this signature in database
38 changed files with 13 additions and 7 deletions

View file

@ -802,7 +802,7 @@ if MCP_AVAILABLE:
},
)
tool_arguments = data.get("arguments")
tool_arguments = data.get("arguments") or {}
proxy_base_llm_response_processor = ProxyBaseLLMRequestProcessing(data=data)
(

View file

@ -612,10 +612,12 @@ class MCPJWTSigner(CustomGuardrail):
When allowed_scopes is configured: join them verbatim.
Otherwise auto-generate minimal, least-privilege scopes:
- Tool call → mcp:tools/call mcp:tools/<name>:call
- No tool → mcp:tools/call mcp:tools/list
- No tool → mcp:tools/list
NOTE: tools/list is intentionally NOT granted on tool-call JWTs to
prevent callers from enumerating tools they didn't ask to use.
Conversely, tools/call is NOT granted on tools/list-only JWTs so an
intercepted list token cannot be replayed to invoke tools.
"""
if self.allowed_scopes is not None:
return " ".join(self.allowed_scopes)
@ -626,7 +628,7 @@ class MCPJWTSigner(CustomGuardrail):
if tool_name:
scopes = ["mcp:tools/call", f"mcp:tools/{tool_name}:call"]
else:
scopes = ["mcp:tools/call", "mcp:tools/list"]
scopes = ["mcp:tools/list"]
return " ".join(scopes)
# ------------------------------------------------------------------

View file

@ -219,7 +219,7 @@ def test_build_claims_scope_with_tool():
def test_build_claims_scope_without_tool():
"""_build_claims() includes mcp:tools/list when no specific tool is called."""
"""_build_claims() emits only mcp:tools/list when no specific tool is called."""
signer = _make_signer()
user_dict = _make_user_api_key_dict()
data: Dict[str, Any] = {}
@ -227,10 +227,11 @@ def test_build_claims_scope_without_tool():
claims = signer._build_claims(user_dict, data)
scopes = set(claims["scope"].split())
assert "mcp:tools/call" in scopes
assert "mcp:tools/list" in scopes
# List-only JWTs must NOT carry mcp:tools/call — least-privilege
assert "mcp:tools/call" not in scopes
# No per-tool call scope when no tool name was given
assert not any(s.endswith(":call") and s != "mcp:tools/call" for s in scopes)
assert not any(s.endswith(":call") for s in scopes)
def test_build_claims_act_fallback_to_litellm_proxy():
@ -372,7 +373,10 @@ async def test_hook_signs_list_mcp_tools():
assert result["extra_headers"]["Authorization"].startswith("Bearer ")
token = result["extra_headers"]["Authorization"].removeprefix("Bearer ")
decoded = _decode_unverified(token)
assert "mcp:tools/list" in decoded["scope"]
scopes = set(decoded["scope"].split())
assert "mcp:tools/list" in scopes
# List-only JWTs must NOT carry mcp:tools/call — least-privilege
assert "mcp:tools/call" not in scopes
@pytest.mark.asyncio