plain.mcp 0.0.0__tar.gz
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.
- plain_mcp-0.0.0/.gitignore +24 -0
- plain_mcp-0.0.0/LICENSE +28 -0
- plain_mcp-0.0.0/PKG-INFO +527 -0
- plain_mcp-0.0.0/README.md +1 -0
- plain_mcp-0.0.0/plain/mcp/README.md +516 -0
- plain_mcp-0.0.0/plain/mcp/__init__.py +14 -0
- plain_mcp-0.0.0/plain/mcp/config.py +11 -0
- plain_mcp-0.0.0/plain/mcp/exceptions.py +19 -0
- plain_mcp-0.0.0/plain/mcp/resources.py +162 -0
- plain_mcp-0.0.0/plain/mcp/schema.py +109 -0
- plain_mcp-0.0.0/plain/mcp/tools.py +77 -0
- plain_mcp-0.0.0/plain/mcp/views.py +443 -0
- plain_mcp-0.0.0/pyproject.toml +19 -0
- plain_mcp-0.0.0/tests/app/settings.py +5 -0
- plain_mcp-0.0.0/tests/app/urls.py +52 -0
- plain_mcp-0.0.0/tests/conformance/README.md +43 -0
- plain_mcp-0.0.0/tests/conformance/app/mcp.py +300 -0
- plain_mcp-0.0.0/tests/conformance/app/settings.py +12 -0
- plain_mcp-0.0.0/tests/conformance/app/urls.py +12 -0
- plain_mcp-0.0.0/tests/conformance/expected-failures.yml +43 -0
- plain_mcp-0.0.0/tests/conformance/run +74 -0
- plain_mcp-0.0.0/tests/test_http.py +109 -0
- plain_mcp-0.0.0/tests/test_mcp.py +975 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
.venv
|
|
2
|
+
/.env
|
|
3
|
+
*.egg-info
|
|
4
|
+
*.py[co]
|
|
5
|
+
__pycache__
|
|
6
|
+
*.DS_Store
|
|
7
|
+
|
|
8
|
+
/*.code-workspace
|
|
9
|
+
|
|
10
|
+
# Test apps
|
|
11
|
+
plain*/tests/.plain
|
|
12
|
+
|
|
13
|
+
# Agent scratch files
|
|
14
|
+
/scratch
|
|
15
|
+
|
|
16
|
+
# Plain temp dirs
|
|
17
|
+
.plain
|
|
18
|
+
|
|
19
|
+
.vscode
|
|
20
|
+
/.claude/settings.local.json
|
|
21
|
+
/.claude/skills/announcements/
|
|
22
|
+
/CLAUDE.local.md
|
|
23
|
+
/.benchmarks
|
|
24
|
+
.claude/worktrees
|
plain_mcp-0.0.0/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Dropseed, LLC
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
plain_mcp-0.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: plain.mcp
|
|
3
|
+
Version: 0.0.0
|
|
4
|
+
Summary: MCP (Model Context Protocol) server for Plain apps.
|
|
5
|
+
Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
|
|
6
|
+
License-Expression: BSD-3-Clause
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.13
|
|
9
|
+
Requires-Dist: plain<1.0.0,>=0.129.0
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# plain.mcp
|
|
13
|
+
|
|
14
|
+
**Expose your Plain app to AI clients as an MCP server over HTTP.**
|
|
15
|
+
|
|
16
|
+
- [Overview](#overview)
|
|
17
|
+
- [Tools](#tools)
|
|
18
|
+
- [Resources](#resources)
|
|
19
|
+
- [Naming](#naming)
|
|
20
|
+
- [Multiple MCP endpoints](#multiple-mcp-endpoints)
|
|
21
|
+
- [Attaching tools to a shared MCP](#attaching-tools-to-a-shared-mcp)
|
|
22
|
+
- [Authentication](#authentication)
|
|
23
|
+
- [Session auth](#session-auth-compose-with-authview)
|
|
24
|
+
- [Bearer token auth](#bearer-token-auth)
|
|
25
|
+
- [Public endpoints](#public-endpoints)
|
|
26
|
+
- [Filtering tools per request](#filtering-tools-per-request)
|
|
27
|
+
- [Custom JSON-RPC methods](#custom-json-rpc-methods)
|
|
28
|
+
- [FAQs](#faqs)
|
|
29
|
+
- [Installation](#installation)
|
|
30
|
+
|
|
31
|
+
## Overview
|
|
32
|
+
|
|
33
|
+
An MCP server is a subclass of [`MCPView`](./views.py#MCPView) that declares a list of `MCPTool` subclasses. `MCPView` is a Plain View — you mount it directly in your URLs.
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
# app/mcp.py (auto-discovered on startup)
|
|
37
|
+
from plain.auth.views import AuthView
|
|
38
|
+
from plain.mcp import MCPTool, MCPView
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Greet(MCPTool):
|
|
42
|
+
"""Say hello to someone."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, name: str):
|
|
45
|
+
self.name = name
|
|
46
|
+
|
|
47
|
+
def run(self) -> str:
|
|
48
|
+
return f"Hello, {self.name}!"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class AppMCP(MCPView, AuthView):
|
|
52
|
+
name = "myapp"
|
|
53
|
+
login_required = True
|
|
54
|
+
tools = [Greet]
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Mount it:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
# app/urls.py
|
|
61
|
+
from app.mcp import AppMCP
|
|
62
|
+
from plain.urls import Router, path
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class AppRouter(Router):
|
|
66
|
+
namespace = ""
|
|
67
|
+
urls = [
|
|
68
|
+
path("mcp/", AppMCP, name="mcp"),
|
|
69
|
+
]
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
AI clients connect to `https://yourapp.com/mcp/` using the [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http).
|
|
73
|
+
|
|
74
|
+
`name` is required. `version` defaults to `settings.VERSION` (from your `pyproject.toml`). Auth and authorization are covered below.
|
|
75
|
+
|
|
76
|
+
## Tools
|
|
77
|
+
|
|
78
|
+
Every tool is an [`MCPTool`](./tools.py#MCPTool) subclass. Arguments from the client are accepted through `__init__` (so they're typed, and can later plug into pydantic / validation). `run()` executes the tool with no extra arguments — everything it needs is already on `self`. Metadata is derived automatically:
|
|
79
|
+
|
|
80
|
+
- **Name** defaults to the class name — override with `name = "..."`
|
|
81
|
+
- **Description** comes from the class docstring (used verbatim — override with `description = "..."`)
|
|
82
|
+
- **Input schema** is derived from `__init__`'s typed signature; override by setting `input_schema = {...}` if you need custom per-parameter descriptions or JSON Schema features
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
class SearchOrders(MCPTool):
|
|
86
|
+
"""Search orders by customer name or order ID."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, query: str, limit: int = 10):
|
|
89
|
+
self.query = query
|
|
90
|
+
self.limit = limit
|
|
91
|
+
|
|
92
|
+
def run(self) -> str:
|
|
93
|
+
return "\n".join(str(o) for o in Order.query.filter(...))
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
**Reading the invoking context.** Before `run()` is called, the dispatcher sets `self.mcp` to the `MCPView` instance that invoked the tool. Use it to read the caller's user, the HTTP request, or any subclass-specific state:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from plain.mcp import MCPTool
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class ListMyNotes(MCPTool):
|
|
103
|
+
"""List notes owned by the caller."""
|
|
104
|
+
|
|
105
|
+
def run(self) -> list[dict]:
|
|
106
|
+
return list(
|
|
107
|
+
Note.query.filter(author=self.mcp.user).values("id", "title")
|
|
108
|
+
)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
**Shared state.** Tool instances are short-lived — one per MCP request. Don't use `__init__` for heavy setup; stash lookups in modules or on the MCP class.
|
|
112
|
+
|
|
113
|
+
**Return types.** `run()` returns get converted to MCP content blocks:
|
|
114
|
+
|
|
115
|
+
- **`str`** → one text block
|
|
116
|
+
- **a dict shaped like a content block** (`type` is one of `text`, `image`, `audio`, `resource`, `resource_link`) → that single block
|
|
117
|
+
- **a list of such dicts** → those blocks, in order (mixed content)
|
|
118
|
+
- **any other `dict`/`list`** → one text block with the value JSON-serialized
|
|
119
|
+
|
|
120
|
+
The dict shape matches the MCP spec wire format directly — you can copy from the [MCP docs](https://modelcontextprotocol.io/specification/2025-03-26/server/tools#tool-result) and return it. `bytes` in `data` (image/audio) or `resource.blob` (embedded resource) are base64-encoded automatically, so you don't touch base64 yourself:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
class Screenshot(MCPTool):
|
|
124
|
+
"""Capture a screenshot of a page."""
|
|
125
|
+
|
|
126
|
+
def __init__(self, url: str):
|
|
127
|
+
self.url = url
|
|
128
|
+
|
|
129
|
+
def run(self) -> list:
|
|
130
|
+
png_bytes = capture(self.url)
|
|
131
|
+
return [
|
|
132
|
+
{"type": "text", "text": f"Screenshot of {self.url}:"},
|
|
133
|
+
{"type": "image", "data": png_bytes, "mimeType": "image/png"},
|
|
134
|
+
]
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Returning a non-content dict like `{"id": 1, "name": "Alice"}` JSON-serializes into a text block — the "here's some structured data" case still works without ceremony.
|
|
138
|
+
|
|
139
|
+
## Resources
|
|
140
|
+
|
|
141
|
+
Resources are addressable data sources your server exposes for reading. Each resource is an [`MCPResource`](./resources.py#MCPResource) subclass with a URI and a `read()` method. Declare them on the MCP with `resources = [...]` (parallel to `tools`):
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
from pathlib import Path
|
|
145
|
+
|
|
146
|
+
from plain.mcp import MCPResource
|
|
147
|
+
from plain.runtime import settings
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class AppVersion(MCPResource):
|
|
151
|
+
"""Current deployed version."""
|
|
152
|
+
|
|
153
|
+
uri = "config://app/version"
|
|
154
|
+
mime_type = "text/plain"
|
|
155
|
+
|
|
156
|
+
def read(self) -> str:
|
|
157
|
+
return settings.VERSION
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class AppReadme(MCPResource):
|
|
161
|
+
"""Project readme."""
|
|
162
|
+
|
|
163
|
+
uri = "config://app/readme"
|
|
164
|
+
mime_type = "text/markdown"
|
|
165
|
+
|
|
166
|
+
def read(self) -> str:
|
|
167
|
+
return Path("README.md").read_text()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class AppMCP(MCPView):
|
|
171
|
+
name = "myapp"
|
|
172
|
+
resources = [AppVersion, AppReadme]
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Metadata is derived automatically:
|
|
176
|
+
|
|
177
|
+
- **Name** defaults to the class name — override with `name = "..."`
|
|
178
|
+
- **Description** comes from the class docstring (used verbatim)
|
|
179
|
+
|
|
180
|
+
**Text vs binary.** `read()` returns `str` for text (emitted as `text`) or `bytes` for binary (emitted as base64 `blob`).
|
|
181
|
+
|
|
182
|
+
**Reading the invoking context.** As with tools, `self.mcp` is set before `read()` is called — use `self.mcp.user` or `self.mcp.request` for user-scoped resources.
|
|
183
|
+
|
|
184
|
+
**Authorization.** Override `allowed_for(mcp)` on the resource (classmethod) to filter who can see it — resources that return `False` are hidden from listings and rejected from reads. Same model and hooks as tools; see [Filtering tools per request](#filtering-tools-per-request).
|
|
185
|
+
|
|
186
|
+
**Parametrized resources (URI templates).** For one class that serves many URIs — e.g. per-entity data — set `uri_template` instead of `uri` and accept the params on `__init__`:
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
class Order(MCPResource):
|
|
190
|
+
"""An order by ID."""
|
|
191
|
+
|
|
192
|
+
uri_template = "orders://{order_id}"
|
|
193
|
+
mime_type = "application/json"
|
|
194
|
+
|
|
195
|
+
def __init__(self, order_id: int):
|
|
196
|
+
self.order_id = order_id
|
|
197
|
+
|
|
198
|
+
def read(self) -> str:
|
|
199
|
+
return str(Order.query.get(pk=self.order_id))
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Templates follow [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570) level 1 — `{name}` placeholders match a single path segment. Extracted params are coerced to the `__init__` annotation for `int`, `float`, `bool`; other types come through as strings. Setting both `uri` and `uri_template` is an error.
|
|
203
|
+
|
|
204
|
+
Templated resources appear under `resources/templates/list` (not `resources/list`); clients then resolve a concrete URI and call `resources/read` with it.
|
|
205
|
+
|
|
206
|
+
## Naming
|
|
207
|
+
|
|
208
|
+
`name` is the identifier your MCP server advertises to clients — it shows up in MCP client UIs alongside other registered servers, so it needs to be recognizable out of context.
|
|
209
|
+
|
|
210
|
+
- **Single MCP endpoint** — use your app's name (typically matches `settings.NAME` from `pyproject.toml`)
|
|
211
|
+
- **Multiple endpoints in one app** — prefix with the role: `myapp-public`, `myapp-admin`
|
|
212
|
+
- **A package shipping an MCP** — use the package's own name
|
|
213
|
+
|
|
214
|
+
## Multiple MCP endpoints
|
|
215
|
+
|
|
216
|
+
Create one `MCPView` subclass per endpoint. Each is mounted at its own path with its own tool surface and auth.
|
|
217
|
+
|
|
218
|
+
```python
|
|
219
|
+
# app/mcp.py
|
|
220
|
+
from plain.auth.views import AuthView
|
|
221
|
+
from plain.mcp import MCPUnauthorized, MCPView
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class AppMCP(MCPView, AuthView):
|
|
225
|
+
name = "myapp-api"
|
|
226
|
+
login_required = True
|
|
227
|
+
tools = [ListCustomerOrders]
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class StaffMCP(MCPView, AuthView):
|
|
231
|
+
name = "myapp-staff"
|
|
232
|
+
login_required = True
|
|
233
|
+
tools = [DescribeSchema]
|
|
234
|
+
|
|
235
|
+
def check_auth(self):
|
|
236
|
+
super().check_auth() # login_required from AuthView
|
|
237
|
+
if not self.user.is_staff:
|
|
238
|
+
raise MCPUnauthorized("Staff only")
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
```python
|
|
242
|
+
# app/urls.py
|
|
243
|
+
urls = [
|
|
244
|
+
path("api/mcp/", AppMCP, name="app_mcp"),
|
|
245
|
+
path("staff/mcp/", StaffMCP, name="staff_mcp"),
|
|
246
|
+
]
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
## Attaching tools to a shared MCP
|
|
250
|
+
|
|
251
|
+
Packages that need to contribute tools to an MCP they don't own (for example, adding a page-views tool to `plain.admin.mcp.AdminMCP`) use the `register_tool()` classmethod:
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
# plain/pageviews/mcp.py
|
|
255
|
+
from plain.admin.mcp import AdminMCP
|
|
256
|
+
from plain.mcp import MCPTool
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class PageViewStats(MCPTool):
|
|
260
|
+
"""Page view summary for the last N days."""
|
|
261
|
+
|
|
262
|
+
def __init__(self, days: int = 7):
|
|
263
|
+
self.days = days
|
|
264
|
+
|
|
265
|
+
def run(self) -> dict:
|
|
266
|
+
...
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
AdminMCP.register_tool(PageViewStats)
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
`register_tool()` accepts an `MCPTool` subclass. The attached tool inherits the host MCP's auth policy; tighter gating goes on the tool itself via `allowed_for()` (see [Authorization](#authorization)).
|
|
273
|
+
|
|
274
|
+
## Authentication
|
|
275
|
+
|
|
276
|
+
The base `MCPView` class does nothing auth-related — auth comes from whatever you compose on top of it. Override `before_request()` and raise `MCPUnauthorized` on failure; `handle_exception` translates it to a JSON-RPC 401 (MCP clients can't follow HTTP redirects, so redirect-to-login behavior isn't appropriate here).
|
|
277
|
+
|
|
278
|
+
### Session auth — compose with `AuthView`
|
|
279
|
+
|
|
280
|
+
For MCP endpoints consumed by users already signed into your Plain app, compose `MCPView` with [`plain.auth.views.AuthView`](../../plain-auth/plain/auth/README.md). Put `MCPView` first in the base list so its `handle_exception` — which emits JSON-RPC errors — takes precedence over `AuthView`'s HTML redirect rendering.
|
|
281
|
+
|
|
282
|
+
```python
|
|
283
|
+
from plain.auth.views import AuthView
|
|
284
|
+
from plain.mcp import MCPView
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class AppMCP(MCPView, AuthView):
|
|
288
|
+
name = "myapp"
|
|
289
|
+
login_required = True
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
`login_required` / `admin_required` / `self.user` / `check_auth()` come from `AuthView`. `LoginRequired` automatically becomes a JSON-RPC 401 because it's an `HTTPException(status_code=401)` that `MCPView.handle_exception` maps through its status-code table.
|
|
293
|
+
|
|
294
|
+
For role-based gating, override `check_auth()`:
|
|
295
|
+
|
|
296
|
+
```python
|
|
297
|
+
class StaffMCP(MCPView, AuthView):
|
|
298
|
+
name = "myapp-staff"
|
|
299
|
+
login_required = True
|
|
300
|
+
|
|
301
|
+
def check_auth(self):
|
|
302
|
+
super().check_auth()
|
|
303
|
+
if not self.user.is_staff:
|
|
304
|
+
raise MCPUnauthorized("Staff only")
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Importing `plain.auth` is required only for this pattern — token-only deployments can ignore it.
|
|
308
|
+
|
|
309
|
+
### Bearer token auth
|
|
310
|
+
|
|
311
|
+
For external integrations (CLI tools, remote clients, CI), subclass `MCPView` directly and check a header in `before_request()`:
|
|
312
|
+
|
|
313
|
+
```python
|
|
314
|
+
import hmac
|
|
315
|
+
import os
|
|
316
|
+
|
|
317
|
+
from plain.mcp import MCPView, MCPUnauthorized
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
class APIKeyMCP(MCPView):
|
|
321
|
+
name = "myapp-api"
|
|
322
|
+
|
|
323
|
+
def before_request(self) -> None:
|
|
324
|
+
header = self.request.headers.get("Authorization", "")
|
|
325
|
+
if not header.startswith("Bearer "):
|
|
326
|
+
raise MCPUnauthorized("Missing or invalid Authorization header")
|
|
327
|
+
if not hmac.compare_digest(header[7:], os.environ["MCP_TOKEN"]):
|
|
328
|
+
raise MCPUnauthorized("Invalid auth token")
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
Clients send the token in their config:
|
|
332
|
+
|
|
333
|
+
```json
|
|
334
|
+
{
|
|
335
|
+
"mcpServers": {
|
|
336
|
+
"my-app": {
|
|
337
|
+
"url": "https://myapp.com/mcp/",
|
|
338
|
+
"headers": {"Authorization": "Bearer <token>"}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
### Public endpoints
|
|
345
|
+
|
|
346
|
+
The base `MCPView` class has no auth by default — subclassing `MCPView` without overriding `before_request` gives you a public endpoint. There's no "allow all" default to silently swap out; the absence of an auth check is visible in the class definition itself.
|
|
347
|
+
|
|
348
|
+
## Filtering tools per request
|
|
349
|
+
|
|
350
|
+
Two hooks, one narrow and one broad:
|
|
351
|
+
|
|
352
|
+
**1. Per-tool via `MCPTool.allowed_for(mcp)`.** A classmethod on the tool, checked before the tool is instantiated — the natural place for tool-level policies (auth, feature flags, tenant restrictions). The default `get_tools()` / `get_resources()` filter through this automatically.
|
|
353
|
+
|
|
354
|
+
```python
|
|
355
|
+
class AdminTool(MCPTool):
|
|
356
|
+
@classmethod
|
|
357
|
+
def allowed_for(cls, mcp) -> bool:
|
|
358
|
+
return mcp.user is not None and mcp.user.is_admin
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
class DeleteUser(AdminTool):
|
|
362
|
+
"""Delete a user account.
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
user_id: ID of the user to delete.
|
|
366
|
+
"""
|
|
367
|
+
|
|
368
|
+
def __init__(self, user_id: int):
|
|
369
|
+
self.user_id = user_id
|
|
370
|
+
|
|
371
|
+
def run(self) -> str:
|
|
372
|
+
...
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
Tools that return `False` from `allowed_for()` are hidden from `tools/list` and rejected from `tools/call` as "unknown tool" — existence isn't leaked. Same for resources and `resources/read`.
|
|
376
|
+
|
|
377
|
+
**2. Cross-cutting via `get_tools()` / `get_resources()` override.** For whole-endpoint policies — readonly mode, superuser bypass, dynamic tool sets — override the getter and return whatever list you want. Skipping `super()` bypasses `allowed_for`:
|
|
378
|
+
|
|
379
|
+
```python
|
|
380
|
+
class AppMCP(MCPView, AuthView):
|
|
381
|
+
name = "myapp"
|
|
382
|
+
login_required = True
|
|
383
|
+
|
|
384
|
+
def get_tools(self):
|
|
385
|
+
if self.user and self.user.is_superuser:
|
|
386
|
+
return self.tools # superuser sees everything, skipping allowed_for
|
|
387
|
+
tools = super().get_tools() # applies each tool's allowed_for
|
|
388
|
+
if settings.READONLY_MODE:
|
|
389
|
+
tools = [t for t in tools if not getattr(t, "mutates", False)]
|
|
390
|
+
return tools
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
**Row-level filtering** ("only this user's notes") belongs inside `run()`/`read()` via `self.mcp.user` — not in the gating layer.
|
|
394
|
+
|
|
395
|
+
## Custom JSON-RPC methods
|
|
396
|
+
|
|
397
|
+
`plain.mcp` ships `tools/*` and `resources/*` with first-class classes. Everything else in the MCP spec — prompts, logging, completions, sampling — you implement directly on your `MCPView` subclass by defining a method named `rpc_<method>`. Slashes in the JSON-RPC method become underscores.
|
|
398
|
+
|
|
399
|
+
The pattern:
|
|
400
|
+
|
|
401
|
+
1. Write an `rpc_<method>` method that takes a `params` dict and returns the response dict (as defined by the [MCP spec](https://modelcontextprotocol.io/specification/2025-03-26/server) for that method)
|
|
402
|
+
2. Advertise the capability in `get_capabilities()` so clients know to call it
|
|
403
|
+
3. Raise `MCPInvalidParams` for bad caller input; anything else becomes a generic `INTERNAL_ERROR` with the exception logged server-side
|
|
404
|
+
|
|
405
|
+
### Example: prompts
|
|
406
|
+
|
|
407
|
+
Here's a complete prompts implementation. Note that nothing in `plain.mcp` knows about prompts — it's pure dispatch + dict responses.
|
|
408
|
+
|
|
409
|
+
```python
|
|
410
|
+
from plain.mcp import MCPInvalidParams, MCPView
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
_PROMPTS = [
|
|
414
|
+
{
|
|
415
|
+
"name": "summarize",
|
|
416
|
+
"description": "Summarize a piece of text",
|
|
417
|
+
"arguments": [
|
|
418
|
+
{
|
|
419
|
+
"name": "text",
|
|
420
|
+
"description": "Text to summarize",
|
|
421
|
+
"required": True,
|
|
422
|
+
},
|
|
423
|
+
],
|
|
424
|
+
},
|
|
425
|
+
{
|
|
426
|
+
"name": "standup",
|
|
427
|
+
"description": "Draft a daily standup update",
|
|
428
|
+
},
|
|
429
|
+
]
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
class AppMCP(MCPView):
|
|
433
|
+
name = "myapp"
|
|
434
|
+
|
|
435
|
+
def rpc_prompts_list(self, params):
|
|
436
|
+
return {"prompts": _PROMPTS}
|
|
437
|
+
|
|
438
|
+
def rpc_prompts_get(self, params):
|
|
439
|
+
name = params.get("name")
|
|
440
|
+
args = params.get("arguments") or {}
|
|
441
|
+
|
|
442
|
+
if name == "summarize":
|
|
443
|
+
text = args.get("text")
|
|
444
|
+
if not text:
|
|
445
|
+
raise MCPInvalidParams("Missing 'text' argument")
|
|
446
|
+
return {
|
|
447
|
+
"messages": [
|
|
448
|
+
{
|
|
449
|
+
"role": "user",
|
|
450
|
+
"content": {
|
|
451
|
+
"type": "text",
|
|
452
|
+
"text": f"Summarize the following in 2 sentences:\n\n{text}",
|
|
453
|
+
},
|
|
454
|
+
}
|
|
455
|
+
]
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if name == "standup":
|
|
459
|
+
return {
|
|
460
|
+
"messages": [
|
|
461
|
+
{
|
|
462
|
+
"role": "user",
|
|
463
|
+
"content": {
|
|
464
|
+
"type": "text",
|
|
465
|
+
"text": "Draft today's standup based on my recent commits and PRs.",
|
|
466
|
+
},
|
|
467
|
+
}
|
|
468
|
+
]
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
raise MCPInvalidParams(f"Unknown prompt: {name}")
|
|
472
|
+
|
|
473
|
+
def get_capabilities(self):
|
|
474
|
+
caps = super().get_capabilities()
|
|
475
|
+
caps["prompts"] = {"listChanged": False}
|
|
476
|
+
return caps
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
The same pattern works for any capability. `rpc_logging_setLevel`, `rpc_completion_complete`, etc. — consult the MCP spec for the method name and response shape.
|
|
480
|
+
|
|
481
|
+
### Overriding built-ins
|
|
482
|
+
|
|
483
|
+
The shipped handlers (`rpc_initialize`, `rpc_ping`, `rpc_tools_list`, `rpc_tools_call`, `rpc_resources_list`, `rpc_resources_templates_list`, `rpc_resources_read`) use the same dispatch — override them on your subclass if you need to change the defaults.
|
|
484
|
+
|
|
485
|
+
## FAQs
|
|
486
|
+
|
|
487
|
+
#### What MCP protocol version is supported?
|
|
488
|
+
|
|
489
|
+
The `2025-03-26` version of the MCP specification, using the Streamable HTTP transport. The older SSE transport is not supported.
|
|
490
|
+
|
|
491
|
+
#### Are resource subscriptions supported?
|
|
492
|
+
|
|
493
|
+
No. `resources/subscribe` and `resources/unsubscribe` require a long-lived server-to-client stream (for pushing `notifications/resources/updated`) and cross-worker fan-out of change events — neither is implemented yet. Clients that need fresh data should re-read the resource. The capabilities advertised to clients reflect this (`resources.subscribe: false`).
|
|
494
|
+
|
|
495
|
+
#### How does auto-discovery work?
|
|
496
|
+
|
|
497
|
+
On startup, `plain.mcp` imports `mcp` modules from installed packages (similar to how `plain.jobs` discovers job classes). Defining your `MCPView` subclass at module level is what makes it discoverable by packages that want to attach tools via `register_tool()`.
|
|
498
|
+
|
|
499
|
+
#### Do I need to handle CSRF?
|
|
500
|
+
|
|
501
|
+
No. Non-browser clients (like AI assistants) don't send `Origin` or `Sec-Fetch-Site` headers, so Plain's CSRF protection skips them automatically.
|
|
502
|
+
|
|
503
|
+
#### Why are arguments on `__init__` instead of `run()`?
|
|
504
|
+
|
|
505
|
+
Putting args on `__init__` makes each call a typed object (like a dataclass or pydantic model), which is the natural shape for validation hooks later and lets `run()` + any helper methods share `self.x` without re-threading parameters. `run()` stays no-arg and side-effect-shaped.
|
|
506
|
+
|
|
507
|
+
#### Why aren't tools just functions?
|
|
508
|
+
|
|
509
|
+
Classes uniformly handle state, grouped authorization (`AdminTool` base classes), and future validation/hooks. Supporting both functions and classes meant two parallel APIs; picking one keeps the mental model small.
|
|
510
|
+
|
|
511
|
+
## Installation
|
|
512
|
+
|
|
513
|
+
Install the `plain.mcp` package from [PyPI](https://pypi.org/project/plain.mcp/):
|
|
514
|
+
|
|
515
|
+
```bash
|
|
516
|
+
uv add plain-mcp
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
Add to your `INSTALLED_PACKAGES`:
|
|
520
|
+
|
|
521
|
+
```python
|
|
522
|
+
# app/settings.py
|
|
523
|
+
INSTALLED_PACKAGES = [
|
|
524
|
+
...
|
|
525
|
+
"plain.mcp",
|
|
526
|
+
]
|
|
527
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
./plain/mcp/README.md
|