Skip to content
Open
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
9 changes: 9 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/mcp/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from .server import Server
from .tool import Tool
from .prompt import Prompt
from .argument import Argument
from .resource import Resource
from .response import Response
from .request import JsonRpcRequest

__all__ = ["Server", "Tool", "Prompt", "Argument", "Resource", "Response", "JsonRpcRequest"]
19 changes: 19 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/mcp/argument.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from __future__ import annotations

from dataclasses import dataclass


@dataclass
class Argument:
"""A single prompt argument definition."""

name: str
description: str | None = None
required: bool = False

def to_json(self) -> dict:
"""Build the MCP argument definition for ``prompts/list``."""
entry: dict = {"name": self.name, "required": self.required}
if self.description:
entry["description"] = self.description
return entry
41 changes: 41 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/mcp/prompt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from __future__ import annotations

from abc import ABC, abstractmethod
from typing import TYPE_CHECKING

from .argument import Argument

if TYPE_CHECKING:
from .response import Response


class Prompt(ABC):
"""Base class for MCP prompts.

Subclasses must set ``name`` and implement ``handle``.
"""

title: str | None = None
name: str | None = None
description: str | None = None

def should_register(self) -> bool:
"""Return ``False`` to conditionally skip registration."""
return True

def arguments(self) -> list[Argument]:
"""Return the prompt's argument definitions."""
return []

@abstractmethod
async def handle(self, arguments: dict) -> Response:
"""Generate the prompt content. Returns a ``Response``."""
...

def to_json(self) -> dict:
"""Build the MCP prompt definition for ``prompts/list``."""
return {
"name": self.name,
"description": self.description,
"arguments": [a.to_json() for a in self.arguments()],
}
163 changes: 163 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/mcp/protocol.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
"""JSON-RPC 2.0 dispatcher for MCP Streamable HTTP transport."""

from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
from .server import Server
from .tool import Tool
from .prompt import Prompt
from .resource import Resource

PROTOCOL_VERSION = "2024-11-05"


class Protocol:
"""Dispatches JSON-RPC 2.0 method calls to the appropriate MCP handler."""

def __init__(self, server: Server):
self.server = server
self._tools: dict = {}
self._prompts: dict = {}
self._resources: dict = {}

self._methods = {
"initialize": self._initialize,
"tools/list": self._tools_list,
"tools/call": self._tools_call,
"prompts/list": self._prompts_list,
"prompts/get": self._prompts_get,
"resources/list": self._resources_list,
"resources/read": self._resources_read,
}

# ── builder ──────────────────────────────────────────────────────────

def tools(self, tools: list[Tool]) -> Protocol:
"""Register tool instances; returns ``self`` for chaining."""
self._tools.update((t.name, t) for t in tools)
return self

def prompts(self, prompts: list[Prompt]) -> Protocol:
"""Register prompt instances; returns ``self`` for chaining."""
self._prompts.update((p.name, p) for p in prompts)
return self

def resources(self, resources: list[Resource]) -> Protocol:
"""Register resource instances; returns ``self`` for chaining."""
self._resources.update((r.uri, r) for r in resources)
return self

# ── JSON-RPC helpers ─────────────────────────────────────────────────

@staticmethod
def ok(rpc_id, result: dict) -> dict:
return {"jsonrpc": "2.0", "id": rpc_id, "result": result}

@staticmethod
def err(rpc_id, code: int, message: str) -> dict:
return {
"jsonrpc": "2.0",
"id": rpc_id,
"error": {"code": code, "message": message},
}

# ── method handlers ──────────────────────────────────────────────────

async def _initialize(self, rpc_id, params: dict, **ctx) -> dict:
return self.ok(rpc_id, {
"protocolVersion": PROTOCOL_VERSION,
"serverInfo": {
"name": self.server.name or "mcp-server",
"version": "1.0.0",
},
"capabilities": self.server.capabilities(),
})

async def _tools_list(self, rpc_id, params: dict, **ctx) -> dict:
tools = [t.to_json() for t in self._tools.values()]
return self.ok(rpc_id, {"tools": tools})

async def _tools_call(self, rpc_id, params: dict, **ctx) -> dict:
name = params.get("name", "")
arguments = params.get("arguments") or {}

tool = self._tools.get(name)
if not tool:
return self.err(rpc_id, -32601, f"Unknown tool: {name}")

