Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial implementation of MCP support #13

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions examples/mcp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"""
This example shows how to use the Model Context Protocol (MCP) with Hype.
It creates a weather service that provides forecasts and alerts from
the [National Weather Service API](https://api.weather.gov).

Download `uv` to run this example: https://github.com/astral-sh/uv

```
uv run examples/mcp.py
```

Then enter JSON-RPC requests, one per line. For example:

```json
{"jsonrpc": "2.0", "method": "tools/call", "params": {"name": "get_forecast", "arguments": {"region": "37.7749,-122.4194"}}, "id": 1}
```
"""

# /// script
# dependencies = [
# "httpx",
# "rich",
# "hype @ git+https://github.com/mattt/hype.git",
# ]
# ///

import asyncio
import json

import httpx
from rich.console import Console

import hype
from hype.mcp import create_mcp_stdio_handler

# Weather API configuration
NWS_API_BASE: str = "https://api.weather.gov"
HEADERS = {"User-Agent": "loopwork-weather/1.0", "Accept": "application/geo+json"}


@hype.up
def get_alerts(region: str) -> list[str] | None:
"""Get weather alerts for the specified region.

Args:
region: The region code to get alerts for (e.g. 'CA', 'NY')

Returns:
A list of formatted alert strings, or None if no alerts
"""
with httpx.Client() as client:
response = client.get(
f"{NWS_API_BASE}/alerts/active/area/{region}", headers=HEADERS
)
response.raise_for_status()
data = response.json()

if not data["features"]:
return None

alerts = []
for alert in data["features"]:
props = alert["properties"]
alerts.append(
f"⚠️ {props['event']}\n"
f"Severity: {props['severity']}\n"
f"Areas: {props['areaDesc']}\n"
f"Description: {props['description']}\n"
)

return alerts


@hype.up
def get_forecast(region: str) -> list[str]:
"""Get weather forecast for the specified region.

Args:
region: The lat,lon coordinates (e.g. '37.7749,-122.4194' for San Francisco)

Returns:
A list of formatted forecast periods
"""
with httpx.Client() as client:
# First get the forecast grid endpoint
points_url = f"{NWS_API_BASE}/points/{region}"
response = client.get(points_url, headers=HEADERS)
response.raise_for_status()
points_data = response.json()

# Get the detailed forecast
forecast_url = points_data["properties"]["forecast"]
response = client.get(forecast_url, headers=HEADERS)
response.raise_for_status()
data = response.json()

periods = data["properties"]["periods"][:5] # Next 5 forecast periods

return [
f"🌤️ {period['name']}\n"
f"Temperature: {period['temperature']}°{period['temperatureUnit']}\n"
f"Wind: {period['windSpeed']} {period['windDirection']}\n"
f"{period['shortForecast']}\n"
for period in periods
]


if __name__ == "__main__":
console = Console()
functions = [get_alerts, get_forecast]

# Print available functions
console.print("\n[bold]Available functions:[/bold]")
for f in functions:
console.print(f"\n[cyan]{f.name}[/cyan]")
if f._wrapped.__doc__:
console.print(f" {f._wrapped.__doc__.strip()}")

console.print("\n[bold]Enter JSON-RPC requests (one per line):[/bold]")
console.print("Example:")
example = {
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "get_forecast",
"arguments": {"region": "37.7749,-122.4194"},
},
"id": 1,
}
console.print(json.dumps(example))
console.print()

try:
# Start stdio handler
handler = create_mcp_stdio_handler(functions)
asyncio.run(anext(handler))
except KeyboardInterrupt:
console.print("\n[bold red]Shutting down...[/bold red]")
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,11 @@ build-backend = "hatchling.build"
[tool.uv]
dev-dependencies = [
"gradio>=5.5.0",
"pytest-asyncio>=0.21.0",
"pytest-mock>=3.14.0",
"pytest>=8.3.3",
"ruff>=0.7.0",
"rich>=13.9.4",
]

[tool.pylint.main]
Expand Down
3 changes: 3 additions & 0 deletions src/hype/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from hype.function import wrap as up
from hype.gui import create_gradio_interface
from hype.http import create_fastapi_app
from hype.mcp import create_mcp_sse_app, create_mcp_stdio_handler
from hype.tools.anthropic import create_anthropic_tools
from hype.tools.ollama import create_ollama_tools
from hype.tools.openai import create_openai_tools
Expand All @@ -10,6 +11,8 @@
"up",
"Function",
"create_fastapi_app",
"create_mcp_sse_app",
"create_mcp_stdio_handler",
"create_anthropic_tools",
"create_openai_tools",
"create_ollama_tools",
Expand Down
7 changes: 7 additions & 0 deletions src/hype/mcp/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from hype.mcp.sse import create_mcp_sse_app
from hype.mcp.stdio import create_mcp_stdio_handler

