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.
@@ -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
@@ -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.
@@ -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