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 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
+ )
@@ -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"
@@ -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
+ ![How thin-mcp fits into a host application](docs/architecture.svg)
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.