__all__ = [
"create_mcp_sse_app",
"create_mcp_stdio_handler",
]
160 changes: 160 additions & 0 deletions src/hype/mcp/jsonrpc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import asyncio
from typing import Any, Literal

from pydantic import BaseModel, Field

from hype.function import Function


class JsonRpcException(Exception):
"""Base class for JSON-RPC errors."""

def __init__(self, code: int, message: str, data: Any | None = None):
self.code = code
self.message = message
self.data = data
super().__init__(message)

def to_error(self) -> "JsonRpcError":
"""Convert exception to JSON-RPC error object."""
return JsonRpcError(code=self.code, message=self.message, data=self.data)


class ParseError(JsonRpcException):
"""Invalid JSON was received by the server."""

def __init__(self, message: str, data: Any | None = None):
super().__init__(-32700, f"Parse error: {message}", data)


class InvalidRequest(JsonRpcException):
"""The JSON sent is not a valid Request object."""

def __init__(self, message: str, data: Any | None = None):
super().__init__(-32600, f"Invalid Request: {message}", data)


class MethodNotFound(JsonRpcException):
"""The method does not exist / is not available."""

def __init__(self, method: str, data: Any | None = None):
super().__init__(-32601, f"Method not found: {method}", data)


class InvalidParams(JsonRpcException):
"""Invalid method parameters."""

def __init__(self, message: str, data: Any | None = None):
super().__init__(-32602, f"Invalid params: {message}", data)


class InternalError(JsonRpcException):
"""Internal JSON-RPC error."""

def __init__(self, message: str, data: Any | None = None):
super().__init__(-32603, f"Internal error: {message}", data)


class JsonRpcRequest(BaseModel):
"""JSON-RPC request object."""

jsonrpc: Literal["2.0"] = "2.0"
method: str
params: dict[str, Any]
id: str | int | None = None


class JsonRpcError(BaseModel):
"""JSON-RPC error object."""

code: int
message: str
data: Any | None = None


class JsonRpcResponse(BaseModel):
"""JSON-RPC response object."""

jsonrpc: Literal["2.0"] = "2.0"
result: Any | None = None
error: JsonRpcError | None = None
id: str | int | None = None


class CallToolRequest(BaseModel):
"""Request to call a tool."""

name: str
arguments: dict[str, Any] = Field(default_factory=dict)


async def handle_jsonrpc_request(
request: JsonRpcRequest, functions: list[Function]
) -> JsonRpcResponse:
"""Handle a JSON-RPC request.

Args:
request: The JSON-RPC request to handle
functions: List of functions available to the request

Returns:
JSON-RPC response
"""
try:
if request.method == "tools/list":
return JsonRpcResponse(
result=[
{
"name": f.name,
"description": f._wrapped.__doc__ or "",
"parameters": f.input_schema.get("properties", {}),
}
for f in functions
],
id=request.id,
)
elif request.method == "tools/call":
try:
params = CallToolRequest(**request.params)
except Exception as e:
raise InvalidParams(str(e))

function = next((f for f in functions if f.name == params.name), None)
if not function:
raise MethodNotFound(params.name)

try:
# Call the function with the provided arguments
if asyncio.iscoroutinefunction(function._wrapped):
result = await function(**params.arguments)
else:
result = function(**params.arguments)
if asyncio.iscoroutine(result):
result = await result

return JsonRpcResponse(
result={
"content": [
{
"type": "text",
"text": str(result) if result is not None else "",
}
]
},
id=request.id,
)
except Exception as e:
# Pass through InvalidParams for validation errors
if isinstance(e, (ValueError, TypeError)):
raise InvalidParams(str(e))
# Convert other exceptions to InternalError with the error message
error = InternalError(str(e))
return JsonRpcResponse(error=error.to_error(), id=request.id)
else:
raise MethodNotFound(request.method)

except JsonRpcException as e:
return JsonRpcResponse(error=e.to_error(), id=request.id)
except Exception as e:
error = InternalError(str(e))
return JsonRpcResponse(error=error.to_error(), id=request.id)
Loading
Loading