try:
response = await tool.handle(arguments)
return self.ok(rpc_id, {"content": response.to_content()})
except Exception as exc:
return self.ok(rpc_id, {
"content": [{"type": "text", "text": f"Error: {exc}"}],
})

async def _prompts_list(self, rpc_id, params: dict, **ctx) -> dict:
prompts = [p.to_json() for p in self._prompts.values()]
return self.ok(rpc_id, {"prompts": prompts})

async def _prompts_get(self, rpc_id, params: dict, **ctx) -> dict:
name = params.get("name", "")
arguments = params.get("arguments") or {}

prompt = self._prompts.get(name)
if not prompt:
return self.err(rpc_id, -32601, f"Unknown prompt: {name}")

try:
response = await prompt.handle(arguments)
return self.ok(rpc_id, {
"description": prompt.description,
"messages": [
{
"role": "user",
"content": response.to_content(),
}
],
})
except Exception as exc:
return self.ok(rpc_id, {
"messages": [
{
"role": "user",
"content": [{"type": "text", "text": f"Error: {exc}"}],
}
],
})

async def _resources_list(self, rpc_id, params: dict, **ctx) -> dict:
resources = [r.to_json() for r in self._resources.values()]
return self.ok(rpc_id, {"resources": resources})

async def _resources_read(self, rpc_id, params: dict, **ctx) -> dict:
uri = params.get("uri", "")

resource = self._resources.get(uri)
if not resource:
return self.err(rpc_id, -32602, f"Unknown resource URI: {uri}")

try:
text = await resource.read(**ctx)
return self.ok(rpc_id, {
"contents": [
{
"uri": resource.uri,
"mimeType": resource.mime_type,
"text": text,
}
],
})
except Exception as exc:
return self.err(rpc_id, -32603, f"Error reading resource: {exc}")

# ── dispatcher ───────────────────────────────────────────────────────

async def dispatch(self, method: str, rpc_id, params: dict, **context) -> dict:
"""Route a JSON-RPC method to the appropriate handler."""
fn = self._methods.get(method)
if not fn:
return self.err(rpc_id, -32601, f"Method not found: {method}")
return await fn(rpc_id, params, **context)
13 changes: 13 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/mcp/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[project]
name = "startkit-mcp"
version = "0.1.0"
description = "MCP (Model Context Protocol) framework for fastapi-startkit — define servers, tools, prompts, and resources as classes"
requires-python = ">=3.13"
dependencies = [
"fastapi-startkit[fastapi]>=0.26.0",
"pydantic>=2.0",
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
17 changes: 17 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/mcp/request.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from __future__ import annotations

from pydantic import BaseModel, Field


class JsonRpcRequest(BaseModel):
"""A JSON-RPC 2.0 request envelope for the MCP transport."""

jsonrpc: str = "2.0"
method: str = ""
id: str | int | None = None
params: dict = Field(default_factory=dict)

@property
def is_notification(self) -> bool:
"""Requests without an ``id`` are notifications (no response expected)."""
return self.id is None
31 changes: 31 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/mcp/resource.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from __future__ import annotations

from abc import ABC, abstractmethod


class Resource(ABC):
"""Base class for MCP resources.

Subclasses must set ``uri`` and ``name`` and implement ``read``.
"""

uri: str | None = None
name: str | None = None
description: str | None = None
mime_type: str = "text/plain"

@abstractmethod
async def read(self, **kwargs) -> str:
"""Read and return the resource content."""
...

def to_json(self) -> dict:
"""Build the MCP resource definition for ``resources/list``."""
entry = {
"uri": self.uri,
"name": self.name,
"mimeType": self.mime_type,
}
if self.description:
entry["description"] = self.description
return entry
24 changes: 24 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/mcp/response.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from __future__ import annotations


class Response:
"""Wraps tool/prompt output into MCP content format."""

def __init__(self):
self._parts: list[dict] = []

@staticmethod
def text(value: str) -> Response:
r = Response()
r._parts.append({"type": "text", "text": value})
return r

@staticmethod
def structure(data: dict) -> Response:
r = Response()
r._parts.append({"type": "resource", "resource": data})
return r

def to_content(self) -> list[dict]:
"""Serialize to MCP content array."""
return self._parts or [{"type": "text", "text": ""}]
Loading
Loading