chore: import upstream snapshot with attribution
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
"""cd to the `examples/snippets` directory and run:
|
||||
uv run completion-client
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from mcp_types import PromptReference, ResourceTemplateReference
|
||||
|
||||
from mcp import ClientSession, StdioServerParameters
|
||||
from mcp.client.stdio import stdio_client
|
||||
|
||||
# Create server parameters for stdio connection
|
||||
server_params = StdioServerParameters(
|
||||
command="uv", # Using uv to run the server
|
||||
args=["run", "server", "completion", "stdio"], # Server with completion support
|
||||
env={"UV_INDEX": os.environ.get("UV_INDEX", "")},
|
||||
)
|
||||
|
||||
|
||||
async def run():
|
||||
"""Run the completion client example."""
|
||||
async with stdio_client(server_params) as (read, write):
|
||||
async with ClientSession(read, write) as session:
|
||||
# Initialize the connection
|
||||
await session.initialize()
|
||||
|
||||
# List available resource templates
|
||||
templates = await session.list_resource_templates()
|
||||
print("Available resource templates:")
|
||||
for template in templates.resource_templates:
|
||||
print(f" - {template.uri_template}")
|
||||
|
||||
# List available prompts
|
||||
prompts = await session.list_prompts()
|
||||
print("\nAvailable prompts:")
|
||||
for prompt in prompts.prompts:
|
||||
print(f" - {prompt.name}")
|
||||
|
||||
# Complete resource template arguments
|
||||
if templates.resource_templates:
|
||||
template = templates.resource_templates[0]
|
||||
print(f"\nCompleting arguments for resource template: {template.uri_template}")
|
||||
|
||||
# Complete without context
|
||||
result = await session.complete(
|
||||
ref=ResourceTemplateReference(type="ref/resource", uri=template.uri_template),
|
||||
argument={"name": "owner", "value": "model"},
|
||||
)
|
||||
print(f"Completions for 'owner' starting with 'model': {result.completion.values}")
|
||||
|
||||
# Complete with context - repo suggestions based on owner
|
||||
result = await session.complete(
|
||||
ref=ResourceTemplateReference(type="ref/resource", uri=template.uri_template),
|
||||
argument={"name": "repo", "value": ""},
|
||||
context_arguments={"owner": "modelcontextprotocol"},
|
||||
)
|
||||
print(f"Completions for 'repo' with owner='modelcontextprotocol': {result.completion.values}")
|
||||
|
||||
# Complete prompt arguments
|
||||
if prompts.prompts:
|
||||
prompt_name = prompts.prompts[0].name
|
||||
print(f"\nCompleting arguments for prompt: {prompt_name}")
|
||||
|
||||
result = await session.complete(
|
||||
ref=PromptReference(type="ref/prompt", name=prompt_name),
|
||||
argument={"name": "style", "value": ""},
|
||||
)
|
||||
print(f"Completions for 'style' argument: {result.completion.values}")
|
||||
|
||||
|
||||
def main():
|
||||
"""Entry point for the completion client."""
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,66 @@
|
||||
"""cd to the `examples/snippets` directory and run:
|
||||
uv run display-utilities-client
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from mcp import ClientSession, StdioServerParameters
|
||||
from mcp.client.stdio import stdio_client
|
||||
from mcp.shared.metadata_utils import get_display_name
|
||||
|
||||
# Create server parameters for stdio connection
|
||||
server_params = StdioServerParameters(
|
||||
command="uv", # Using uv to run the server
|
||||
args=["run", "server", "mcpserver_quickstart", "stdio"],
|
||||
env={"UV_INDEX": os.environ.get("UV_INDEX", "")},
|
||||
)
|
||||
|
||||
|
||||
async def display_tools(session: ClientSession):
|
||||
"""Display available tools with human-readable names"""
|
||||
tools_response = await session.list_tools()
|
||||
|
||||
for tool in tools_response.tools:
|
||||
# get_display_name() returns the title if available, otherwise the name
|
||||
display_name = get_display_name(tool)
|
||||
print(f"Tool: {display_name}")
|
||||
if tool.description:
|
||||
print(f" {tool.description}")
|
||||
|
||||
|
||||
async def display_resources(session: ClientSession):
|
||||
"""Display available resources with human-readable names"""
|
||||
resources_response = await session.list_resources()
|
||||
|
||||
for resource in resources_response.resources:
|
||||
display_name = get_display_name(resource)
|
||||
print(f"Resource: {display_name} ({resource.uri})")
|
||||
|
||||
templates_response = await session.list_resource_templates()
|
||||
for template in templates_response.resource_templates:
|
||||
display_name = get_display_name(template)
|
||||
print(f"Resource Template: {display_name}")
|
||||
|
||||
|
||||
async def run():
|
||||
"""Run the display utilities example."""
|
||||
async with stdio_client(server_params) as (read, write):
|
||||
async with ClientSession(read, write) as session:
|
||||
# Initialize the connection
|
||||
await session.initialize()
|
||||
|
||||
print("=== Available Tools ===")
|
||||
await display_tools(session)
|
||||
|
||||
print("\n=== Available Resources ===")
|
||||
await display_resources(session)
|
||||
|
||||
|
||||
def main():
|
||||
"""Entry point for the display utilities client."""
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Client side of SEP-990 (enterprise IdP policy controls).
|
||||
|
||||
`IdentityAssertionOAuthProvider` presents an Identity Assertion Authorization Grant (ID-JAG) issued
|
||||
by the enterprise IdP to the MCP authorization server using the RFC 7523 jwt-bearer grant
|
||||
(`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, ID-JAG as `assertion`), and receives an
|
||||
MCP access token. No browser redirect or dynamic client registration is involved.
|
||||
|
||||
Obtaining the ID-JAG (logging into the IdP and the leg-1 exchange against it) is deployment-specific
|
||||
and out of scope for the SDK; supply it through the `assertion_provider` callback. The callback
|
||||
receives the authorization server's issuer (the ID-JAG `aud`) and the MCP server's resource
|
||||
identifier (the ID-JAG `resource` claim). SEP-990 requires a confidential client, so a client secret
|
||||
is mandatory, and `issuer` is the authorization server the credentials are provisioned for - the
|
||||
provider fetches metadata from that issuer's well-known and never asks the resource server which AS
|
||||
to use.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
|
||||
from mcp import ClientSession
|
||||
from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
|
||||
|
||||
|
||||
class InMemoryTokenStorage:
|
||||
"""Demo in-memory token storage."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.tokens: OAuthToken | None = None
|
||||
self.client_info: OAuthClientInformationFull | None = None
|
||||
|
||||
async def get_tokens(self) -> OAuthToken | None:
|
||||
return self.tokens
|
||||
|
||||
async def set_tokens(self, tokens: OAuthToken) -> None:
|
||||
self.tokens = tokens
|
||||
|
||||
async def get_client_info(self) -> OAuthClientInformationFull | None:
|
||||
return self.client_info
|
||||
|
||||
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
|
||||
self.client_info = client_info
|
||||
|
||||
|
||||
async def fetch_id_jag(audience: str, resource: str) -> str:
|
||||
"""Return the ID-JAG to present.
|
||||
|
||||
`audience` is the MCP authorization server's issuer (the ID-JAG `aud` claim); `resource` is the
|
||||
MCP server's RFC 9728 identifier (the ID-JAG `resource` claim, which the AS audience-restricts
|
||||
the issued token against). In production this exchanges the user's IdP ID token for an ID-JAG
|
||||
against the enterprise identity provider.
|
||||
"""
|
||||
raise NotImplementedError("Obtain the ID-JAG from your enterprise identity provider")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
oauth_auth = IdentityAssertionOAuthProvider(
|
||||
server_url="http://localhost:8001/mcp",
|
||||
storage=InMemoryTokenStorage(),
|
||||
client_id="enterprise-mcp-client",
|
||||
client_secret="enterprise-mcp-secret",
|
||||
issuer="http://localhost:8001",
|
||||
assertion_provider=fetch_id_jag,
|
||||
scope="user",
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as http_client:
|
||||
async with streamable_http_client("http://localhost:8001/mcp", http_client=http_client) as (read, write):
|
||||
async with ClientSession(read, write) as session:
|
||||
await session.initialize()
|
||||
tools = await session.list_tools()
|
||||
print(f"Available tools: {[tool.name for tool in tools.tools]}")
|
||||
|
||||
|
||||
def run() -> None:
|
||||
asyncio.run(main())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Before running, specify running MCP RS server URL.
|
||||
To spin up RS server locally, see
|
||||
examples/servers/simple-auth/README.md
|
||||
|
||||
cd to the `examples/snippets` directory and run:
|
||||
uv run oauth-client
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
from pydantic import AnyUrl
|
||||
|
||||
from mcp import ClientSession
|
||||
from mcp.client.auth import AuthorizationCodeResult, OAuthClientProvider, TokenStorage
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
|
||||
|
||||
|
||||
class InMemoryTokenStorage(TokenStorage):
|
||||
"""Demo In-memory token storage implementation."""
|
||||
|
||||
def __init__(self):
|
||||
self.tokens: OAuthToken | None = None
|
||||
self.client_info: OAuthClientInformationFull | None = None
|
||||
|
||||
async def get_tokens(self) -> OAuthToken | None:
|
||||
"""Get stored tokens."""
|
||||
return self.tokens
|
||||
|
||||
async def set_tokens(self, tokens: OAuthToken) -> None:
|
||||
"""Store tokens."""
|
||||
self.tokens = tokens
|
||||
|
||||
async def get_client_info(self) -> OAuthClientInformationFull | None:
|
||||
"""Get stored client information."""
|
||||
return self.client_info
|
||||
|
||||
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
|
||||
"""Store client information."""
|
||||
self.client_info = client_info
|
||||
|
||||
|
||||
async def handle_redirect(auth_url: str) -> None:
|
||||
print(f"Visit: {auth_url}")
|
||||
|
||||
|
||||
async def handle_callback() -> AuthorizationCodeResult:
|
||||
callback_url = input("Paste callback URL: ")
|
||||
params = parse_qs(urlparse(callback_url).query)
|
||||
return AuthorizationCodeResult(
|
||||
code=params["code"][0],
|
||||
state=params.get("state", [None])[0],
|
||||
iss=params.get("iss", [None])[0],
|
||||
)
|
||||
|
||||
|
||||
async def main():
|
||||
"""Run the OAuth client example."""
|
||||
oauth_auth = OAuthClientProvider(
|
||||
server_url="http://localhost:8001",
|
||||
client_metadata=OAuthClientMetadata(
|
||||
client_name="Example MCP Client",
|
||||
redirect_uris=[AnyUrl("http://localhost:3000/callback")],
|
||||
grant_types=["authorization_code", "refresh_token"],
|
||||
response_types=["code"],
|
||||
scope="user",
|
||||
),
|
||||
storage=InMemoryTokenStorage(),
|
||||
redirect_handler=handle_redirect,
|
||||
callback_handler=handle_callback,
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client:
|
||||
async with streamable_http_client("http://localhost:8001/mcp", http_client=custom_client) as (read, write):
|
||||
async with ClientSession(read, write) as session:
|
||||
await session.initialize()
|
||||
|
||||
tools = await session.list_tools()
|
||||
print(f"Available tools: {[tool.name for tool in tools.tools]}")
|
||||
|
||||
resources = await session.list_resources()
|
||||
print(f"Available resources: {[r.uri for r in resources.resources]}")
|
||||
|
||||
|
||||
def run():
|
||||
asyncio.run(main())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Example of consuming paginated MCP endpoints from a client."""
|
||||
|
||||
import asyncio
|
||||
|
||||
from mcp_types import PaginatedRequestParams, Resource
|
||||
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.client.stdio import StdioServerParameters, stdio_client
|
||||
|
||||
|
||||
async def list_all_resources() -> None:
|
||||
"""Fetch all resources using pagination."""
|
||||
async with stdio_client(StdioServerParameters(command="uv", args=["run", "mcp-simple-pagination"])) as (
|
||||
read,
|
||||
write,
|
||||
):
|
||||
async with ClientSession(read, write) as session:
|
||||
await session.initialize()
|
||||
|
||||
all_resources: list[Resource] = []
|
||||
cursor = None
|
||||
|
||||
while True:
|
||||
# Fetch a page of resources
|
||||
result = await session.list_resources(params=PaginatedRequestParams(cursor=cursor))
|
||||
all_resources.extend(result.resources)
|
||||
|
||||
print(f"Fetched {len(result.resources)} resources")
|
||||
|
||||
# Check if there are more pages
|
||||
if result.next_cursor:
|
||||
cursor = result.next_cursor
|
||||
else:
|
||||
break
|
||||
|
||||
print(f"Total resources: {len(all_resources)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(list_all_resources())
|
||||
@@ -0,0 +1,62 @@
|
||||
"""examples/snippets/clients/parsing_tool_results.py"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import mcp_types as types
|
||||
|
||||
from mcp import ClientSession, StdioServerParameters
|
||||
from mcp.client.stdio import stdio_client
|
||||
|
||||
|
||||
async def parse_tool_results():
|
||||
"""Demonstrates how to parse different types of content in CallToolResult."""
|
||||
server_params = StdioServerParameters(command="python", args=["path/to/mcp_server.py"])
|
||||
|
||||
async with stdio_client(server_params) as (read, write):
|
||||
async with ClientSession(read, write) as session:
|
||||
await session.initialize()
|
||||
|
||||
# Example 1: Parsing text content
|
||||
result = await session.call_tool("get_data", {"format": "text"})
|
||||
for content in result.content:
|
||||
if isinstance(content, types.TextContent):
|
||||
print(f"Text: {content.text}")
|
||||
|
||||
# Example 2: Parsing structured content from JSON tools
|
||||
result = await session.call_tool("get_user", {"id": "123"})
|
||||
if hasattr(result, "structured_content") and result.structured_content:
|
||||
# Access structured data directly
|
||||
user_data = result.structured_content
|
||||
print(f"User: {user_data.get('name')}, Age: {user_data.get('age')}")
|
||||
|
||||
# Example 3: Parsing embedded resources
|
||||
result = await session.call_tool("read_config", {})
|
||||
for content in result.content:
|
||||
if isinstance(content, types.EmbeddedResource):
|
||||
resource = content.resource
|
||||
if isinstance(resource, types.TextResourceContents):
|
||||
print(f"Config from {resource.uri}: {resource.text}")
|
||||
else:
|
||||
print(f"Binary data from {resource.uri}")
|
||||
|
||||
# Example 4: Parsing image content
|
||||
result = await session.call_tool("generate_chart", {"data": [1, 2, 3]})
|
||||
for content in result.content:
|
||||
if isinstance(content, types.ImageContent):
|
||||
print(f"Image ({content.mime_type}): {len(content.data)} bytes")
|
||||
|
||||
# Example 5: Handling errors
|
||||
result = await session.call_tool("failing_tool", {})
|
||||
if result.is_error:
|
||||
print("Tool execution failed!")
|
||||
for content in result.content:
|
||||
if isinstance(content, types.TextContent):
|
||||
print(f"Error: {content.text}")
|
||||
|
||||
|
||||
async def main():
|
||||
await parse_tool_results()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,82 @@
|
||||
"""cd to the `examples/snippets/clients` directory and run:
|
||||
uv run client
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
import mcp_types as types
|
||||
|
||||
from mcp import ClientSession, StdioServerParameters
|
||||
from mcp.client.context import ClientRequestContext
|
||||
from mcp.client.stdio import stdio_client
|
||||
|
||||
# Create server parameters for stdio connection
|
||||
server_params = StdioServerParameters(
|
||||
command="uv", # Using uv to run the server
|
||||
args=["run", "server", "mcpserver_quickstart", "stdio"], # We're already in snippets dir
|
||||
env={"UV_INDEX": os.environ.get("UV_INDEX", "")},
|
||||
)
|
||||
|
||||
|
||||
# Optional: create a sampling callback
|
||||
async def handle_sampling_message(
|
||||
context: ClientRequestContext, params: types.CreateMessageRequestParams
|
||||
) -> types.CreateMessageResult:
|
||||
print(f"Sampling request: {params.messages}")
|
||||
return types.CreateMessageResult(
|
||||
role="assistant",
|
||||
content=types.TextContent(
|
||||
type="text",
|
||||
text="Hello, world! from model",
|
||||
),
|
||||
model="gpt-3.5-turbo",
|
||||
stop_reason="endTurn",
|
||||
)
|
||||
|
||||
|
||||
async def run():
|
||||
async with stdio_client(server_params) as (read, write):
|
||||
async with ClientSession(read, write, sampling_callback=handle_sampling_message) as session:
|
||||
# Initialize the connection
|
||||
await session.initialize()
|
||||
|
||||
# List available prompts
|
||||
prompts = await session.list_prompts()
|
||||
print(f"Available prompts: {[p.name for p in prompts.prompts]}")
|
||||
|
||||
# Get a prompt (greet_user prompt from mcpserver_quickstart)
|
||||
if prompts.prompts:
|
||||
prompt = await session.get_prompt("greet_user", arguments={"name": "Alice", "style": "friendly"})
|
||||
print(f"Prompt result: {prompt.messages[0].content}")
|
||||
|
||||
# List available resources
|
||||
resources = await session.list_resources()
|
||||
print(f"Available resources: {[r.uri for r in resources.resources]}")
|
||||
|
||||
# List available tools
|
||||
tools = await session.list_tools()
|
||||
print(f"Available tools: {[t.name for t in tools.tools]}")
|
||||
|
||||
# Read a resource (greeting resource from mcpserver_quickstart)
|
||||
resource_content = await session.read_resource("greeting://World")
|
||||
content_block = resource_content.contents[0]
|
||||
if isinstance(content_block, types.TextResourceContents):
|
||||
print(f"Resource content: {content_block.text}")
|
||||
|
||||
# Call a tool (add tool from mcpserver_quickstart)
|
||||
result = await session.call_tool("add", arguments={"a": 5, "b": 3})
|
||||
result_unstructured = result.content[0]
|
||||
if isinstance(result_unstructured, types.TextContent):
|
||||
print(f"Tool result: {result_unstructured.text}")
|
||||
result_structured = result.structured_content
|
||||
print(f"Structured tool result: {result_structured}")
|
||||
|
||||
|
||||
def main():
|
||||
"""Entry point for the client script."""
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Run from the repository root:
|
||||
uv run examples/snippets/clients/streamable_basic.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from mcp import ClientSession
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
|
||||
|
||||
async def main():
|
||||
# Connect to a streamable HTTP server
|
||||
async with streamable_http_client("http://localhost:8000/mcp") as (read_stream, write_stream):
|
||||
# Create a session using the client streams
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
# Initialize the connection
|
||||
await session.initialize()
|
||||
# List available tools
|
||||
tools = await session.list_tools()
|
||||
print(f"Available tools: {[tool.name for tool in tools.tools]}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,318 @@
|
||||
"""URL Elicitation Client Example.
|
||||
|
||||
Demonstrates how clients handle URL elicitation requests from servers.
|
||||
This is the Python equivalent of TypeScript SDK's elicitationUrlExample.ts,
|
||||
focused on URL elicitation patterns without OAuth complexity.
|
||||
|
||||
Features demonstrated:
|
||||
1. Client elicitation capability declaration
|
||||
2. Handling elicitation requests from servers via callback
|
||||
3. Catching UrlElicitationRequiredError from tool calls
|
||||
4. Browser interaction with security warnings
|
||||
5. Interactive CLI for testing
|
||||
|
||||
Run with:
|
||||
cd examples/snippets
|
||||
uv run elicitation-client
|
||||
|
||||
Requires a server with URL elicitation tools running. Start the elicitation
|
||||
server first:
|
||||
uv run server elicitation sse
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import webbrowser
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import mcp_types as types
|
||||
from mcp_types import URL_ELICITATION_REQUIRED
|
||||
|
||||
from mcp import ClientSession
|
||||
from mcp.client.context import ClientRequestContext
|
||||
from mcp.client.sse import sse_client
|
||||
from mcp.shared.exceptions import MCPError, UrlElicitationRequiredError
|
||||
|
||||
|
||||
async def handle_elicitation(
|
||||
context: ClientRequestContext,
|
||||
params: types.ElicitRequestParams,
|
||||
) -> types.ElicitResult | types.ErrorData:
|
||||
"""Handle elicitation requests from the server.
|
||||
|
||||
This callback is invoked when the server sends an elicitation/request.
|
||||
For URL mode, we prompt the user and optionally open their browser.
|
||||
"""
|
||||
if params.mode == "url":
|
||||
return await handle_url_elicitation(params)
|
||||
else:
|
||||
# We only support URL mode in this example
|
||||
return types.ErrorData(
|
||||
code=types.INVALID_REQUEST,
|
||||
message=f"Unsupported elicitation mode: {params.mode}",
|
||||
)
|
||||
|
||||
|
||||
ALLOWED_SCHEMES = {"http", "https"}
|
||||
|
||||
|
||||
async def handle_url_elicitation(
|
||||
params: types.ElicitRequestParams,
|
||||
) -> types.ElicitResult:
|
||||
"""Handle URL mode elicitation - show security warning and optionally open browser.
|
||||
|
||||
This function demonstrates the security-conscious approach to URL elicitation:
|
||||
1. Validate the URL scheme before prompting the user
|
||||
2. Display the full URL and domain for user inspection
|
||||
3. Show the server's reason for requesting this interaction
|
||||
4. Require explicit user consent before opening any URL
|
||||
"""
|
||||
# Extract URL parameters - these are available on URL mode requests
|
||||
url = getattr(params, "url", None)
|
||||
elicitation_id = getattr(params, "elicitationId", None)
|
||||
message = params.message
|
||||
|
||||
if not url:
|
||||
print("Error: No URL provided in elicitation request")
|
||||
return types.ElicitResult(action="cancel")
|
||||
|
||||
# Reject dangerous URL schemes before prompting the user
|
||||
parsed = urlparse(str(url))
|
||||
if parsed.scheme.lower() not in ALLOWED_SCHEMES:
|
||||
print(f"\nRejecting URL with disallowed scheme '{parsed.scheme}': {url}")
|
||||
return types.ElicitResult(action="decline")
|
||||
|
||||
# Extract domain for security display
|
||||
domain = extract_domain(url)
|
||||
|
||||
# Security warning - always show the user what they're being asked to do
|
||||
print("\n" + "=" * 60)
|
||||
print("SECURITY WARNING: External URL Request")
|
||||
print("=" * 60)
|
||||
print("\nThe server is requesting you to open an external URL.")
|
||||
print(f"\n Domain: {domain}")
|
||||
print(f" Full URL: {url}")
|
||||
print("\n Server's reason:")
|
||||
print(f" {message}")
|
||||
print(f"\n Elicitation ID: {elicitation_id}")
|
||||
print("\n" + "-" * 60)
|
||||
|
||||
# Get explicit user consent
|
||||
try:
|
||||
response = input("\nOpen this URL in your browser? (y/n): ").strip().lower()
|
||||
except EOFError:
|
||||
return types.ElicitResult(action="cancel")
|
||||
|
||||
if response in ("n", "no"):
|
||||
print("URL navigation declined.")
|
||||
return types.ElicitResult(action="decline")
|
||||
elif response not in ("y", "yes"):
|
||||
print("Invalid response. Cancelling.")
|
||||
return types.ElicitResult(action="cancel")
|
||||
|
||||
# Open the browser
|
||||
print(f"\nOpening browser to: {url}")
|
||||
try:
|
||||
webbrowser.open(url)
|
||||
except Exception as e:
|
||||
print(f"Failed to open browser: {e}")
|
||||
print(f"Please manually open: {url}")
|
||||
|
||||
print("Waiting for you to complete the interaction in your browser...")
|
||||
print("(The server will continue once you've finished)")
|
||||
|
||||
return types.ElicitResult(action="accept")
|
||||
|
||||
|
||||
def extract_domain(url: str) -> str:
|
||||
"""Extract domain from URL for security display."""
|
||||
try:
|
||||
return urlparse(url).netloc
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
|
||||
async def call_tool_with_error_handling(
|
||||
session: ClientSession,
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any],
|
||||
) -> types.CallToolResult | None:
|
||||
"""Call a tool, handling UrlElicitationRequiredError if raised.
|
||||
|
||||
When a server tool needs URL elicitation before it can proceed,
|
||||
it can either:
|
||||
1. Send an elicitation request directly (handled by elicitation_callback)
|
||||
2. Return an error with code -32042 (URL_ELICITATION_REQUIRED)
|
||||
|
||||
This function demonstrates handling case 2 - catching the error
|
||||
and processing the required URL elicitations.
|
||||
"""
|
||||
try:
|
||||
result = await session.call_tool(tool_name, arguments)
|
||||
|
||||
# Check if the tool returned an error in the result
|
||||
if result.is_error:
|
||||
print(f"Tool returned error: {result.content}")
|
||||
return None
|
||||
|
||||
return result
|
||||
|
||||
except MCPError as e:
|
||||
# Check if this is a URL elicitation required error
|
||||
if e.code == URL_ELICITATION_REQUIRED:
|
||||
print("\n[Tool requires URL elicitation to proceed]")
|
||||
|
||||
# Convert to typed error to access elicitations
|
||||
url_error = UrlElicitationRequiredError.from_error(e.error)
|
||||
|
||||
# Process each required elicitation
|
||||
for elicitation in url_error.elicitations:
|
||||
await handle_url_elicitation(elicitation)
|
||||
|
||||
return None
|
||||
else:
|
||||
# Re-raise other MCP errors
|
||||
print(f"MCP Error: {e.error.message} (code: {e.error.code})")
|
||||
return None
|
||||
|
||||
|
||||
def print_help() -> None:
|
||||
"""Print available commands."""
|
||||
print("\nAvailable commands:")
|
||||
print(" list-tools - List available tools")
|
||||
print(" call <name> [json-args] - Call a tool with optional JSON arguments")
|
||||
print(" secure-payment - Test URL elicitation via ctx.elicit_url()")
|
||||
print(" connect-service - Test URL elicitation via UrlElicitationRequiredError")
|
||||
print(" help - Show this help")
|
||||
print(" quit - Exit the program")
|
||||
|
||||
|
||||
def print_tool_result(result: types.CallToolResult | None) -> None:
|
||||
"""Print a tool call result."""
|
||||
if not result:
|
||||
return
|
||||
print("\nTool result:")
|
||||
for content in result.content:
|
||||
if isinstance(content, types.TextContent):
|
||||
print(f" {content.text}")
|
||||
else:
|
||||
print(f" [{content.type}]")
|
||||
|
||||
|
||||
async def handle_list_tools(session: ClientSession) -> None:
|
||||
"""Handle the list-tools command."""
|
||||
tools = await session.list_tools()
|
||||
if tools.tools:
|
||||
print("\nAvailable tools:")
|
||||
for tool in tools.tools:
|
||||
print(f" - {tool.name}: {tool.description or 'No description'}")
|
||||
else:
|
||||
print("No tools available")
|
||||
|
||||
|
||||
async def handle_call_command(session: ClientSession, command: str) -> None:
|
||||
"""Handle the call command."""
|
||||
parts = command.split(maxsplit=2)
|
||||
if len(parts) < 2:
|
||||
print("Usage: call <tool-name> [json-args]")
|
||||
return
|
||||
|
||||
tool_name = parts[1]
|
||||
args: dict[str, Any] = {}
|
||||
if len(parts) > 2:
|
||||
try:
|
||||
args = json.loads(parts[2])
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Invalid JSON arguments: {e}")
|
||||
return
|
||||
|
||||
print(f"\nCalling tool '{tool_name}' with args: {args}")
|
||||
result = await call_tool_with_error_handling(session, tool_name, args)
|
||||
print_tool_result(result)
|
||||
|
||||
|
||||
async def process_command(session: ClientSession, command: str) -> bool:
|
||||
"""Process a single command. Returns False if should exit."""
|
||||
if command in {"quit", "exit"}:
|
||||
print("Goodbye!")
|
||||
return False
|
||||
|
||||
if command == "help":
|
||||
print_help()
|
||||
elif command == "list-tools":
|
||||
await handle_list_tools(session)
|
||||
elif command.startswith("call "):
|
||||
await handle_call_command(session, command)
|
||||
elif command == "secure-payment":
|
||||
print("\nTesting secure_payment tool (uses ctx.elicit_url())...")
|
||||
result = await call_tool_with_error_handling(session, "secure_payment", {"amount": 99.99})
|
||||
print_tool_result(result)
|
||||
elif command == "connect-service":
|
||||
print("\nTesting connect_service tool (raises UrlElicitationRequiredError)...")
|
||||
result = await call_tool_with_error_handling(session, "connect_service", {"service_name": "github"})
|
||||
print_tool_result(result)
|
||||
else:
|
||||
print(f"Unknown command: {command}")
|
||||
print("Type 'help' for available commands.")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def run_command_loop(session: ClientSession) -> None:
|
||||
"""Run the interactive command loop."""
|
||||
while True:
|
||||
try:
|
||||
command = input("> ").strip()
|
||||
except EOFError:
|
||||
break
|
||||
except KeyboardInterrupt:
|
||||
print("\n")
|
||||
break
|
||||
|
||||
if not command:
|
||||
continue
|
||||
|
||||
if not await process_command(session, command):
|
||||
break
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Run the interactive URL elicitation client."""
|
||||
server_url = "http://localhost:8000/sse"
|
||||
|
||||
print("=" * 60)
|
||||
print("URL Elicitation Client Example")
|
||||
print("=" * 60)
|
||||
print(f"\nConnecting to: {server_url}")
|
||||
print("(Start server with: cd examples/snippets && uv run server elicitation sse)")
|
||||
|
||||
try:
|
||||
async with sse_client(server_url) as (read, write):
|
||||
async with ClientSession(
|
||||
read,
|
||||
write,
|
||||
elicitation_callback=handle_elicitation,
|
||||
) as session:
|
||||
await session.initialize()
|
||||
print("\nConnected! Type 'help' for available commands.\n")
|
||||
await run_command_loop(session)
|
||||
|
||||
except ConnectionRefusedError:
|
||||
print(f"\nError: Could not connect to {server_url}")
|
||||
print("Make sure the elicitation server is running:")
|
||||
print(" cd examples/snippets && uv run server elicitation sse")
|
||||
except Exception as e:
|
||||
print(f"\nError: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def run() -> None:
|
||||
"""Entry point for the client script."""
|
||||
asyncio.run(main())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
Reference in New Issue
Block a user