3tears-mcp
Shared MCP (Model Context Protocol) framework. Per-product MCP servers compose this framework instead of reimplementing stdio transport, JWT auth, error mapping, and per-tool RBAC.
Install
pip install 3tears-mcp # server, tools, identity, PlatformHttpClient
pip install "3tears-mcp[grants]" # plus the Postgres-backed grant store
The base depends on the mcp SDK, 3tears-observe, httpx, starlette and uvicorn only. Everything
below that needs the rest of the family -- LocalGrantAuthorizer, rbac, migrations/ -- is the
grants extra. Bring your own Authorizer and the base is all you install.
What's in here
| Module | Responsibility |
|---|---|
server |
McpServer -- wraps the official mcp.server.Server. Owns tool registration, RBAC gating before handler dispatch, structured error mapping per the MCP spec. tool_error builds a tool-execution error a handler returns. |
tool |
McpTool dataclass (name, description, input_schema, required_permission, handler, and optional output_schema, title, annotations), McpToolAnnotations, ToolRegistry, ToolProvider, and the register_tool decorator. |
http_server |
The Streamable-HTTP transport: McpHttpTransport for a host app to embed, build_mcp_http_app / serve_mcp_http to serve standalone, current_bearer_token for BearerTokenIdentityProvider. |
http_client |
PlatformHttpClient -- typed httpx client with JWT login + refresh-on-401. Used by both MCP server tool handlers (calling /api/v1/...) and CLI scripts. One HTTP-client implementation, two transports. |
auth |
Identity dataclass + IdentityProvider Protocol + EnvVarIdentityProvider (stdio impl). Authorizer Protocol + LocalGrantAuthorizer (default impl backed by McpToolGrantCollection). |
rbac |
McpToolGrantCollection -- BaseCollection over mcp_tool_grants. Exposes the in-memory grant cache that LocalGrantAuthorizer consults. |
migrations/ |
v01_create_mcp_tool_grants -- platform-scope DDL. Consumers register via MigrationRunner.register(epoch_pkg) (same shape as threetears.epoch). |
Tool metadata
McpTool (and register_tool) take three optional fields, listed by tools/list under their
protocol names:
McpTool(
name="runners.list",
description="list the runners",
input_schema={"type": "object", "properties": {}},
required_permission="runners.read",
handler=list_runners,
output_schema={"type": "object", "properties": {"runners": {"type": "array"}}, "required": ["runners"]},
title="List runners",
annotations=McpToolAnnotations(read_only_hint=True, idempotent_hint=True),
)
| Field | Listed as | Notes |
|---|---|---|
output_schema |
outputSchema |
A JSON Schema whose type is "object", checked when the tool is constructed. While a tool declares one, every successful call must carry structuredContent that validates against it. Otherwise the server answers OUTPUT_VALIDATION_ERROR in place of the result and logs an error, because the bug is in the tool. Error results are exempt. |
title |
title |
A human-readable display name. name stays the identifier the client calls. |
annotations |
annotations |
McpToolAnnotations(read_only_hint=, destructive_hint=, idempotent_hint=, open_world_hint=), listed as readOnlyHint, destructiveHint, idempotentHint and openWorldHint. A hint left None is omitted, so the client applies the spec's default for it (read-only False, destructive True, idempotent False, open-world True). Hints are advice to the client, not something the server enforces. |
Results and tool errors
What a handler returns becomes the call's result:
| Handler returns | The client receives |
|---|---|
a dict |
structuredContent set to the dict, plus a text block holding its serialized JSON (the spec asks for both) |
| any other JSON-serializable value | the same, with the value wrapped as {"result": value} |
str, TextContent or a list of TextContent |
text content only, with no structuredContent |
tool_error({...}) |
isError: true, with the object as structuredContent and as serialized JSON in a text block |
an mcp.types.CallToolResult |
that result, passed through unchanged |
tool_error is for the spec's tool-execution error: the tool ran, and the call failed in a way the
caller can read and act on:
from threetears.mcp import tool_error
async def get_runner(runner_id: str) -> dict:
runner = registry.find(runner_id)
if runner is None:
return tool_error({"code": "NOT_FOUND", "message": f"no runner {runner_id!r}", "retryable": False, "field": "runner_id"})
return {"runner": runner.describe()}
A tool error is not logged, it is not mapped to one of the framework's codes, and output_schema
doesn't apply to it. The framework's own refusals (UNKNOWN_TOOL, PERMISSION_DENIED,
IDENTITY_UNAVAILABLE, AUTHZ_ERROR, HANDLER_ERROR, OUTPUT_VALIDATION_ERROR, TOOLS_UNAVAILABLE)
are also isError: true, but they carry {"tool_name": ..., "error": {"code": ..., "message": ...}} in
a text block and no structuredContent. A handler that raises still gets HANDLER_ERROR.
A live tool set
McpServer takes either a fixed registry= or a tool_provider=. A provider is a callable that
returns the tools as they stand right now (any iterable of McpTool). The server calls it on every
tools/list and every tools/call, so a tool set that follows a live registry is served as it is at
each request: a tool added at runtime can be called, a removed one answers UNKNOWN_TOOL, and arguments
are checked against the input schema the tool has now.
server = McpServer(
name="coordinator",
identity_provider=identity_provider,
authorizer=authorizer,
tool_provider=lambda: [op_to_tool(op) for op in ops.current()],
)
Tool names must be unique in each answer. If the provider raises, or returns two tools with one name, a
tools/call answers TOOLS_UNAVAILABLE and logs an error, and a tools/list fails with a JSON-RPC
error. The server does not send notifications/tools/list_changed, because a stateless HTTP transport
has no session to push it on. Clients see a changed set on their next tools/list.
Embedding the HTTP transport in a host app
build_http_app() returns a standalone Starlette app that runs the SDK's session manager in its own
lifespan. Starlette does not run a mounted sub-app's lifespan, so a host serving MCP inside its own app
uses build_http_transport() instead. It adds the transport's routes and enters the transport's
lifespan in its own:
await server.start()
transport = server.build_http_transport(path="/mcp") # stateless=True, json_response=True by default
@contextlib.asynccontextmanager
async def lifespan(app: Starlette) -> AsyncIterator[None]:
async with transport.lifespan():
yield
app = Starlette(routes=[*own_routes, *transport.routes], lifespan=lifespan)
transport.routesserves the endpoint at exactlypath.transport.endpointis the raw ASGI app, for a host's ownRouteorMount. AMount("/mcp", app=transport.endpoint)answers at/mcp/, and Starlette redirectsPOST /mcpthere, so usetransport.routesif the endpoint has to be exactly/mcp.- A request that arrives while the lifespan isn't running gets a 503 that names
McpHttpTransport.lifespan(). Without that, the SDK raises a bare "Task group is not initialized". - A transport runs once, because the SDK's session manager is single-use. A host that restarts builds a new transport.
Bearer identity works the same way it does under build_http_app(). The endpoint reads the request's
Authorization: Bearer header into current_bearer_token(), which is the token_source to give
BearerTokenIdentityProvider.
Protocol versions
The protocol versions are the SDK's. With the locked mcp 2.3.0, the HTTP transport serves the
handshake-era revisions (up to 2025-11-25) and the 2026-07-28 single-exchange protocol, including
server/discover. A 2.x mcp.Client in its default auto mode probes server/discover and settles
on 2026-07-28, and its calls go through the same dispatch, RBAC gate and bearer identity
(tests/unit/test_http_transport_embedding.py proves it). On mcp 1.x the transport speaks the
handshake only. 3tears adds nothing per version, so a newer SDK's versions are served once the lock
moves to it.
RBAC model
Per-tool, default-deny. Each McpTool declares a required_permission string (e.g. "conversations.read", "audit.read"). On every dispatch:
- The framework calls
Authorizer.allows(identity, required_permission). LocalGrantAuthorizerchecks whether the caller's identity matches an active grant inMcpToolGrantCollectionfor the requested permission.- If denied, the framework returns a structured MCP error to the client (not a Python exception in the response body).
The configured admin identity (env-var creds in the stdio impl) is auto-granted in memory at server startup. The grant is logged but NOT written to mcp_tool_grants. This keeps the table truthful (only operator-added grants live there).
Grant changes propagate cross-pod via the mcp.rbac epoch broadcast. LocalGrantAuthorizer subscribes to Subjects.mcp_rbac_epoch() via an EpochListener; on bump it reloads the grant cache from L3. Cold-start primes from EpochClient.current(...). Missed broadcasts recover via the standard pull-on-stale path.
Identity in v1
EnvVarIdentityProvider returns one fixed Identity for the lifetime of the server, derived from env-var credentials. v2 (HTTP transport + per-call bearer-token identity) plugs in by adding a BearerTokenIdentityProvider; the rest of the framework is unchanged. The Authorizer.allows(identity, permission) interface is unchanged between v1 and v2.
Stdio transport discipline
Every byte on stdout/stderr from a stdio MCP server confuses the client. The framework configures logging to a file or to NATS; no module under threetears.mcp should write to sys.stdout / sys.stderr. An AST enforcement test guards this.
Postgres backing
CREATE TABLE IF NOT EXISTS mcp_tool_grants (
grant_id UUID PRIMARY KEY,
principal_type TEXT NOT NULL, -- 'user' | 'group' | 'role'
principal_id UUID NOT NULL,
tool_name TEXT NOT NULL,
permission TEXT NOT NULL,
date_created TIMESTAMPTZ NOT NULL DEFAULT now()
);
Mutation paths (admin POST /admin/mcp/grants, DELETE /admin/mcp/grants/{id}) bump Subjects.mcp_rbac_epoch() after the row commit; sibling pods reload via the epoch listener.
Metadata
Release files for 3tears-mcp 0.77.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| 3tears_mcp-0.77.0.tar.gz | 80.1 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 3tears_mcp-0.77.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 127.2 kB
Release files / 3tears_mcp-0.77.0.tar.gz
| Download URL | 3tears_mcp-0.77.0.tar.gz |
|---|---|
| Size | 80.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
153eef49e646a37d7a96ea6b2c099b5ee613c356eaa7ad95136346b0562e537e
|
|
BLAKE2b-256 checksum How to use checksums |
eb2c8f37dfaa95866d224128349fa4e7c68ea3bd5c09bd9f0caa97fbdc15f8dd
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 24, 2026.
Transparency logRelease files / 3tears_mcp-0.77.0-py3-none-any.whl
| Download URL | 3tears_mcp-0.77.0-py3-none-any.whl |
|---|---|
| Size | 47.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
66b6e5e0e8b304a70973694b9da7c503bf31fa42864093e6b9cd6a488ed1afc0
|
|
BLAKE2b-256 checksum How to use checksums |
a449e0214336631b12bc188eff2c2403f9e7478c0236b2db3589b8b1c0e409b9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 24, 2026.
Transparency logSecurity for 3tears-mcp 0.77.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for 3tears_mcp-0.77.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 24, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.