plain.mcp 0.0.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.
plain/mcp/README.md ADDED
@@ -0,0 +1,516 @@
1
+ # plain.mcp
2
+
3
+ **Expose your Plain app to AI clients as an MCP server over HTTP.**
4
+
5
+ - [Overview](#overview)
6
+ - [Tools](#tools)
7
+ - [Resources](#resources)
8
+ - [Naming](#naming)
9
+ - [Multiple MCP endpoints](#multiple-mcp-endpoints)
10
+ - [Attaching tools to a shared MCP](#attaching-tools-to-a-shared-mcp)
11
+ - [Authentication](#authentication)
12
+ - [Session auth](#session-auth-compose-with-authview)
13
+ - [Bearer token auth](#bearer-token-auth)
14
+ - [Public endpoints](#public-endpoints)
15
+ - [Filtering tools per request](#filtering-tools-per-request)
16
+ - [Custom JSON-RPC methods](#custom-json-rpc-methods)
17
+ - [FAQs](#faqs)
18
+ - [Installation](#installation)
19
+
20
+ ## Overview
21
+
22
+ 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.
23
+
24
+ ```python
25
+ # app/mcp.py (auto-discovered on startup)
26
+ from plain.auth.views import AuthView
27
+ from plain.mcp import MCPTool, MCPView
28
+
29
+
30
+ class Greet(MCPTool):
31
+ """Say hello to someone."""
32
+
33
+ def __init__(self, name: str):
34
+ self.name = name
35
+
36
+ def run(self) -> str:
37
+ return f"Hello, {self.name}!"
38
+
39
+
40
+ class AppMCP(MCPView, AuthView):
41
+ name = "myapp"
42
+ login_required = True
43
+ tools = [Greet]
44
+ ```
45
+
46
+ Mount it:
47
+
48
+ ```python
49
+ # app/urls.py
50
+ from app.mcp import AppMCP
51
+ from plain.urls import Router, path
52
+
53
+
54
+ class AppRouter(Router):
55
+ namespace = ""
56
+ urls = [
57
+ path("mcp/", AppMCP, name="mcp"),
58
+ ]
59
+ ```
60
+
61
+ AI clients connect to `https://yourapp.com/mcp/` using the [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http).
62
+
63
+ `name` is required. `version` defaults to `settings.VERSION` (from your `pyproject.toml`). Auth and authorization are covered below.
64
+
65
+ ## Tools
66
+
67
+ 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:
68
+
69
+ - **Name** defaults to the class name — override with `name = "..."`
70
+ - **Description** comes from the class docstring (used verbatim — override with `description = "..."`)
71
+ - **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
72
+
73
+ ```python
74
+ class SearchOrders(MCPTool):
75
+ """Search orders by customer name or order ID."""
76
+
77
+ def __init__(self, query: str, limit: int = 10):
78
+ self.query = query
79
+ self.limit = limit
80
+
81
+ def run(self) -> str:
82
+ return "\n".join(str(o) for o in Order.query.filter(...))
83
+ ```
84
+
85
+ **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:
86
+
87
+ ```python
88
+ from plain.mcp import MCPTool
89
+
90
+
91
+ class ListMyNotes(MCPTool):
92
+ """List notes owned by the caller."""
93
+
94
+ def run(self) -> list[dict]:
95
+ return list(
96
+ Note.query.filter(author=self.mcp.user).values("id", "title")
97
+ )
98
+ ```
99
+
100
+ **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.
101
+
102
+ **Return types.** `run()` returns get converted to MCP content blocks:
103
+
104
+ - **`str`** → one text block
105
+ - **a dict shaped like a content block** (`type` is one of `text`, `image`, `audio`, `resource`, `resource_link`) → that single block
106
+ - **a list of such dicts** → those blocks, in order (mixed content)
107
+ - **any other `dict`/`list`** → one text block with the value JSON-serialized
108
+
109
+ 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:
110
+
111
+ ```python
112
+ class Screenshot(MCPTool):
113
+ """Capture a screenshot of a page."""
114
+
115
+ def __init__(self, url: str):
116
+ self.url = url
117
+
118
+ def run(self) -> list:
119
+ png_bytes = capture(self.url)
120
+ return [
121
+ {"type": "text", "text": f"Screenshot of {self.url}:"},
122
+ {"type": "image", "data": png_bytes, "mimeType": "image/png"},
123
+ ]
124
+ ```
125
+
126
+ 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.
127
+
128
+ ## Resources
129
+
130
+ 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`):
131
+
132
+ ```python
133
+ from pathlib import Path
134
+
135
+ from plain.mcp import MCPResource
136
+ from plain.runtime import settings
137
+
138
+
139
+ class AppVersion(MCPResource):
140
+ """Current deployed version."""
141
+
142
+ uri = "config://app/version"
143
+ mime_type = "text/plain"
144
+
145
+ def read(self) -> str:
146
+ return settings.VERSION
147
+
148
+
149
+ class AppReadme(MCPResource):
150
+ """Project readme."""
151
+
152
+ uri = "config://app/readme"
153
+ mime_type = "text/markdown"
154
+
155
+ def read(self) -> str:
156
+ return Path("README.md").read_text()
157
+
158
+
159
+ class AppMCP(MCPView):
160
+ name = "myapp"
161
+ resources = [AppVersion, AppReadme]
162
+ ```
163
+
164
+ Metadata is derived automatically:
165
+
166
+ - **Name** defaults to the class name — override with `name = "..."`
167
+ - **Description** comes from the class docstring (used verbatim)
168
+
169
+ **Text vs binary.** `read()` returns `str` for text (emitted as `text`) or `bytes` for binary (emitted as base64 `blob`).
170
+
171
+ **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.
172
+
173
+ **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).
174
+
175
+ **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__`:
176
+
177
+ ```python
178
+ class Order(MCPResource):
179
+ """An order by ID."""
180
+
181
+ uri_template = "orders://{order_id}"
182
+ mime_type = "application/json"
183
+
184
+ def __init__(self, order_id: int):
185
+ self.order_id = order_id
186
+
187
+ def read(self) -> str:
188
+ return str(Order.query.get(pk=self.order_id))
189
+ ```
190
+
191
+ 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.
192
+
193
+ Templated resources appear under `resources/templates/list` (not `resources/list`); clients then resolve a concrete URI and call `resources/read` with it.
194
+
195
+ ## Naming
196
+
197
+ `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.
198
+
199
+ - **Single MCP endpoint** — use your app's name (typically matches `settings.NAME` from `pyproject.toml`)
200
+ - **Multiple endpoints in one app** — prefix with the role: `myapp-public`, `myapp-admin`
201
+ - **A package shipping an MCP** — use the package's own name
202
+
203
+ ## Multiple MCP endpoints
204
+
205
+ Create one `MCPView` subclass per endpoint. Each is mounted at its own path with its own tool surface and auth.
206
+
207
+ ```python
208
+ # app/mcp.py
209
+ from plain.auth.views import AuthView
210
+ from plain.mcp import MCPUnauthorized, MCPView
211
+
212
+
213
+ class AppMCP(MCPView, AuthView):
214
+ name = "myapp-api"
215
+ login_required = True
216
+ tools = [ListCustomerOrders]
217
+
218
+
219
+ class StaffMCP(MCPView, AuthView):
220
+ name = "myapp-staff"
221
+ login_required = True
222
+ tools = [DescribeSchema]
223
+
224
+ def check_auth(self):
225
+ super().check_auth() # login_required from AuthView
226
+ if not self.user.is_staff:
227
+ raise MCPUnauthorized("Staff only")
228
+ ```
229
+
230
+ ```python
231
+ # app/urls.py
232
+ urls = [
233
+ path("api/mcp/", AppMCP, name="app_mcp"),
234
+ path("staff/mcp/", StaffMCP, name="staff_mcp"),
235
+ ]
236
+ ```
237
+
238
+ ## Attaching tools to a shared MCP
239
+
240
+ 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:
241
+
242
+ ```python
243
+ # plain/pageviews/mcp.py
244
+ from plain.admin.mcp import AdminMCP
245
+ from plain.mcp import MCPTool
246
+
247
+
248
+ class PageViewStats(MCPTool):
249
+ """Page view summary for the last N days."""
250
+
251
+ def __init__(self, days: int = 7):
252
+ self.days = days
253
+
254
+ def run(self) -> dict:
255
+ ...
256
+
257
+
258
+ AdminMCP.register_tool(PageViewStats)
259
+ ```
260
+
261
+ `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)).
262
+
263
+ ## Authentication
264
+
265
+ 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).
266
+
267
+ ### Session auth — compose with `AuthView`
268
+
269
+ 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.
270
+
271
+ ```python
272
+ from plain.auth.views import AuthView
273
+ from plain.mcp import MCPView
274
+
275
+
276
+ class AppMCP(MCPView, AuthView):
277
+ name = "myapp"
278
+ login_required = True
279
+ ```
280
+
281
+ `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.
282
+
283
+ For role-based gating, override `check_auth()`:
284
+
285
+ ```python
286
+ class StaffMCP(MCPView, AuthView):
287
+ name = "myapp-staff"
288
+ login_required = True
289
+
290
+ def check_auth(self):
291
+ super().check_auth()
292
+ if not self.user.is_staff:
293
+ raise MCPUnauthorized("Staff only")
294
+ ```
295
+
296
+ Importing `plain.auth` is required only for this pattern — token-only deployments can ignore it.
297
+
298
+ ### Bearer token auth
299
+
300
+ For external integrations (CLI tools, remote clients, CI), subclass `MCPView` directly and check a header in `before_request()`:
301
+
302
+ ```python
303
+ import hmac
304
+ import os
305
+
306
+ from plain.mcp import MCPView, MCPUnauthorized
307
+
308
+
309
+ class APIKeyMCP(MCPView):
310
+ name = "myapp-api"
311
+
312
+ def before_request(self) -> None:
313
+ header = self.request.headers.get("Authorization", "")
314
+ if not header.startswith("Bearer "):
315
+ raise MCPUnauthorized("Missing or invalid Authorization header")
316
+ if not hmac.compare_digest(header[7:], os.environ["MCP_TOKEN"]):
317
+ raise MCPUnauthorized("Invalid auth token")
318
+ ```
319
+
320
+ Clients send the token in their config:
321
+
322
+ ```json
323
+ {
324
+ "mcpServers": {
325
+ "my-app": {
326
+ "url": "https://myapp.com/mcp/",
327
+ "headers": {"Authorization": "Bearer <token>"}
328
+ }
329
+ }
330
+ }
331
+ ```
332
+
333
+ ### Public endpoints
334
+
335
+ 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.
336
+
337
+ ## Filtering tools per request
338
+
339
+ Two hooks, one narrow and one broad:
340
+
341
+ **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.
342
+
343
+ ```python
344
+ class AdminTool(MCPTool):
345
+ @classmethod
346
+ def allowed_for(cls, mcp) -> bool:
347
+ return mcp.user is not None and mcp.user.is_admin
348
+
349
+
350
+ class DeleteUser(AdminTool):
351
+ """Delete a user account.
352
+
353
+ Args:
354
+ user_id: ID of the user to delete.
355
+ """
356
+
357
+ def __init__(self, user_id: int):
358
+ self.user_id = user_id
359
+
360
+ def run(self) -> str:
361
+ ...
362
+ ```
363
+
364
+ 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`.
365
+
366
+ **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`:
367
+
368
+ ```python
369
+ class AppMCP(MCPView, AuthView):
370
+ name = "myapp"
371
+ login_required = True
372
+
373
+ def get_tools(self):
374
+ if self.user and self.user.is_superuser:
375
+ return self.tools # superuser sees everything, skipping allowed_for
376
+ tools = super().get_tools() # applies each tool's allowed_for
377
+ if settings.READONLY_MODE:
378
+ tools = [t for t in tools if not getattr(t, "mutates", False)]
379
+ return tools
380
+ ```
381
+
382
+ **Row-level filtering** ("only this user's notes") belongs inside `run()`/`read()` via `self.mcp.user` — not in the gating layer.
383
+
384
+ ## Custom JSON-RPC methods
385
+
386
+ `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.
387
+
388
+ The pattern:
389
+
390
+ 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)
391
+ 2. Advertise the capability in `get_capabilities()` so clients know to call it
392
+ 3. Raise `MCPInvalidParams` for bad caller input; anything else becomes a generic `INTERNAL_ERROR` with the exception logged server-side
393
+
394
+ ### Example: prompts
395
+
396
+ Here's a complete prompts implementation. Note that nothing in `plain.mcp` knows about prompts — it's pure dispatch + dict responses.
397
+
398
+ ```python
399
+ from plain.mcp import MCPInvalidParams, MCPView
400
+
401
+
402
+ _PROMPTS = [
403
+ {
404
+ "name": "summarize",
405
+ "description": "Summarize a piece of text",
406
+ "arguments": [
407
+ {
408
+ "name": "text",
409
+ "description": "Text to summarize",
410
+ "required": True,
411
+ },
412
+ ],
413
+ },
414
+ {
415
+ "name": "standup",
416
+ "description": "Draft a daily standup update",
417
+ },
418
+ ]
419
+
420
+
421
+ class AppMCP(MCPView):
422
+ name = "myapp"
423
+
424
+ def rpc_prompts_list(self, params):
425
+ return {"prompts": _PROMPTS}
426
+
427
+ def rpc_prompts_get(self, params):
428
+ name = params.get("name")
429
+ args = params.get("arguments") or {}
430
+
431
+ if name == "summarize":
432
+ text = args.get("text")
433
+ if not text:
434
+ raise MCPInvalidParams("Missing 'text' argument")
435
+ return {
436
+ "messages": [
437
+ {
438
+ "role": "user",
439
+ "content": {
440
+ "type": "text",
441
+ "text": f"Summarize the following in 2 sentences:\n\n{text}",
442
+ },
443
+ }
444
+ ]
445
+ }
446
+
447
+ if name == "standup":
448
+ return {
449
+ "messages": [
450
+ {
451
+ "role": "user",
452
+ "content": {
453
+ "type": "text",
454
+ "text": "Draft today's standup based on my recent commits and PRs.",
455
+ },
456
+ }
457
+ ]
458
+ }
459
+
460
+ raise MCPInvalidParams(f"Unknown prompt: {name}")
461
+
462
+ def get_capabilities(self):
463
+ caps = super().get_capabilities()
464
+ caps["prompts"] = {"listChanged": False}
465
+ return caps
466
+ ```
467
+
468
+ 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.
469
+
470
+ ### Overriding built-ins
471
+
472
+ 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.
473
+
474
+ ## FAQs
475
+
476
+ #### What MCP protocol version is supported?
477
+
478
+ The `2025-03-26` version of the MCP specification, using the Streamable HTTP transport. The older SSE transport is not supported.
479
+
480
+ #### Are resource subscriptions supported?
481
+
482
+ 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`).
483
+
484
+ #### How does auto-discovery work?
485
+
486
+ 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()`.
487
+
488
+ #### Do I need to handle CSRF?
489
+
490
+ No. Non-browser clients (like AI assistants) don't send `Origin` or `Sec-Fetch-Site` headers, so Plain's CSRF protection skips them automatically.
491
+
492
+ #### Why are arguments on `__init__` instead of `run()`?
493
+
494
+ 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.
495
+
496
+ #### Why aren't tools just functions?
497
+
498
+ 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.
499
+
500
+ ## Installation
501
+
502
+ Install the `plain.mcp` package from [PyPI](https://pypi.org/project/plain.mcp/):
503
+
504
+ ```bash
505
+ uv add plain-mcp
506
+ ```
507
+
508
+ Add to your `INSTALLED_PACKAGES`:
509
+
510
+ ```python
511
+ # app/settings.py
512
+ INSTALLED_PACKAGES = [
513
+ ...
514
+ "plain.mcp",
515
+ ]
516
+ ```
plain/mcp/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from .exceptions import MCPInvalidParams, MCPUnauthorized
4
+ from .resources import MCPResource
5
+ from .tools import MCPTool
6
+ from .views import MCPView
7
+
8
+ __all__ = [
9
+ "MCPInvalidParams",
10
+ "MCPResource",
11
+ "MCPTool",
12
+ "MCPUnauthorized",
13
+ "MCPView",
14
+ ]
plain/mcp/config.py ADDED
@@ -0,0 +1,11 @@
1
+ from __future__ import annotations
2
+
3
+ from plain.packages import PackageConfig, packages_registry, register_config
4
+
5
+
6
+ @register_config
7
+ class Config(PackageConfig):
8
+ package_label = "plainmcp"
9
+
10
+ def ready(self) -> None:
11
+ packages_registry.autodiscover_modules("mcp", include_app=True)
@@ -0,0 +1,19 @@
1
+ """Exception types used by `plain.mcp`."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class MCPUnauthorized(Exception):
7
+ """Raised from `before_request` to reject an MCP request.
8
+
9
+ `MCPView.handle_exception` catches this and returns a JSON-RPC 401
10
+ response with the exception message as the error text.
11
+ """
12
+
13
+
14
+ class MCPInvalidParams(Exception):
15
+ """Raised from a JSON-RPC handler to signal bad caller params.
16
+
17
+ The dispatcher translates this to a JSON-RPC `INVALID_PARAMS`
18
+ (-32602) error rather than the blanket `INTERNAL_ERROR`.
19
+ """