v0.154.0

plain.mcp

Expose your Plain app to AI clients as an MCP server over HTTP.

Overview

An MCP server is a subclass of MCPView that declares a list of MCPTool subclasses. MCPView is a Plain View — you mount it directly in your URLs.

# app/mcp.py (auto-discovered on startup)
from plain.auth.views import AuthView
from plain.mcp import MCPTool, MCPView


class Greet(MCPTool):
    """Say hello to someone."""

    def __init__(self, name: str):
        self.name = name

    def run(self) -> str:
        return f"Hello, {self.name}!"


class AppMCP(MCPView, AuthView):
    name = "myapp"
    login_required = True
    tools = [Greet]

Mount it:

# app/urls.py
from app.mcp import AppMCP
from plain.urls import Router, path


class AppRouter(Router):
    namespace = ""
    urls = [
        path("mcp/", AppMCP, name="mcp"),
    ]

AI clients connect to https://yourapp.com/mcp using the Streamable HTTP transport.

name is required. version defaults to settings.VERSION (from your pyproject.toml). Auth and authorization are covered below.

Requests and results

MCP 2026-07-28 is stateless — no handshake, no sessions, no server-to-client stream. Every POST carries one self-describing JSON-RPC request, so the client restates who it is on each call, in params._meta:

params._meta key Required Value
io.modelcontextprotocol/protocolVersion yes "2026-07-28"
io.modelcontextprotocol/clientCapabilities yes object
io.modelcontextprotocol/clientInfo no {"name": ..., "version": ...}

The routing-relevant parts are mirrored into headers, so infrastructure in front of your app can route and authorize a call without parsing the body:

Header Required Must equal
MCP-Protocol-Version every request the _meta version
Mcp-Method every request the body's method
Mcp-Name tools/call, prompts/get, resources/read the body's target

Whether the method exists is settled first: an unknown one is a -32601 at HTTP 404, which is how a client tells a real MCP endpoint apart from a URL that isn't there. A method the spec has since removed (initialize, ping, logging/setLevel) answers the same way, naming the version this server speaks — a client from one of those revisions sends no _meta to validate, so that error is all it can learn from. A notification (no id) gets a bare 202 and skips everything below.

A request for a method that does exist is then validated in this order — say what you are, then agree with yourself, then name a version we have:

Rung Failure Error Status
_meta present a required key is missing -32602 400
headers agree a header is missing or disagrees -32020 400
version spoken the version named isn't 2026-07-28 -32022 400

A client that contradicts itself is told that before it's told its version is unsupported — the two call for different fixes.

Every JSON-RPC error code maps to one HTTP status, wherever the error came from — the ladder above or your own handler. The exception is an internal error (-32603), which stays at 200 with the error in the body: a bug on our side isn't a malformed request.

An exception that escapes the view entirely — raised in before_request, or an HTTPException from your own code — never reaches that table. It's translated separately, HTTP status first, and the JSON-RPC code follows from the status: 500 for anything unhandled (so -32603 does also appear at 500), 401 for MCPUnauthorized, and the status itself as the code for everything else.

MCP clients handle all of this — you only assemble it by hand when poking at your own server:

curl -X POST https://myapp.com/mcp \
  -H 'Content-Type: application/json' \
  -H 'MCP-Protocol-Version: 2026-07-28' \
  -H 'Mcp-Method: tools/list' \
  -d '{
    "jsonrpc": "2.0", "id": 1, "method": "tools/list",
    "params": {"_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientCapabilities": {}
    }}
  }'

self.client_info and self.client_capabilities hold what the caller sent — readable from a tool through self.mcp.

