Skip to content

Commit 0c016c1

Browse files
committed
Add client example demonstrating pre-execution authorization check
1 parent e8e6484 commit 0c016c1

1 file changed

Lines changed: 158 additions & 0 deletions

File tree

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
"""
2+
Example: MCP client with a pre-execution authorization callback.
3+
4+
This example shows how to build a tool-execution loop that evaluates
5+
every tool call against an authorization policy before execution.
6+
This pattern is essential when connecting agents to MCP servers at
7+
scale, where some tools are safe to run freely and others require
8+
approval or should be blocked entirely.
9+
10+
Run from the repository root:
11+
uv run examples/snippets/clients/client_with_authorization.py
12+
"""
13+
14+
import asyncio
15+
import os
16+
from dataclasses import dataclass
17+
from enum import Enum
18+
from typing import Any
19+
20+
from mcp import ClientSession, StdioServerParameters
21+
from mcp.client.stdio import stdio_client
22+
23+
24+
# ---------------------------------------------------------------------------
25+
# Authorization layer
26+
# ---------------------------------------------------------------------------
27+
28+
class Decision(str, Enum):
29+
ALLOW = "allow"
30+
DENY = "deny"
31+
APPROVAL_REQUIRED = "approval_required"
32+
33+
34+
@dataclass
35+
class AuthRequest:
36+
tool_name: str
37+
arguments: dict[str, Any]
38+
39+
40+
@dataclass
41+
class AuthResult:
42+
decision: Decision
43+
reason: str
44+
45+
46+
def default_policy(request: AuthRequest) -> AuthResult:
47+
"""
48+
A simple policy function that decides whether a tool call should
49+
be allowed, denied, or held for approval.
50+
51+
Replace or extend this function with your own logic — for example,
52+
reading from a policy file, checking roles, or calling an external
53+
authorization service.
54+
"""
55+
# Safe tools (e.g. arithmetic, reading data) are always allowed
56+
if request.tool_name in ["add", "calculator", "get_weather"] or request.tool_name.startswith(("read_", "list_")):
57+
return AuthResult(Decision.ALLOW, "safe tool, allowed by default")
58+
59+
# Destructive tools are always blocked
60+
if request.tool_name.startswith(("delete_", "drop_", "destroy_", "execute_script")):
61+
return AuthResult(Decision.DENY, "destructive tool, blocked by policy")
62+
63+
# Everything else needs a human to approve
64+
return AuthResult(
65+
Decision.APPROVAL_REQUIRED,
66+
"tool has unknown side effects, requires approval before execution",
67+
)
68+
69+
70+
async def authorized_call_tool(
71+
session: ClientSession,
72+
tool_name: str,
73+
arguments: dict[str, Any],
74+
policy=default_policy,
75+
) -> Any:
76+
"""
77+
Evaluate the authorization policy before calling a tool.
78+
Only executes the tool if the decision is ALLOW.
79+
"""
80+
request = AuthRequest(tool_name=tool_name, arguments=arguments)
81+
result = policy(request)
82+
83+
print(f"\n Tool : {tool_name}")
84+
print(f" Decision : {result.decision.value.upper()}")
85+
print(f" Reason : {result.reason}")
86+
87+
if result.decision == Decision.ALLOW:
88+
try:
89+
tool_result = await session.call_tool(tool_name, arguments)
90+
91+
# Safely extract text output if present
92+
output = tool_result.content[0].text if getattr(tool_result, 'content', None) else str(tool_result)
93+
print(f" Result : {output}")
94+
return tool_result
95+
except Exception as e:
96+
print(f" Error : {e}")
97+
return None
98+
99+
if result.decision == Decision.APPROVAL_REQUIRED:
100+
# In a real system this would create a checkpoint and notify a
101+
# human approver. Here we simply surface the requirement.
102+
print(" Action : execution paused — waiting for human approval")
103+
return None
104+
105+
# Decision.DENY
106+
print(" Action : execution blocked")
107+
return None
108+
109+
110+
# ---------------------------------------------------------------------------
111+
# Main
112+
# ---------------------------------------------------------------------------
113+
114+
# We use mcpserver_quickstart to have a reliable server to connect to
115+
server_params = StdioServerParameters(
116+
command="uv",
117+
args=["run", "server", "mcpserver_quickstart", "stdio"],
118+
env={"UV_INDEX": os.environ.get("UV_INDEX", "")},
119+
)
120+
121+
122+
async def run():
123+
async with stdio_client(server_params) as (read, write):
124+
async with ClientSession(read, write) as session:
125+
await session.initialize()
126+
127+
# Discover available tools
128+
tools = await session.list_tools()
129+
print("Available tools:")
130+
for tool in tools.tools:
131+
print(f" - {tool.name}: {tool.description}")
132+
133+
print("\n--- Running authorization checks ---")
134+
135+
# Demonstrate: safe tool -> allowed (add is from mcpserver_quickstart)
136+
await authorized_call_tool(
137+
session,
138+
tool_name="add",
139+
arguments={"a": 5, "b": 3},
140+
)
141+
142+
# Demonstrate: unknown tool -> approval required
143+
await authorized_call_tool(
144+
session,
145+
tool_name="write_file",
146+
arguments={"path": "/tmp/example.txt", "content": "hello"},
147+
)
148+
149+
# Demonstrate: delete tool -> denied
150+
await authorized_call_tool(
151+
session,
152+
tool_name="delete_file",
153+
arguments={"path": "/tmp/example.txt"},
154+
)
155+
156+
157+
if __name__ == "__main__":
158+
asyncio.run(run())

0 commit comments

Comments
 (0)