thin-mcp 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- thin_mcp/__init__.py +38 -0
- thin_mcp/django_ninja.py +178 -0
- thin_mcp/enums.py +17 -0
- thin_mcp/error_codes.py +17 -0
- thin_mcp/protocol.py +119 -0
- thin_mcp/py.typed +1 -0
- thin_mcp/responses.py +61 -0
- thin_mcp/schemas.py +206 -0
- thin_mcp/server.py +233 -0
- thin_mcp/tools.py +161 -0
- thin_mcp-0.1.0.dist-info/METADATA +206 -0
- thin_mcp-0.1.0.dist-info/RECORD +14 -0
- thin_mcp-0.1.0.dist-info/WHEEL +4 -0
- thin_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
thin_mcp/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Reusable MCP protocol and tool registration infrastructure."""
|
|
2
|
+
|
|
3
|
+
from thin_mcp.enums import McpHeaderName, McpMethodName
|
|
4
|
+
from thin_mcp.error_codes import JsonRpcErrorCode
|
|
5
|
+
from thin_mcp.protocol import McpRequestHeaders
|
|
6
|
+
from thin_mcp.responses import McpHttpResult
|
|
7
|
+
from thin_mcp.schemas import (
|
|
8
|
+
EmptyToolArguments,
|
|
9
|
+
McpRequest,
|
|
10
|
+
ToolAnnotations,
|
|
11
|
+
ToolCallParams,
|
|
12
|
+
)
|
|
13
|
+
from thin_mcp.server import McpServer
|
|
14
|
+
from thin_mcp.tools import (
|
|
15
|
+
ToolContext,
|
|
16
|
+
ToolExecutionError,
|
|
17
|
+
ToolPermission,
|
|
18
|
+
ToolPermissionDenied,
|
|
19
|
+
ToolRegistry,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = (
|
|
23
|
+
"EmptyToolArguments",
|
|
24
|
+
"JsonRpcErrorCode",
|
|
25
|
+
"McpHeaderName",
|
|
26
|
+
"McpHttpResult",
|
|
27
|
+
"McpMethodName",
|
|
28
|
+
"McpRequest",
|
|
29
|
+
"McpRequestHeaders",
|
|
30
|
+
"McpServer",
|
|
31
|
+
"ToolAnnotations",
|
|
32
|
+
"ToolCallParams",
|
|
33
|
+
"ToolContext",
|
|
34
|
+
"ToolExecutionError",
|
|
35
|
+
"ToolPermission",
|
|
36
|
+
"ToolPermissionDenied",
|
|
37
|
+
"ToolRegistry",
|
|
38
|
+
)
|
thin_mcp/django_ninja.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import math
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from django.http import HttpRequest, HttpResponse
|
|
6
|
+
from ninja import NinjaAPI
|
|
7
|
+
from ninja.errors import (
|
|
8
|
+
AuthenticationError,
|
|
9
|
+
AuthorizationError,
|
|
10
|
+
HttpError,
|
|
11
|
+
Throttled,
|
|
12
|
+
ValidationError,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from thin_mcp.error_codes import JsonRpcErrorCode
|
|
16
|
+
from thin_mcp.responses import McpHttpResult
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def exception_instance[ExceptionT: Exception](
|
|
22
|
+
exc: ExceptionT | type[ExceptionT],
|
|
23
|
+
) -> ExceptionT:
|
|
24
|
+
if isinstance(exc, type):
|
|
25
|
+
raise TypeError("Django Ninja passed an exception type instead of an instance.")
|
|
26
|
+
return exc
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class McpAPI(NinjaAPI):
|
|
30
|
+
"""Ninja API with MCP error envelopes and exact transport responses."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
33
|
+
super().__init__(*args, **kwargs)
|
|
34
|
+
self.add_exception_handler(AuthenticationError, self.authentication_error)
|
|
35
|
+
self.add_exception_handler(AuthorizationError, self.authorization_error)
|
|
36
|
+
self.add_exception_handler(ValidationError, self.validation_error)
|
|
37
|
+
self.add_exception_handler(Throttled, self.throttled_error)
|
|
38
|
+
self.add_exception_handler(HttpError, self.http_error)
|
|
39
|
+
self.add_exception_handler(Exception, self.internal_error)
|
|
40
|
+
|
|
41
|
+
def error_response(
|
|
42
|
+
self,
|
|
43
|
+
request: HttpRequest,
|
|
44
|
+
*,
|
|
45
|
+
status: int,
|
|
46
|
+
code: int,
|
|
47
|
+
message: str,
|
|
48
|
+
) -> HttpResponse:
|
|
49
|
+
result = McpHttpResult.error(None, code, message, status=status)
|
|
50
|
+
if result.body is None: # pragma: no cover - error always has a body
|
|
51
|
+
raise RuntimeError("MCP error response has no body.")
|
|
52
|
+
return self.create_response(
|
|
53
|
+
request,
|
|
54
|
+
result.body.model_dump(mode="json", by_alias=True),
|
|
55
|
+
status=status,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def authentication_error(
|
|
59
|
+
self,
|
|
60
|
+
request: HttpRequest,
|
|
61
|
+
exc: AuthenticationError | type[AuthenticationError],
|
|
62
|
+
) -> HttpResponse:
|
|
63
|
+
error = exception_instance(exc)
|
|
64
|
+
response = self.error_response(
|
|
65
|
+
request,
|
|
66
|
+
status=error.status_code,
|
|
67
|
+
code=JsonRpcErrorCode.SERVER_ERROR,
|
|
68
|
+
message="Authentication required.",
|
|
69
|
+
)
|
|
70
|
+
response["WWW-Authenticate"] = "Bearer"
|
|
71
|
+
return response
|
|
72
|
+
|
|
73
|
+
def authorization_error(
|
|
74
|
+
self,
|
|
75
|
+
request: HttpRequest,
|
|
76
|
+
exc: AuthorizationError | type[AuthorizationError],
|
|
77
|
+
) -> HttpResponse:
|
|
78
|
+
error = exception_instance(exc)
|
|
79
|
+
return self.error_response(
|
|
80
|
+
request,
|
|
81
|
+
status=error.status_code,
|
|
82
|
+
code=JsonRpcErrorCode.SERVER_ERROR,
|
|
83
|
+
message=str(error),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def validation_error(
|
|
87
|
+
self,
|
|
88
|
+
request: HttpRequest,
|
|
89
|
+
exc: ValidationError | type[ValidationError],
|
|
90
|
+
) -> HttpResponse:
|
|
91
|
+
exception_instance(exc)
|
|
92
|
+
return self.error_response(
|
|
93
|
+
request,
|
|
94
|
+
status=400,
|
|
95
|
+
code=JsonRpcErrorCode.INVALID_REQUEST,
|
|
96
|
+
message="Invalid Request.",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def http_error(
|
|
100
|
+
self,
|
|
101
|
+
request: HttpRequest,
|
|
102
|
+
exc: HttpError | type[HttpError],
|
|
103
|
+
) -> HttpResponse:
|
|
104
|
+
error = exception_instance(exc)
|
|
105
|
+
return self.error_response(
|
|
106
|
+
request,
|
|
107
|
+
status=error.status_code,
|
|
108
|
+
code=JsonRpcErrorCode.SERVER_ERROR,
|
|
109
|
+
message=str(error),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def throttled_error(
|
|
113
|
+
self,
|
|
114
|
+
request: HttpRequest,
|
|
115
|
+
exc: Throttled | type[Throttled],
|
|
116
|
+
) -> HttpResponse:
|
|
117
|
+
error = exception_instance(exc)
|
|
118
|
+
response = self.error_response(
|
|
119
|
+
request,
|
|
120
|
+
status=error.status_code,
|
|
121
|
+
code=JsonRpcErrorCode.SERVER_ERROR,
|
|
122
|
+
message=str(error),
|
|
123
|
+
)
|
|
124
|
+
if error.wait is not None:
|
|
125
|
+
response["Retry-After"] = str(max(math.ceil(error.wait), 1))
|
|
126
|
+
return response
|
|
127
|
+
|
|
128
|
+
def internal_error(
|
|
129
|
+
self,
|
|
130
|
+
request: HttpRequest,
|
|
131
|
+
exc: Exception | type[Exception],
|
|
132
|
+
) -> HttpResponse:
|
|
133
|
+
error = exception_instance(exc)
|
|
134
|
+
logger.error("Unhandled MCP request error", exc_info=error)
|
|
135
|
+
return self.error_response(
|
|
136
|
+
request,
|
|
137
|
+
status=500,
|
|
138
|
+
code=JsonRpcErrorCode.INTERNAL_ERROR,
|
|
139
|
+
message="Internal error.",
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def on_exception(
|
|
143
|
+
self,
|
|
144
|
+
request: HttpRequest,
|
|
145
|
+
exc: Exception | type[Exception],
|
|
146
|
+
) -> HttpResponse:
|
|
147
|
+
error = exception_instance(exc)
|
|
148
|
+
if (
|
|
149
|
+
isinstance(error, HttpError)
|
|
150
|
+
and error.status_code == 400
|
|
151
|
+
and error.__cause__ is not None
|
|
152
|
+
):
|
|
153
|
+
response = render_mcp_result(
|
|
154
|
+
McpHttpResult.error(
|
|
155
|
+
None,
|
|
156
|
+
JsonRpcErrorCode.PARSE_ERROR,
|
|
157
|
+
"Parse error.",
|
|
158
|
+
status=400,
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
else:
|
|
162
|
+
response = super().on_exception(request, error)
|
|
163
|
+
if hasattr(request, "request_id"):
|
|
164
|
+
response["X-Request-ID"] = request.request_id
|
|
165
|
+
return response
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def render_mcp_result(result: McpHttpResult) -> HttpResponse:
|
|
169
|
+
if result.body is None:
|
|
170
|
+
response = HttpResponse(status=result.status)
|
|
171
|
+
else:
|
|
172
|
+
response = HttpResponse(
|
|
173
|
+
result.body.model_dump_json(by_alias=True),
|
|
174
|
+
content_type="application/json",
|
|
175
|
+
status=result.status,
|
|
176
|
+
)
|
|
177
|
+
response["Cache-Control"] = "no-store"
|
|
178
|
+
return response
|
thin_mcp/enums.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from enum import StrEnum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class McpHeaderName(StrEnum):
|
|
5
|
+
PROTOCOL_VERSION = "MCP-Protocol-Version"
|
|
6
|
+
METHOD = "Mcp-Method"
|
|
7
|
+
NAME = "Mcp-Name"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class McpMethodName(StrEnum):
|
|
11
|
+
SERVER_DISCOVER = "server/discover"
|
|
12
|
+
INITIALIZE = "initialize"
|
|
13
|
+
PING = "ping"
|
|
14
|
+
TOOLS_LIST = "tools/list"
|
|
15
|
+
TOOLS_CALL = "tools/call"
|
|
16
|
+
PROMPTS_GET = "prompts/get"
|
|
17
|
+
RESOURCES_READ = "resources/read"
|
thin_mcp/error_codes.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from enum import IntEnum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class JsonRpcErrorCode(IntEnum):
|
|
5
|
+
"""JSON-RPC 2.0 error codes used by the MCP server.
|
|
6
|
+
|
|
7
|
+
See https://www.jsonrpc.org/specification#error_object.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
PARSE_ERROR = -32700
|
|
11
|
+
INVALID_REQUEST = -32600
|
|
12
|
+
METHOD_NOT_FOUND = -32601
|
|
13
|
+
INVALID_PARAMS = -32602
|
|
14
|
+
INTERNAL_ERROR = -32603
|
|
15
|
+
SERVER_ERROR = -32000
|
|
16
|
+
HEADER_MISMATCH = -32020
|
|
17
|
+
UNSUPPORTED_PROTOCOL_VERSION = -32022
|
thin_mcp/protocol.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Per-request MCP metadata and Streamable HTTP header validation."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ValidationError
|
|
8
|
+
|
|
9
|
+
from thin_mcp.enums import McpHeaderName, McpMethodName
|
|
10
|
+
from thin_mcp.error_codes import JsonRpcErrorCode
|
|
11
|
+
from thin_mcp.responses import McpHttpResult
|
|
12
|
+
from thin_mcp.schemas import (
|
|
13
|
+
McpRequest,
|
|
14
|
+
RequestMetadata,
|
|
15
|
+
UnsupportedProtocolVersionData,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
MODERN_PROTOCOL_VERSION = "2026-07-28"
|
|
19
|
+
PROTOCOL_VERSION_META_KEY = "io.modelcontextprotocol/protocolVersion"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class McpRequestHeaders(BaseModel):
|
|
23
|
+
protocol_version: str | None = None
|
|
24
|
+
method: str | None = None
|
|
25
|
+
name: str | None = None
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def from_mapping(cls, headers: Mapping[str, str]) -> "McpRequestHeaders":
|
|
29
|
+
normalized = {key.lower(): value for key, value in headers.items()}
|
|
30
|
+
return cls(
|
|
31
|
+
protocol_version=normalized.get(McpHeaderName.PROTOCOL_VERSION.lower()),
|
|
32
|
+
method=normalized.get(McpHeaderName.METHOD.lower()),
|
|
33
|
+
name=normalized.get(McpHeaderName.NAME.lower()),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def has_protocol_metadata(request: McpRequest) -> bool:
|
|
38
|
+
meta = request.params.get("_meta")
|
|
39
|
+
return isinstance(meta, dict) and PROTOCOL_VERSION_META_KEY in meta
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def is_modern_version(version: str | None) -> bool:
|
|
43
|
+
return bool(
|
|
44
|
+
version
|
|
45
|
+
and re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", version)
|
|
46
|
+
and version >= MODERN_PROTOCOL_VERSION
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def uses_modern_protocol(request: McpRequest, headers: McpRequestHeaders) -> bool:
|
|
51
|
+
if has_protocol_metadata(request):
|
|
52
|
+
return True
|
|
53
|
+
# Initialization proposes a version in its params and negotiates a
|
|
54
|
+
# handshake-era revision even when the HTTP header is newer.
|
|
55
|
+
return request.method != McpMethodName.INITIALIZE and is_modern_version(
|
|
56
|
+
headers.protocol_version
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def decode_name_header(value: str) -> str:
|
|
61
|
+
if not re.fullmatch(r"[\x20-\x7e\t]*", value) or value != value.strip():
|
|
62
|
+
raise ValueError("Invalid header characters.")
|
|
63
|
+
if value.startswith("=?base64?") and value.endswith("?="):
|
|
64
|
+
return base64.b64decode(value[9:-2], validate=True).decode("utf-8")
|
|
65
|
+
return value
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def validate_modern_request(
|
|
69
|
+
request: McpRequest,
|
|
70
|
+
headers: McpRequestHeaders,
|
|
71
|
+
supported_versions: tuple[str, ...],
|
|
72
|
+
) -> McpHttpResult | None:
|
|
73
|
+
try:
|
|
74
|
+
metadata = RequestMetadata.model_validate(request.params.get("_meta"))
|
|
75
|
+
except ValidationError:
|
|
76
|
+
return McpHttpResult.error(
|
|
77
|
+
request.id,
|
|
78
|
+
JsonRpcErrorCode.INVALID_PARAMS,
|
|
79
|
+
"Required protocol metadata is missing or invalid.",
|
|
80
|
+
status=400,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
mismatched_header: McpHeaderName | None = None
|
|
84
|
+
if headers.protocol_version != metadata.protocol_version:
|
|
85
|
+
mismatched_header = McpHeaderName.PROTOCOL_VERSION
|
|
86
|
+
elif headers.method != request.method:
|
|
87
|
+
mismatched_header = McpHeaderName.METHOD
|
|
88
|
+
elif request.method in {
|
|
89
|
+
McpMethodName.TOOLS_CALL,
|
|
90
|
+
McpMethodName.PROMPTS_GET,
|
|
91
|
+
McpMethodName.RESOURCES_READ,
|
|
92
|
+
}:
|
|
93
|
+
source = "uri" if request.method == McpMethodName.RESOURCES_READ else "name"
|
|
94
|
+
try:
|
|
95
|
+
if headers.name is None or decode_name_header(
|
|
96
|
+
headers.name
|
|
97
|
+
) != request.params.get(source):
|
|
98
|
+
mismatched_header = McpHeaderName.NAME
|
|
99
|
+
except ValueError:
|
|
100
|
+
mismatched_header = McpHeaderName.NAME
|
|
101
|
+
|
|
102
|
+
if mismatched_header is not None:
|
|
103
|
+
return McpHttpResult.error(
|
|
104
|
+
request.id,
|
|
105
|
+
JsonRpcErrorCode.HEADER_MISMATCH,
|
|
106
|
+
f"Missing, malformed, or mismatched {mismatched_header} header.",
|
|
107
|
+
status=400,
|
|
108
|
+
)
|
|
109
|
+
if metadata.protocol_version not in supported_versions:
|
|
110
|
+
return McpHttpResult.error(
|
|
111
|
+
request.id,
|
|
112
|
+
JsonRpcErrorCode.UNSUPPORTED_PROTOCOL_VERSION,
|
|
113
|
+
"Unsupported protocol version.",
|
|
114
|
+
status=400,
|
|
115
|
+
data=UnsupportedProtocolVersionData(
|
|
116
|
+
supported=list(supported_versions), requested=metadata.protocol_version
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
return None
|
thin_mcp/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
thin_mcp/responses.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
from thin_mcp.schemas import (
|
|
6
|
+
JsonRpcError,
|
|
7
|
+
JsonRpcErrorResponse,
|
|
8
|
+
JsonRpcResultResponse,
|
|
9
|
+
McpRequestId,
|
|
10
|
+
ToolResult,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class McpHttpResult:
|
|
16
|
+
"""A protocol response together with its HTTP transport status."""
|
|
17
|
+
|
|
18
|
+
status: int = 200
|
|
19
|
+
body: JsonRpcErrorResponse | JsonRpcResultResponse | None = None
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def success(
|
|
23
|
+
cls,
|
|
24
|
+
request_id: McpRequestId | None,
|
|
25
|
+
result: BaseModel,
|
|
26
|
+
) -> "McpHttpResult":
|
|
27
|
+
return cls(body=JsonRpcResultResponse(id=request_id, result=result))
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def error(
|
|
31
|
+
cls,
|
|
32
|
+
request_id: McpRequestId | None,
|
|
33
|
+
code: int,
|
|
34
|
+
message: str,
|
|
35
|
+
*,
|
|
36
|
+
status: int = 200,
|
|
37
|
+
data: BaseModel | None = None,
|
|
38
|
+
) -> "McpHttpResult":
|
|
39
|
+
return cls(
|
|
40
|
+
status=status,
|
|
41
|
+
body=JsonRpcErrorResponse(
|
|
42
|
+
id=request_id,
|
|
43
|
+
error=JsonRpcError(code=code, message=message, data=data),
|
|
44
|
+
),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def accepted(cls) -> "McpHttpResult":
|
|
49
|
+
return cls(status=202)
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def is_error(self) -> bool:
|
|
53
|
+
return (
|
|
54
|
+
self.status >= 400
|
|
55
|
+
or isinstance(self.body, JsonRpcErrorResponse)
|
|
56
|
+
or (
|
|
57
|
+
isinstance(self.body, JsonRpcResultResponse)
|
|
58
|
+
and isinstance(self.body.result, ToolResult)
|
|
59
|
+
and self.body.result.is_error
|
|
60
|
+
)
|
|
61
|
+
)
|
thin_mcp/schemas.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
from typing import Annotated, Any, Literal
|
|
2
|
+
|
|
3
|
+
from pydantic import (
|
|
4
|
+
BaseModel,
|
|
5
|
+
ConfigDict,
|
|
6
|
+
Field,
|
|
7
|
+
SerializeAsAny,
|
|
8
|
+
StringConstraints,
|
|
9
|
+
model_validator,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
McpRequestId = str | int
|
|
13
|
+
McpMethod = Annotated[
|
|
14
|
+
str,
|
|
15
|
+
StringConstraints(
|
|
16
|
+
strip_whitespace=True,
|
|
17
|
+
min_length=1,
|
|
18
|
+
max_length=128,
|
|
19
|
+
pattern=r"^[A-Za-z0-9_.\-/]+$",
|
|
20
|
+
),
|
|
21
|
+
]
|
|
22
|
+
McpToolName = Annotated[
|
|
23
|
+
str,
|
|
24
|
+
StringConstraints(
|
|
25
|
+
strip_whitespace=True,
|
|
26
|
+
min_length=1,
|
|
27
|
+
max_length=128,
|
|
28
|
+
pattern=r"^[A-Za-z0-9_.\-]+$",
|
|
29
|
+
),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class McpRequest(BaseModel):
|
|
34
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
35
|
+
|
|
36
|
+
jsonrpc: Literal["2.0"]
|
|
37
|
+
id: McpRequestId | None = None
|
|
38
|
+
method: McpMethod
|
|
39
|
+
params: dict[str, Any] = Field(default_factory=dict)
|
|
40
|
+
|
|
41
|
+
@model_validator(mode="after")
|
|
42
|
+
def validate_request_id(self) -> "McpRequest":
|
|
43
|
+
if "id" in self.model_fields_set and (
|
|
44
|
+
self.id is None or isinstance(self.id, bool)
|
|
45
|
+
):
|
|
46
|
+
raise ValueError("Request id must be a string or integer.")
|
|
47
|
+
return self
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def is_notification(self) -> bool:
|
|
51
|
+
return "id" not in self.model_fields_set
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class EmptyToolArguments(BaseModel):
|
|
55
|
+
model_config = ConfigDict(extra="forbid")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ToolCallParams(BaseModel):
|
|
59
|
+
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
|
60
|
+
|
|
61
|
+
name: McpToolName
|
|
62
|
+
arguments: dict[str, Any] = Field(default_factory=dict)
|
|
63
|
+
meta: dict[str, Any] = Field(default_factory=dict, alias="_meta")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ImplementationInfo(BaseModel):
|
|
67
|
+
model_config = ConfigDict(extra="allow")
|
|
68
|
+
|
|
69
|
+
name: str = Field(min_length=1, max_length=255)
|
|
70
|
+
version: str = Field(min_length=1, max_length=100)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class InitializeParams(BaseModel):
|
|
74
|
+
model_config = ConfigDict(extra="allow")
|
|
75
|
+
|
|
76
|
+
protocol_version: str = Field(alias="protocolVersion")
|
|
77
|
+
capabilities: dict[str, Any]
|
|
78
|
+
client_info: ImplementationInfo = Field(alias="clientInfo")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class RequestMetadata(BaseModel):
|
|
82
|
+
model_config = ConfigDict(extra="allow", strict=True)
|
|
83
|
+
|
|
84
|
+
protocol_version: str = Field(alias="io.modelcontextprotocol/protocolVersion")
|
|
85
|
+
client_capabilities: dict[str, Any] = Field(
|
|
86
|
+
alias="io.modelcontextprotocol/clientCapabilities"
|
|
87
|
+
)
|
|
88
|
+
client_info: ImplementationInfo | None = Field(
|
|
89
|
+
default=None, alias="io.modelcontextprotocol/clientInfo"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class ResultMetadata(BaseModel):
|
|
94
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
95
|
+
|
|
96
|
+
server_info: ImplementationInfo = Field(alias="io.modelcontextprotocol/serverInfo")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class McpResult(BaseModel):
|
|
100
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
101
|
+
|
|
102
|
+
result_type: Literal["complete"] | None = Field(
|
|
103
|
+
default=None, alias="resultType", exclude_if=lambda value: value is None
|
|
104
|
+
)
|
|
105
|
+
meta: ResultMetadata | None = Field(
|
|
106
|
+
default=None, alias="_meta", exclude_if=lambda value: value is None
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class CacheableResult(McpResult):
|
|
111
|
+
ttl_ms: int | None = Field(
|
|
112
|
+
default=None, alias="ttlMs", ge=0, exclude_if=lambda value: value is None
|
|
113
|
+
)
|
|
114
|
+
cache_scope: Literal["private", "public"] | None = Field(
|
|
115
|
+
default=None, alias="cacheScope", exclude_if=lambda value: value is None
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class ToolAnnotations(BaseModel):
|
|
120
|
+
model_config = ConfigDict(populate_by_name=True, extra="forbid")
|
|
121
|
+
|
|
122
|
+
read_only_hint: bool = Field(default=False, alias="readOnlyHint")
|
|
123
|
+
destructive_hint: bool = Field(default=True, alias="destructiveHint")
|
|
124
|
+
idempotent_hint: bool = Field(default=False, alias="idempotentHint")
|
|
125
|
+
open_world_hint: bool = Field(default=True, alias="openWorldHint")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class ToolDefinition(BaseModel):
|
|
129
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
130
|
+
|
|
131
|
+
name: McpToolName
|
|
132
|
+
description: str
|
|
133
|
+
input_schema: dict[str, Any] = Field(alias="inputSchema")
|
|
134
|
+
output_schema: dict[str, Any] = Field(alias="outputSchema")
|
|
135
|
+
annotations: ToolAnnotations = Field(default_factory=ToolAnnotations)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class ToolsListResult(CacheableResult):
|
|
139
|
+
tools: list[ToolDefinition]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class TextContent(BaseModel):
|
|
143
|
+
type: Literal["text"] = "text"
|
|
144
|
+
text: str
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class ToolResult(McpResult):
|
|
148
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
149
|
+
|
|
150
|
+
content: list[TextContent]
|
|
151
|
+
structured_content: SerializeAsAny[BaseModel] | None = Field(
|
|
152
|
+
default=None,
|
|
153
|
+
alias="structuredContent",
|
|
154
|
+
exclude_if=lambda value: value is None,
|
|
155
|
+
)
|
|
156
|
+
is_error: bool = Field(default=False, alias="isError")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class EmptyResult(McpResult):
|
|
160
|
+
model_config = ConfigDict(extra="forbid")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class ToolCapabilities(BaseModel):
|
|
164
|
+
list_changed: bool = Field(default=False, alias="listChanged")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class ServerCapabilities(BaseModel):
|
|
168
|
+
tools: ToolCapabilities = Field(default_factory=ToolCapabilities)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class InitializeResult(McpResult):
|
|
172
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
173
|
+
|
|
174
|
+
protocol_version: str = Field(alias="protocolVersion")
|
|
175
|
+
capabilities: ServerCapabilities = Field(default_factory=ServerCapabilities)
|
|
176
|
+
server_info: ImplementationInfo = Field(alias="serverInfo")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class DiscoverResult(CacheableResult):
|
|
180
|
+
supported_versions: list[str] = Field(alias="supportedVersions")
|
|
181
|
+
capabilities: ServerCapabilities = Field(default_factory=ServerCapabilities)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class UnsupportedProtocolVersionData(BaseModel):
|
|
185
|
+
supported: list[str]
|
|
186
|
+
requested: str
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class JsonRpcError(BaseModel):
|
|
190
|
+
code: int
|
|
191
|
+
message: str
|
|
192
|
+
data: SerializeAsAny[BaseModel] | None = Field(
|
|
193
|
+
default=None, exclude_if=lambda value: value is None
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class JsonRpcErrorResponse(BaseModel):
|
|
198
|
+
jsonrpc: Literal["2.0"] = "2.0"
|
|
199
|
+
id: McpRequestId | None
|
|
200
|
+
error: JsonRpcError
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class JsonRpcResultResponse(BaseModel):
|
|
204
|
+
jsonrpc: Literal["2.0"] = "2.0"
|
|
205
|
+
id: McpRequestId | None
|
|
206
|
+
result: SerializeAsAny[BaseModel]
|
thin_mcp/server.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import ValidationError
|
|
6
|
+
|
|
7
|
+
from thin_mcp.enums import McpMethodName
|
|
8
|
+
from thin_mcp.error_codes import JsonRpcErrorCode
|
|
9
|
+
from thin_mcp.protocol import (
|
|
10
|
+
McpRequestHeaders,
|
|
11
|
+
has_protocol_metadata,
|
|
12
|
+
is_modern_version,
|
|
13
|
+
uses_modern_protocol,
|
|
14
|
+
validate_modern_request,
|
|
15
|
+
)
|
|
16
|
+
from thin_mcp.responses import McpHttpResult
|
|
17
|
+
from thin_mcp.schemas import (
|
|
18
|
+
CacheableResult,
|
|
19
|
+
DiscoverResult,
|
|
20
|
+
EmptyResult,
|
|
21
|
+
ImplementationInfo,
|
|
22
|
+
InitializeParams,
|
|
23
|
+
InitializeResult,
|
|
24
|
+
JsonRpcResultResponse,
|
|
25
|
+
McpRequest,
|
|
26
|
+
McpResult,
|
|
27
|
+
ResultMetadata,
|
|
28
|
+
ServerCapabilities,
|
|
29
|
+
ToolCallParams,
|
|
30
|
+
ToolDefinition,
|
|
31
|
+
ToolsListResult,
|
|
32
|
+
)
|
|
33
|
+
from thin_mcp.tools import ToolRegistry
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class McpServer:
|
|
38
|
+
"""Configured MCP protocol dispatcher independent of application policy."""
|
|
39
|
+
|
|
40
|
+
name: str
|
|
41
|
+
version: str
|
|
42
|
+
protocol_versions: tuple[str, ...]
|
|
43
|
+
tools: ToolRegistry
|
|
44
|
+
|
|
45
|
+
def __post_init__(self) -> None:
|
|
46
|
+
if not self.protocol_versions:
|
|
47
|
+
raise ValueError("At least one MCP protocol version is required.")
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def latest_protocol_version(self) -> str:
|
|
51
|
+
return self.protocol_versions[-1]
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def legacy_protocol_versions(self) -> tuple[str, ...]:
|
|
55
|
+
return tuple(v for v in self.protocol_versions if not is_modern_version(v))
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def modern_protocol_versions(self) -> tuple[str, ...]:
|
|
59
|
+
return tuple(v for v in self.protocol_versions if is_modern_version(v))
|
|
60
|
+
|
|
61
|
+
def get_tool_definitions(self) -> list[ToolDefinition]:
|
|
62
|
+
return [tool.definition() for tool in self.tools.all()]
|
|
63
|
+
|
|
64
|
+
def validate_http_request(
|
|
65
|
+
self,
|
|
66
|
+
request: McpRequest,
|
|
67
|
+
headers: McpRequestHeaders,
|
|
68
|
+
) -> McpHttpResult | None:
|
|
69
|
+
if uses_modern_protocol(request, headers):
|
|
70
|
+
if self.modern_protocol_versions:
|
|
71
|
+
return validate_modern_request(
|
|
72
|
+
request, headers, self.modern_protocol_versions
|
|
73
|
+
)
|
|
74
|
+
return McpHttpResult.error(
|
|
75
|
+
request.id,
|
|
76
|
+
JsonRpcErrorCode.SERVER_ERROR,
|
|
77
|
+
"Unsupported MCP-Protocol-Version.",
|
|
78
|
+
status=400,
|
|
79
|
+
)
|
|
80
|
+
if request.method == McpMethodName.INITIALIZE and self.legacy_protocol_versions:
|
|
81
|
+
return None
|
|
82
|
+
if (
|
|
83
|
+
headers.protocol_version or "2025-03-26"
|
|
84
|
+
) not in self.legacy_protocol_versions:
|
|
85
|
+
return McpHttpResult.error(
|
|
86
|
+
request.id,
|
|
87
|
+
JsonRpcErrorCode.SERVER_ERROR,
|
|
88
|
+
"Unsupported MCP-Protocol-Version.",
|
|
89
|
+
status=400,
|
|
90
|
+
)
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
def format_result(
|
|
94
|
+
self, request: McpRequest, result: McpHttpResult
|
|
95
|
+
) -> McpHttpResult:
|
|
96
|
+
"""Add modern metadata to successful results for modern requests."""
|
|
97
|
+
if has_protocol_metadata(request) and isinstance(
|
|
98
|
+
result.body, JsonRpcResultResponse
|
|
99
|
+
):
|
|
100
|
+
if not isinstance(result.body.result, McpResult):
|
|
101
|
+
raise TypeError("MCP results must inherit McpResult.")
|
|
102
|
+
body = result.body.result.model_copy(
|
|
103
|
+
update={
|
|
104
|
+
"result_type": "complete",
|
|
105
|
+
"meta": ResultMetadata(
|
|
106
|
+
**{
|
|
107
|
+
"io.modelcontextprotocol/serverInfo": ImplementationInfo(
|
|
108
|
+
name=self.name, version=self.version
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
),
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
if isinstance(body, CacheableResult):
|
|
115
|
+
body = body.model_copy(update={"ttl_ms": 0, "cache_scope": "private"})
|
|
116
|
+
return McpHttpResult(
|
|
117
|
+
status=result.status,
|
|
118
|
+
body=result.body.model_copy(update={"result": body}),
|
|
119
|
+
)
|
|
120
|
+
return result
|
|
121
|
+
|
|
122
|
+
def discover(self, request: McpRequest) -> McpHttpResult:
|
|
123
|
+
return McpHttpResult.success(
|
|
124
|
+
request.id,
|
|
125
|
+
DiscoverResult(supportedVersions=list(self.modern_protocol_versions)),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def call_tool(
|
|
129
|
+
self,
|
|
130
|
+
user: Any,
|
|
131
|
+
request: McpRequest,
|
|
132
|
+
*,
|
|
133
|
+
resources: tuple[object, ...] = (),
|
|
134
|
+
) -> McpHttpResult:
|
|
135
|
+
try:
|
|
136
|
+
tool_call = ToolCallParams.model_validate(request.params)
|
|
137
|
+
except ValidationError:
|
|
138
|
+
return McpHttpResult.error(
|
|
139
|
+
request.id,
|
|
140
|
+
JsonRpcErrorCode.INVALID_PARAMS,
|
|
141
|
+
"Tool name and arguments are required.",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
tool = self.tools.get(tool_call.name)
|
|
145
|
+
if tool is None:
|
|
146
|
+
return McpHttpResult.error(
|
|
147
|
+
request.id,
|
|
148
|
+
JsonRpcErrorCode.INVALID_PARAMS,
|
|
149
|
+
f"Unknown tool: {tool_call.name}",
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
result = tool.execute(
|
|
153
|
+
user,
|
|
154
|
+
tool_call.arguments,
|
|
155
|
+
resources=resources,
|
|
156
|
+
)
|
|
157
|
+
return McpHttpResult.success(request.id, result)
|
|
158
|
+
|
|
159
|
+
def initialize(self, request: McpRequest) -> McpHttpResult:
|
|
160
|
+
if not self.legacy_protocol_versions:
|
|
161
|
+
return self.method_not_found(request)
|
|
162
|
+
try:
|
|
163
|
+
params = InitializeParams.model_validate(request.params)
|
|
164
|
+
except ValidationError:
|
|
165
|
+
return McpHttpResult.error(
|
|
166
|
+
request.id,
|
|
167
|
+
JsonRpcErrorCode.INVALID_PARAMS,
|
|
168
|
+
"Invalid initialize parameters.",
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
protocol_version = (
|
|
172
|
+
params.protocol_version
|
|
173
|
+
if params.protocol_version in self.legacy_protocol_versions
|
|
174
|
+
else self.legacy_protocol_versions[-1]
|
|
175
|
+
)
|
|
176
|
+
result = InitializeResult(
|
|
177
|
+
protocolVersion=protocol_version,
|
|
178
|
+
capabilities=ServerCapabilities(),
|
|
179
|
+
serverInfo=ImplementationInfo(name=self.name, version=self.version),
|
|
180
|
+
)
|
|
181
|
+
return McpHttpResult.success(request.id, result)
|
|
182
|
+
|
|
183
|
+
def ping(self, request: McpRequest) -> McpHttpResult:
|
|
184
|
+
return McpHttpResult.success(request.id, EmptyResult())
|
|
185
|
+
|
|
186
|
+
def list_tools(self, request: McpRequest) -> McpHttpResult:
|
|
187
|
+
result = ToolsListResult(tools=self.get_tool_definitions())
|
|
188
|
+
return McpHttpResult.success(request.id, result)
|
|
189
|
+
|
|
190
|
+
def method_not_found(self, request: McpRequest) -> McpHttpResult:
|
|
191
|
+
status = 404 if has_protocol_metadata(request) else 200
|
|
192
|
+
return McpHttpResult.error(
|
|
193
|
+
request.id,
|
|
194
|
+
JsonRpcErrorCode.METHOD_NOT_FOUND,
|
|
195
|
+
"Method not found.",
|
|
196
|
+
status=status,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
def dispatch_request(
|
|
200
|
+
self,
|
|
201
|
+
user: Any,
|
|
202
|
+
request: McpRequest,
|
|
203
|
+
*,
|
|
204
|
+
resources: tuple[object, ...] = (),
|
|
205
|
+
) -> McpHttpResult:
|
|
206
|
+
return self.format_result(
|
|
207
|
+
request, self.dispatch_method(user, request, resources=resources)
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
def dispatch_method(
|
|
211
|
+
self,
|
|
212
|
+
user: Any,
|
|
213
|
+
request: McpRequest,
|
|
214
|
+
*,
|
|
215
|
+
resources: tuple[object, ...] = (),
|
|
216
|
+
) -> McpHttpResult:
|
|
217
|
+
if request.is_notification:
|
|
218
|
+
return McpHttpResult.accepted()
|
|
219
|
+
|
|
220
|
+
handlers: dict[str, Callable[[], McpHttpResult]] = {
|
|
221
|
+
McpMethodName.PING: lambda: self.ping(request),
|
|
222
|
+
McpMethodName.TOOLS_LIST: lambda: self.list_tools(request),
|
|
223
|
+
McpMethodName.TOOLS_CALL: lambda: self.call_tool(
|
|
224
|
+
user, request, resources=resources
|
|
225
|
+
),
|
|
226
|
+
}
|
|
227
|
+
if has_protocol_metadata(request):
|
|
228
|
+
handlers[McpMethodName.SERVER_DISCOVER] = lambda: self.discover(request)
|
|
229
|
+
else:
|
|
230
|
+
handlers[McpMethodName.INITIALIZE] = lambda: self.initialize(request)
|
|
231
|
+
|
|
232
|
+
handler = handlers.get(request.method)
|
|
233
|
+
return handler() if handler is not None else self.method_not_found(request)
|
thin_mcp/tools.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from typing import Any, Protocol, TypeVar
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ValidationError
|
|
6
|
+
|
|
7
|
+
from thin_mcp.schemas import (
|
|
8
|
+
TextContent,
|
|
9
|
+
ToolAnnotations,
|
|
10
|
+
ToolDefinition,
|
|
11
|
+
ToolResult,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
ArgumentsT = TypeVar("ArgumentsT", bound=BaseModel)
|
|
15
|
+
ResultT = TypeVar("ResultT", bound=BaseModel)
|
|
16
|
+
ResourceT = TypeVar("ResourceT")
|
|
17
|
+
ToolHandler = Callable[["ToolContext", ArgumentsT], ResultT]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ToolExecutionError(Exception):
|
|
21
|
+
"""Raised for an expected tool failure that a client can act on."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ToolPermissionDenied(ToolExecutionError):
|
|
25
|
+
"""Raised when a tool permission rejects the authenticated caller."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ToolContext:
|
|
30
|
+
user: Any
|
|
31
|
+
resources: dict[type[Any], Any] = field(default_factory=dict)
|
|
32
|
+
|
|
33
|
+
def set_resource(self, resource: object) -> None:
|
|
34
|
+
self.resources[type(resource)] = resource
|
|
35
|
+
|
|
36
|
+
def require_resource(self, resource_type: type[ResourceT]) -> ResourceT:
|
|
37
|
+
try:
|
|
38
|
+
resource = self.resources[resource_type]
|
|
39
|
+
except KeyError:
|
|
40
|
+
raise RuntimeError(
|
|
41
|
+
f"{resource_type.__name__} permission was not evaluated."
|
|
42
|
+
) from None
|
|
43
|
+
if not isinstance(resource, resource_type):
|
|
44
|
+
raise TypeError(f"Invalid resource type for {resource_type.__name__}.")
|
|
45
|
+
return resource
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ToolPermission(Protocol):
|
|
49
|
+
def check(self, context: ToolContext, arguments: BaseModel) -> None:
|
|
50
|
+
"""Raise ToolPermissionDenied when access should be rejected."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def tool_result[ResultT: BaseModel](
|
|
54
|
+
data: ResultT,
|
|
55
|
+
) -> ToolResult:
|
|
56
|
+
"""Format a structured tool result with a concise text fallback."""
|
|
57
|
+
return ToolResult(
|
|
58
|
+
content=[TextContent(text=data.model_dump_json(by_alias=True))],
|
|
59
|
+
structuredContent=data,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def tool_error_result(message: str) -> ToolResult:
|
|
64
|
+
"""Format an actionable tool failure without violating its output schema."""
|
|
65
|
+
return ToolResult(
|
|
66
|
+
content=[TextContent(text=message)],
|
|
67
|
+
isError=True,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class McpTool[ArgumentsT: BaseModel, ResultT: BaseModel]:
|
|
73
|
+
"""One typed, permission-checked MCP tool declaration."""
|
|
74
|
+
|
|
75
|
+
name: str
|
|
76
|
+
description: str
|
|
77
|
+
arguments_model: type[ArgumentsT]
|
|
78
|
+
result_model: type[ResultT]
|
|
79
|
+
handler: ToolHandler[ArgumentsT, ResultT]
|
|
80
|
+
invalid_arguments_message: str
|
|
81
|
+
permissions: tuple[ToolPermission, ...] = ()
|
|
82
|
+
annotations: ToolAnnotations = field(default_factory=ToolAnnotations)
|
|
83
|
+
|
|
84
|
+
def definition(self) -> ToolDefinition:
|
|
85
|
+
return ToolDefinition(
|
|
86
|
+
name=self.name,
|
|
87
|
+
description=self.description,
|
|
88
|
+
inputSchema=self.arguments_model.model_json_schema(),
|
|
89
|
+
outputSchema=self.result_model.model_json_schema(),
|
|
90
|
+
annotations=self.annotations,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def execute(
|
|
94
|
+
self,
|
|
95
|
+
user: Any,
|
|
96
|
+
raw_arguments: dict[str, Any],
|
|
97
|
+
*,
|
|
98
|
+
resources: tuple[object, ...] = (),
|
|
99
|
+
) -> ToolResult:
|
|
100
|
+
try:
|
|
101
|
+
arguments = self.arguments_model.model_validate(raw_arguments)
|
|
102
|
+
except ValidationError:
|
|
103
|
+
return tool_error_result(self.invalid_arguments_message)
|
|
104
|
+
|
|
105
|
+
context = ToolContext(user=user)
|
|
106
|
+
for resource in resources:
|
|
107
|
+
context.set_resource(resource)
|
|
108
|
+
try:
|
|
109
|
+
for permission in self.permissions:
|
|
110
|
+
permission.check(context, arguments)
|
|
111
|
+
raw_result = self.handler(context, arguments)
|
|
112
|
+
except ToolExecutionError as exc:
|
|
113
|
+
return tool_error_result(str(exc))
|
|
114
|
+
|
|
115
|
+
result = self.result_model.model_validate(raw_result)
|
|
116
|
+
return tool_result(result)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class ToolRegistry:
|
|
120
|
+
"""Own an explicit, deterministic set of MCP tool declarations."""
|
|
121
|
+
|
|
122
|
+
def __init__(self) -> None:
|
|
123
|
+
self.tools: dict[str, McpTool[Any, Any]] = {}
|
|
124
|
+
|
|
125
|
+
def register(
|
|
126
|
+
self,
|
|
127
|
+
*,
|
|
128
|
+
name: str,
|
|
129
|
+
description: str,
|
|
130
|
+
arguments_model: type[ArgumentsT],
|
|
131
|
+
result_model: type[ResultT],
|
|
132
|
+
permissions: tuple[ToolPermission, ...] = (),
|
|
133
|
+
invalid_arguments_message: str = "Invalid tool arguments.",
|
|
134
|
+
annotations: ToolAnnotations | None = None,
|
|
135
|
+
) -> Callable[[ToolHandler[ArgumentsT, ResultT]], ToolHandler[ArgumentsT, ResultT]]:
|
|
136
|
+
def decorator(
|
|
137
|
+
handler: ToolHandler[ArgumentsT, ResultT],
|
|
138
|
+
) -> ToolHandler[ArgumentsT, ResultT]:
|
|
139
|
+
if name in self.tools:
|
|
140
|
+
raise ValueError(f"Duplicate MCP tool name: {name}")
|
|
141
|
+
self.tools[name] = McpTool(
|
|
142
|
+
name=name,
|
|
143
|
+
description=description,
|
|
144
|
+
arguments_model=arguments_model,
|
|
145
|
+
result_model=result_model,
|
|
146
|
+
handler=handler,
|
|
147
|
+
invalid_arguments_message=invalid_arguments_message,
|
|
148
|
+
permissions=permissions,
|
|
149
|
+
annotations=(
|
|
150
|
+
annotations if annotations is not None else ToolAnnotations()
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
return handler
|
|
154
|
+
|
|
155
|
+
return decorator
|
|
156
|
+
|
|
157
|
+
def all(self) -> tuple[McpTool[Any, Any], ...]:
|
|
158
|
+
return tuple(self.tools.values())
|
|
159
|
+
|
|
160
|
+
def get(self, name: str) -> McpTool[Any, Any] | None:
|
|
161
|
+
return self.tools.get(name)
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: thin-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A small typed framework for building MCP tool servers
|
|
5
|
+
Project-URL: Changelog, https://github.com/wild-edge/thin-mcp/blob/main/CHANGELOG.md
|
|
6
|
+
Project-URL: Issues, https://github.com/wild-edge/thin-mcp/issues
|
|
7
|
+
Project-URL: Repository, https://github.com/wild-edge/thin-mcp
|
|
8
|
+
Author: Wild Edge contributors
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: json-rpc,mcp,model-context-protocol,pydantic
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Framework :: Django
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.12
|
|
23
|
+
Requires-Dist: pydantic<3,>=2.12
|
|
24
|
+
Provides-Extra: django
|
|
25
|
+
Requires-Dist: django-ninja>=1.5.1; extra == 'django'
|
|
26
|
+
Requires-Dist: django>=4.2; extra == 'django'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# thin-mcp
|
|
30
|
+
|
|
31
|
+
`thin-mcp` is a small, typed framework for implementing the tool-serving
|
|
32
|
+
surface of the [Model Context Protocol](https://modelcontextprotocol.io/).
|
|
33
|
+
It connects strict Pydantic models to an explicit tool registry and a
|
|
34
|
+
transport-independent protocol dispatcher.
|
|
35
|
+
|
|
36
|
+

|
|
37
|
+
|
|
38
|
+
The package deliberately does not own authentication, application permissions,
|
|
39
|
+
throttling, auditing, Django models, or business logic. A Django application
|
|
40
|
+
should provide a small integration package—typically named `mcp_server/`—that
|
|
41
|
+
registers tools from its application domains and connects the framework to its
|
|
42
|
+
HTTP and security layers.
|
|
43
|
+
|
|
44
|
+
## Why Django Ninja?
|
|
45
|
+
|
|
46
|
+
Django API projects are often built with Django REST Framework (DRF). For this
|
|
47
|
+
project, I wanted Pydantic models to define and validate the MCP-facing data,
|
|
48
|
+
so I chose Django Ninja for the optional Django HTTP adapter. The protocol
|
|
49
|
+
dispatcher and typed tool registry remain framework-neutral; applications can
|
|
50
|
+
also connect them to another framework, such as FastAPI, with a small route
|
|
51
|
+
adapter.
|
|
52
|
+
|
|
53
|
+
## Installation
|
|
54
|
+
|
|
55
|
+
Install the core package:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install thin-mcp
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Install the optional Django Ninja adapter:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pip install "thin-mcp[django]"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Python 3.12 or newer and Pydantic 2.12 or newer are required.
|
|
68
|
+
|
|
69
|
+
## Recommended Django integration
|
|
70
|
+
|
|
71
|
+
Keep the MCP integration in the host application, separate from both
|
|
72
|
+
`thin_mcp` and the domain applications it exposes:
|
|
73
|
+
|
|
74
|
+
```text
|
|
75
|
+
my_project/
|
|
76
|
+
├── accounts/
|
|
77
|
+
│ └── services.py
|
|
78
|
+
├── projects/
|
|
79
|
+
│ └── services.py
|
|
80
|
+
└── mcp_server/
|
|
81
|
+
├── api.py
|
|
82
|
+
├── core.py
|
|
83
|
+
├── permissions.py
|
|
84
|
+
├── schemas.py
|
|
85
|
+
├── tools_registry.py
|
|
86
|
+
└── tools/
|
|
87
|
+
├── __init__.py
|
|
88
|
+
├── accounts.py
|
|
89
|
+
└── projects.py
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The recommended pattern is registration-driven:
|
|
93
|
+
|
|
94
|
+
1. `tools_registry.py` owns one `ToolRegistry` and, when needed, a host-specific
|
|
95
|
+
`register_tool` wrapper that requires every tool to declare application
|
|
96
|
+
policy such as token access.
|
|
97
|
+
2. Each `tools/<domain>.py` module defines typed adapters for one Django domain.
|
|
98
|
+
3. `tools/__init__.py` explicitly imports every domain module in stable order.
|
|
99
|
+
Importing this package populates the shared registry.
|
|
100
|
+
4. `core.py` constructs one `McpServer` with that populated registry.
|
|
101
|
+
5. `api.py` handles HTTP, authentication, throttling, and auditing, then delegates
|
|
102
|
+
the validated request to `core.py`.
|
|
103
|
+
|
|
104
|
+
This makes `tools/list` deterministic and ensures the schema advertised for a
|
|
105
|
+
tool is the schema enforced when that tool runs. Avoid filesystem auto-discovery
|
|
106
|
+
or registration hidden in Django app startup hooks.
|
|
107
|
+
|
|
108
|
+
See [GUIDE.md](GUIDE.md) for a complete generic Django example, permission
|
|
109
|
+
patterns, tests, and migration steps.
|
|
110
|
+
|
|
111
|
+
## Minimal framework example
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from pydantic import BaseModel, ConfigDict
|
|
115
|
+
|
|
116
|
+
from thin_mcp import McpRequest, McpServer, ToolContext, ToolRegistry
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class EchoArguments(BaseModel):
|
|
120
|
+
model_config = ConfigDict(extra="forbid")
|
|
121
|
+
|
|
122
|
+
text: str
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class EchoResult(BaseModel):
|
|
126
|
+
text: str
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
registry = ToolRegistry()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@registry.register(
|
|
133
|
+
name="echo",
|
|
134
|
+
description="Echo typed text.",
|
|
135
|
+
arguments_model=EchoArguments,
|
|
136
|
+
result_model=EchoResult,
|
|
137
|
+
)
|
|
138
|
+
def echo(context: ToolContext, arguments: EchoArguments) -> EchoResult:
|
|
139
|
+
return EchoResult(text=arguments.text)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
server = McpServer(
|
|
143
|
+
name="example",
|
|
144
|
+
version="1.0.0",
|
|
145
|
+
protocol_versions=("2025-11-25",),
|
|
146
|
+
tools=registry,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
request = McpRequest.model_validate(
|
|
150
|
+
{
|
|
151
|
+
"jsonrpc": "2.0",
|
|
152
|
+
"id": 1,
|
|
153
|
+
"method": "tools/call",
|
|
154
|
+
"params": {"name": "echo", "arguments": {"text": "hello"}},
|
|
155
|
+
}
|
|
156
|
+
)
|
|
157
|
+
response = server.dispatch_request(user=None, request=request)
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
The host validates transport input into `McpRequest`, supplies its authenticated
|
|
161
|
+
user and request-scoped resources, and serializes the returned `McpHttpResult`.
|
|
162
|
+
Django Ninja hosts can use `thin_mcp.django_ninja.McpAPI` and
|
|
163
|
+
`render_mcp_result` for exception mapping and HTTP serialization.
|
|
164
|
+
|
|
165
|
+
## Supported MCP surface
|
|
166
|
+
|
|
167
|
+
The dispatcher currently supports:
|
|
168
|
+
|
|
169
|
+
- `initialize`
|
|
170
|
+
- `server/discover` for the modern protocol flow
|
|
171
|
+
- `ping`
|
|
172
|
+
- `tools/list`
|
|
173
|
+
- `tools/call`
|
|
174
|
+
- notifications that require no response body
|
|
175
|
+
|
|
176
|
+
Legacy handshake protocol versions and the modern `2026-07-28` request metadata
|
|
177
|
+
flow are supported. For modern Streamable HTTP requests, the host builds
|
|
178
|
+
`McpRequestHeaders` from the incoming headers, calls
|
|
179
|
+
`server.validate_http_request(request, headers)`, returns any validation result,
|
|
180
|
+
and otherwise dispatches the request. Modern responses include protocol result
|
|
181
|
+
metadata and disable caching for discovery and tool listings.
|
|
182
|
+
|
|
183
|
+
Resources, prompts, sampling, elicitation, tasks, sessions, server-initiated
|
|
184
|
+
notifications, and streaming are not implemented.
|
|
185
|
+
|
|
186
|
+
## Development
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
uv sync --extra django --group dev
|
|
190
|
+
uv run ruff check .
|
|
191
|
+
uv run mypy
|
|
192
|
+
uv run pytest
|
|
193
|
+
uv build
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Repository contributors should also read [AGENTS.md](AGENTS.md).
|
|
197
|
+
Maintainers can follow [Publishing to PyPI](docs/releasing.md) to configure
|
|
198
|
+
Trusted Publishing and release from GitHub Actions.
|
|
199
|
+
|
|
200
|
+
## Versioning and license
|
|
201
|
+
|
|
202
|
+
`thin-mcp` follows Semantic Versioning. Before `1.0.0`, incompatible public API
|
|
203
|
+
changes may be released in a new minor version. After `1.0.0`, incompatible
|
|
204
|
+
changes require a major version.
|
|
205
|
+
|
|
206
|
+
The package is distributed under the [MIT License](LICENSE).
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
thin_mcp/__init__.py,sha256=nnJFeKxeSz_vB7xyAvuwP9uwbx2xcAH-Y9WbQrDdgLc,902
|
|
2
|
+
thin_mcp/django_ninja.py,sha256=AkZ1P437r4bXV6XnLloF8bPphfVopSjV7khPMeYxFSs,5480
|
|
3
|
+
thin_mcp/enums.py,sha256=D2e5mMtuP0D_zhT0M5ctDQ8p_KqNfNc3wGuOaB7PREo,401
|
|
4
|
+
thin_mcp/error_codes.py,sha256=DljeLD1S1tq_s71JjFxa7ZjY3mczS5U6dXs8bd5f4zo,423
|
|
5
|
+
thin_mcp/protocol.py,sha256=VNuGLcb-JJH5aYKpDagiQEedfMUdUzeCmazC5UoWigM,4131
|
|
6
|
+
thin_mcp/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
7
|
+
thin_mcp/responses.py,sha256=uubsIeFXu3dqFPNLgiYGPXzbEUqwGCaBcDnk_0S5csg,1536
|
|
8
|
+
thin_mcp/schemas.py,sha256=fMJ4CziCiNHWwenoS-HvH91V8b08PBMM6qqS4YRj8iQ,5772
|
|
9
|
+
thin_mcp/server.py,sha256=xghs2z1bXJnC8L1GD63l05pgCrsOiNsPBU-tws33w0E,7854
|
|
10
|
+
thin_mcp/tools.py,sha256=17FpTvbo94MerGZ_Jmfh8viCP3U4tCpadngvy_xxviY,5262
|
|
11
|
+
thin_mcp-0.1.0.dist-info/METADATA,sha256=juPGqcmSMmqQ0OVL1qZhxrFhq0DstmoqdMkvSunLgZk,6590
|
|
12
|
+
thin_mcp-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
13
|
+
thin_mcp-0.1.0.dist-info/licenses/LICENSE,sha256=i_QprAm_jrQJ6uwQw-4ydj_6XSiCbwqw4HEVe7egVMU,1079
|
|
14
|
+
thin_mcp-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Wild Edge contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|