Results are stamped for you, including results from your own rpc_ methods:

  • resultType: "complete"
  • _meta carrying io.modelcontextprotocol/serverInfo (this server's name and version)
  • ttlMs: 0 and cacheScope: "private" on list and read results — server/discover, tools/list, prompts/list, resources/list, resources/templates/list, resources/read

The cache defaults assume the worst because listings are filtered per authenticated user by allowed_for(). A handler that returns its own ttlMs / cacheScope (or resultType) keeps them.

Both tables are class attributes you can extend. Add your own rpc_ list method to cacheable_result_methods to have its results stamped with cache hints, and add a method that names a target to name_header_params (mapping the method to the body param the header mirrors) to have Mcp-Name checked for it:

class AppMCP(MCPView):
    name = "myapp"
    cacheable_result_methods = MCPView.cacheable_result_methods | {"widgets/list"}
    name_header_params = MCPView.name_header_params | {"widgets/get": "id"}

Discovery

Clients start at server/discover, which reports the protocol versions this server speaks, its capabilities, and — when you set it — instructions: free-form guidance for the model driving the server.

class AppMCP(MCPView):
    name = "myapp"
    instructions = "Search for an order before creating one. Order IDs are numeric."

Capabilities are derived from what you registered (tools and resources); override get_capabilities() to advertise more.

Tools

Every tool is an 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:

  • Name defaults to the class name — override with name = "..."
  • Description comes from the class docstring (used verbatim — override with description = "...")
  • 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

Argument validation. Incoming arguments are checked against the derived input schema before your __init__ runs, so a wrong-typed or missing argument comes back as a clear isError message the model can fix ("'limit' must be an integer") instead of blowing up inside run() and being logged as a server bug. Validation covers the shapes plain-mcp derives from type hints (primitives, Literal enums, list[T], T | None); if you hand-write input_schema with richer JSON Schema (oneOf, $ref, numeric bounds), those keywords pass through untouched — validate them in __init__ or run() yourself.

class SearchOrders(MCPTool):
    """Search orders by customer name or order ID."""

    def __init__(self, query: str, limit: int = 10):
        self.query = query
        self.limit = limit

    def run(self) -> str:
        return "\n".join(str(o) for o in Order.query.filter(...))

Reading the invoking context. Before run() is called, the dispatcher sets self.mcp to the MCPView instance that invoked the tool — read the HTTP request through it, plus the caller's user and any subclass state. self.mcp is typed as the base MCPView, which has no user; for typed access to attributes an auth mixin or your subclass adds, define a per-app base tool that re-annotates mcp to your view, then subclass it:

from plain.mcp import MCPTool, MCPView, OAuthResourceServer, TokenInfo


class AppTool(MCPTool):
    mcp: AppMCP  # forward ref — typed access to AppMCP's user, scopes, etc.


class ListMyNotes(AppTool):
    """List notes owned by the caller."""

    def run(self) -> list[dict]:
        return list(
            Note.query.filter(author=self.mcp.user).values("id", "title")
        )


class AppMCP(OAuthResourceServer, MCPView):
    name = "myapp"
    tools = [ListMyNotes]

    def authenticate_token(self, token: str) -> TokenInfo | None:
        ...

The mcp: AppMCP annotation is a forward reference (resolved lazily), so this natural order just works — base tool and tools first, then the view with tools = [...]. A tool that needs nothing view-specific stays bare — class Greet(MCPTool) — and self.mcp is the base MCPView (request, no user).

Signaling errors. Raise MCPToolError from run() for an expected, caller-facing failure — bad input, not found, forbidden. The message goes back to the client with isError: true (MCP's in-result error channel) so the model can self-correct, and it is not logged as a server exception. Any other exception is treated as a bug: logged server-side and returned as an opaque "Tool execution failed".

from plain.mcp import MCPTool, MCPToolError


class GetOrder(MCPTool):
    """Look up an order by ID."""

    def __init__(self, order_id: int):
        self.order_id = order_id

    def run(self) -> dict:
        order = Order.query.filter(id=self.order_id).first()
        if order is None:
            raise MCPToolError(f"No order with id {self.order_id}")
        return {"id": order.id, "status": order.status}

Requiring something from the client. A tool that calls back to the caller — asking its model to sample, or the user to fill something in — can't run for a client that doesn't offer that. Declare it with required_client_capabilities (in MCP's ClientCapabilities shape) and a client that didn't announce it gets a JSON-RPC -32021 naming exactly what's missing, instead of a confusing tool failure:

class Summarize(MCPTool):
    """Summarize a document using the client's model."""

    required_client_capabilities = {"sampling": {}}

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.

Return types. run() returns get converted to MCP content blocks:

  • str → one text block
  • a dict shaped like a content block (type is one of text, image, audio, resource, resource_link) → that single block
  • a list of such dicts → those blocks, in order (mixed content)
  • any other dict/list → one text block with the value JSON-serialized

The dict shape matches the MCP spec wire format directly — you can copy from the MCP docs and return it. bytes in data (image/audio) or resource.blob (embedded resource) are base64-encoded automatically, so you don't touch base64 yourself:

class Screenshot(MCPTool):
    """Capture a screenshot of a page."""

    def __init__(self, url: str):
        self.url = url

    def run(self) -> list:
        png_bytes = capture(self.url)
        return [
            {"type": "text", "text": f"Screenshot of {self.url}:"},
            {"type": "image", "data": png_bytes, "mimeType": "image/png"},
        ]

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.

Tool annotations

Tools can advertise MCP annotations — hints the client uses to present and gate them — by setting annotations to a dict in MCP wire format:

class ListOrders(MCPTool):
    """List orders."""

    annotations = {"readOnlyHint": True}

readOnlyHint is the load-bearing one: clients (e.g. Claude's connector settings) group read-only tools and let users auto-allow them while requiring approval for the rest. The spec defines these hints:

Key Meaning Spec default
readOnlyHint tool does not modify state false
destructiveHint may perform destructive updates (only when not read-only) true
idempotentHint repeated calls have no additional effect false
openWorldHint interacts with an open / external world true

The dict is emitted verbatim, so any hint the spec adds later (or one this version doesn't list) works without a plain-mcp update — and a tool that sets no annotations carries no annotations object at all. Annotations are inherited like any class attribute, so a shared base tool can set them once:

class ReadTool(MCPTool):
    annotations = {"readOnlyHint": True}


class ListOrders(ReadTool):
    """List orders."""  # inherits readOnlyHint

Annotations are advisory: per the spec, clients must treat them as untrusted from untrusted servers, so don't rely on them as an access control — gate writes in the tool itself.

Resources

Resources are addressable data sources your server exposes for reading. Each resource is an MCPResource subclass with a URI and a read() method. Declare them on the MCP with resources = [...] (parallel to tools):

from pathlib import Path

from plain.mcp import MCPResource
from plain.runtime import settings


class AppVersion(MCPResource):
    """Current deployed version."""

    uri = "config://app/version"
    mime_type = "text/plain"

    def read(self) -> str:
        return settings.VERSION


class AppReadme(MCPResource):
    """Project readme."""

    uri = "config://app/readme"
    mime_type = "text/markdown"

    def read(self) -> str:
        return Path("README.md").read_text()


class AppMCP(MCPView):
    name = "myapp"
    resources = [AppVersion, AppReadme]

Metadata is derived automatically:

  • Name defaults to the class name — override with name = "..."
  • Description comes from the class docstring (used verbatim)

Text vs binary. read() returns str for text (emitted as text) or bytes for binary (emitted as base64 blob).

Reading the invoking context. As with tools, self.mcp is set before read() is called — use self.mcp.request, and (for typed access to your view's user and other attributes) re-annotate mcp on a per-app base resource: class AppResource(MCPResource): mcp: AppMCP.

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.

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__:

class Order(MCPResource):
    """An order by ID."""

    uri_template = "orders://{order_id}"
    mime_type = "application/json"

    def __init__(self, order_id: int):
        self.order_id = order_id

    def read(self) -> str:
        return str(Order.query.get(pk=self.order_id))

Templates follow RFC 6570 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.

Templated resources appear under resources/templates/list (not resources/list); clients then resolve a concrete URI and call resources/read with it.

Naming

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.

  • Single MCP endpoint — use your app's name (typically matches settings.NAME from pyproject.toml)
  • Multiple endpoints in one app — prefix with the role: myapp-public, myapp-admin
  • A package shipping an MCP — use the package's own name

Multiple MCP endpoints

Create one MCPView subclass per endpoint. Each is mounted at its own path with its own tool surface and auth.

# app/mcp.py
from plain.auth.views import AuthView
from plain.mcp import MCPUnauthorized, MCPView


class AppMCP(MCPView, AuthView):
    name = "myapp-api"
    login_required = True
    tools = [ListCustomerOrders]


class StaffMCP(MCPView, AuthView):
    name = "myapp-staff"
    login_required = True
    tools = [DescribeSchema]

    def check_auth(self):
        super().check_auth()  # login_required from AuthView
        if not self.user.is_staff:
            raise MCPUnauthorized("Staff only")
# app/urls.py
urls = [
    path("api/mcp", AppMCP, name="app_mcp"),
    path("staff/mcp", StaffMCP, name="staff_mcp"),
]

Attaching tools to a shared MCP

Packages that need to contribute tools to an MCP they don't own (for example, adding a feature-flag tool to plain.admin.mcp.AdminMCP) use the register_tool() classmethod:

# plain/flags/mcp.py
from plain.admin.mcp import AdminMCP
from plain.mcp import MCPTool


class FlagStatus(MCPTool):
    """Whether a feature flag is currently enabled."""

    def __init__(self, name: str):
        self.name = name

    def run(self) -> dict:
        ...


AdminMCP.register_tool(FlagStatus)

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

Authentication

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

Session auth — compose with AuthView

For MCP endpoints consumed by users already signed into your Plain app, compose MCPView with plain.auth.views.AuthView. Put MCPView first in the base list so its handle_exception — which emits JSON-RPC errors — takes precedence over AuthView's HTML redirect rendering.

from plain.auth.views import AuthView
from plain.mcp import MCPView


class AppMCP(MCPView, AuthView):
    name = "myapp"
    login_required = True

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.

For role-based gating, override check_auth():

class StaffMCP(MCPView, AuthView):
    name = "myapp-staff"
    login_required = True

    def check_auth(self):
        super().check_auth()
        if not self.user.is_staff:
            raise MCPUnauthorized("Staff only")

Importing plain.auth is required only for this pattern — token-only deployments can ignore it.

Bearer token auth

For external integrations (CLI tools, remote clients, CI), subclass MCPView directly and check a header in before_request():

import hmac
import os

from plain.mcp import MCPView, MCPUnauthorized


class APIKeyMCP(MCPView):
    name = "myapp-api"

    def before_request(self) -> None:
        header = self.request.headers.get("Authorization", "")
        if not header.startswith("Bearer "):
            raise MCPUnauthorized("Missing or invalid Authorization header")
        if not hmac.compare_digest(header[7:], os.environ["MCP_TOKEN"]):
            raise MCPUnauthorized("Invalid auth token")

Clients send the token in their config:

{
  "mcpServers": {
    "my-app": {
      "url": "https://myapp.com/mcp",
      "headers": { "Authorization": "Bearer <token>" }
    }
  }
}

OAuth for MCP clients

Hosted MCP clients (Claude's custom connectors, etc.) authenticate over OAuth 2.1 — they discover your authorization server, register, and complete a browser login, with no token to paste. Compose OAuthResourceServer with MCPView and implement authenticate_token to validate the bearer against whatever issued it:

# app/mcp.py
from plain.mcp import MCPView, OAuthResourceServer, TokenInfo
from plain.oauthserver import validate_access_token


class AppMCP(OAuthResourceServer, MCPView):
    name = "myapp"
    tools = [...]

    def authenticate_token(self, token: str) -> TokenInfo | None:
        at = validate_access_token(token, resource=self.oauth_resource)
        return TokenInfo(at.user, at.scopes) if at else None

On success self.user and self.scopes are set for tools to read. On failure the request gets a 401 with an RFC 9728 WWW-Authenticate challenge that points the client at a protected-resource metadata document — that document names the authorization server, which is how the client knows where to authenticate.

Serve the metadata with MCPProtectedResourceView, mounted at the challenge path (.well-known/oauth-protected-resource/ + your MCP path):

# app/urls.py
from plain.mcp import MCPProtectedResourceView
from plain.urls import Router, path

from app.mcp import AppMCP


class AppMCPMetadata(MCPProtectedResourceView):
    pass  # authorization_servers defaults to this app's own origin


class AppRouter(Router):
    namespace = ""
    urls = [
        path("mcp", AppMCP, name="mcp"),
        path(
            ".well-known/oauth-protected-resource/mcp",
            AppMCPMetadata,
            name="mcp_prm",
        ),
    ]

authorization_servers defaults to this app's origin, so the same-app case (above) needs nothing set. Override it only when an external IdP issues the tokens.

If your app sets URLS_TRAILING_SLASH = True, mount both paths — including the catch-all variant below — with force_trailing_slash=False. MCP clients won't reliably follow a 308 trailing-slash redirect: they request the metadata URL exactly as the WWW-Authenticate challenge gives it (or construct the .well-known path from the resource identifier). Keep the two mounts on the same slash form either way, since the challenge URL is derived from the MCP path.

The metadata view derives resource from the request path, so an app with several MCP endpoints can mount one catch-all instead of a metadata view per endpoint: path(".well-known/oauth-protected-resource/<path:resource_path>", AppMCPMetadata).

plain.mcp only validates tokens and emits the challenge — it never issues them. The authorization server is yours to run; plain.oauthserver is a drop-in one. authenticate_token is the seam, so any issuer (a third-party IdP, a custom JWT service) works the same way.

Behind the scenes the client drives the whole handshake — you don't write any of it:

  1. Calls your MCP endpoint with no token → gets the 401 + WWW-Authenticate challenge.
  2. Reads the protected-resource metadata it points to → finds your authorization server.
  3. Fetches the server's metadata (/.well-known/oauth-authorization-server) and registers itself — no manual setup.
  4. Opens a browser to the authorize endpoint; the user logs in and approves.
  5. Exchanges the code (with PKCE) for an access + refresh token, then re-calls the endpoint with Authorization: Bearer <token>.

Public endpoints

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.

Filtering tools per request

Two hooks, one narrow and one broad:

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.

class AdminTool(MCPTool):
    @classmethod
    def allowed_for(cls, mcp) -> bool:
        return mcp.user is not None and mcp.user.is_admin


class DeleteUser(AdminTool):
    """Delete a user account.

    Args:
        user_id: ID of the user to delete.
    """

    def __init__(self, user_id: int):
        self.user_id = user_id

    def run(self) -> str:
        ...

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.

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:

class AppMCP(MCPView, AuthView):
    name = "myapp"
    login_required = True

    def get_tools(self):
        if self.user and self.user.is_superuser:
            return self.tools  # superuser sees everything, skipping allowed_for
        tools = super().get_tools()  # applies each tool's allowed_for
        if settings.READONLY_MODE:
            tools = [t for t in tools if not getattr(t, "mutates", False)]
        return tools

Row-level filtering ("only this user's notes") belongs inside run()/read() via self.mcp.user — not in the gating layer.

Custom JSON-RPC methods

plain.mcp ships server/discover, tools/*, and resources/* with first-class classes. Everything else in the MCP spec — prompts, completions, sampling — you implement directly on your MCPView subclass by defining a method named rpc_<method>. Slashes in the JSON-RPC method become underscores.

The pattern:

  1. Write an rpc_<method> method that takes a params dict and returns the response dict (as defined by the MCP spec for that method)
  2. Advertise the capability in get_capabilities() so clients know to call it
  3. Raise MCPInvalidParams for bad caller input; anything else becomes a generic INTERNAL_ERROR with the exception logged server-side

MCPInvalidParams is the error channel for these custom rpc_* handlers (and resources/read). It becomes a -32602 at HTTP 400, and takes an optional data payload so the client doesn't have to parse your message to learn what was wrong:

raise MCPInvalidParams(f"Unknown widget: {widget_id}", data={"widgetId": widget_id})

Tools are different: a tool's run() reports failures in its result via isError (still HTTP 200 — the call succeeded, the work didn't), so raise MCPToolError there instead — see Tools → Signaling errors.

Example: prompts

Here's a complete prompts implementation. Note that nothing in plain.mcp knows about prompts — it's pure dispatch + dict responses.

from plain.mcp import MCPInvalidParams, MCPView


_PROMPTS = [
    {
        "name": "summarize",
        "description": "Summarize a piece of text",
        "arguments": [
            {
                "name": "text",
                "description": "Text to summarize",
                "required": True,
            },
        ],
    },
    {
        "name": "standup",
        "description": "Draft a daily standup update",
    },
]


class AppMCP(MCPView):
    name = "myapp"

    def rpc_prompts_list(self, params):
        return {"prompts": _PROMPTS}

    def rpc_prompts_get(self, params):
        name = params.get("name")
        args = params.get("arguments") or {}

        if name == "summarize":
            text = args.get("text")
            if not text:
                raise MCPInvalidParams("Missing 'text' argument")
            return {
                "messages": [
                    {
                        "role": "user",
                        "content": {
                            "type": "text",
                            "text": f"Summarize the following in 2 sentences:\n\n{text}",
                        },
                    }
                ]
            }

        if name == "standup":
            return {
                "messages": [
                    {
                        "role": "user",
                        "content": {
                            "type": "text",
                            "text": "Draft today's standup based on my recent commits and PRs.",
                        },
                    }
                ]
            }

        raise MCPInvalidParams(f"Unknown prompt: {name}")

    def get_capabilities(self):
        caps = super().get_capabilities()
        caps["prompts"] = {}
        return caps

The same pattern works for any capability. rpc_completion_complete, rpc_prompts_get, etc. — consult the MCP spec for the method name and response shape. Your handler returns just the result dict; resultType, serverInfo, and (for */list and resources/read methods) ttlMs / cacheScope are stamped on automatically — see Requests and results.

Overriding built-ins

The shipped handlers (rpc_server_discover, 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.

Spec coverage

Where plain.mcp stands on each feature of the MCP 2026-07-28 spec. Built in means the framework handles it; build it yourself means the spec method is yours to implement on the documented rpc_ extension point; not supported means there's no way to do it today.

Spec feature Support How
Stateless Streamable HTTP (_meta, headers, validation ladder) built in Requests and results
server/discover built in Discoveryinstructions, get_capabilities()
Tools (tools/list, tools/call) built in MCPTool — schema derivation, validation, isError, annotations
Required client capabilities (-32021) built in MCPTool.required_client_capabilities
Resources (resources/list, templates/list, read) built in MCPResource — static URIs and templates
Result stamping (resultType, serverInfo, ttlMs/cacheScope) built in Requests and results — handler-set values win
OAuth resource server (RFC 9728) built in OAuthResourceServer, MCPProtectedResourceView
Per-request authorization built in allowed_for(), get_tools()/get_resources()
Prompts (prompts/list, prompts/get) build it yourself rpc_ methods — complete example there
Completions (completion/complete) build it yourself same rpc_ pattern
Any other spec or custom method build it yourself rpc_<method> + get_capabilities() + the two class-attribute tables
MRTR (input_required — sampling, elicitation, roots) not supported tools always return complete results
Subscriptions (subscriptions/listen, change notifications) not supported see FAQs — clients re-read instead
Progress notifications / SSE response streams not supported every response is a single JSON object (spec-permitted)
Tasks extension not supported not in the official SDK yet either
Mcp-Param-* headers (x-mcp-header) not supported no tool API to declare them, so clients never send them
Legacy protocol versions (pre-2026-07-28) not supported see FAQs

The conformance suite baseline at tests/conformance/expected-failures.yml is the machine-checked version of the "not supported" rows — it fails the build if reality drifts from this table in either direction.

FAQs

What MCP protocol version is supported?

The 2026-07-28 version of the MCP specification, using the Streamable HTTP transport — and only that version. It's the revision that made MCP stateless, so there's no initialize handshake to negotiate an older one down to, and no session or SSE machinery to keep alive. A client that opens with initialize gets the same 404 / -32601 as any other unknown method, with a message naming the version this server does speak — that's all a pre-2026-07-28 client can learn from us.

Are resource subscriptions supported?

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, and a stateless server has no stream to push down. Clients that need fresh data re-read the resource; the ttlMs: 0 stamped on read results tells them not to cache it.

How does auto-discovery work?

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().

Do I need to handle CSRF?

No. Non-browser clients (like AI assistants) don't send Origin or Sec-Fetch-Site headers, so Plain's CSRF protection skips them automatically.

Why are arguments on __init__ instead of run()?

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.

Why aren't tools just functions?

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.

Installation

Install the plain.mcp package from PyPI:

uv add plain-mcp

Add to your INSTALLED_PACKAGES:

# app/settings.py
INSTALLED_PACKAGES = [
    ...
    "plain.mcp",
]