mcp-core
Provides shared MCP-server machinery for Flask apps, including streamable HTTP transport, token-based auth, caller identity resolution, tool registry with permissions, and audit logging, enabling developers to build consistent and secure MCP servers.
README
mcp-core
Shared MCP-server machinery for the DNS platform Flask apps (SR, Books, CRM,
DM, PM). Each app currently hand-rolls the same five things in its
app/routes/mcp.py; this library extracts them once, behind interfaces:
| Module | What it extracts | Reference implementation |
|---|---|---|
mcp_core.transport |
Streamable HTTP transport (spec rev 2025-03-26): single POST /mcp endpoint, JSON-RPC 2.0 + batches, stateless (GET -> 405, DELETE -> 200), bearer required on every request |
SR mcp_streamable |
mcp_core.auth |
TokenValidator protocol + sha256 token-hash helpers (no plaintext-recoverable storage, ever) |
SR/Books validate_bearer_token |
mcp_core.identity |
CallerResolver / PrincipalResolver protocols, fail-closed caller resolution, explicit with_external_tiers opt-in |
SR resolve_caller, Books _resolve_service_caller |
mcp_core.registry |
ToolRegistry: declarative per-tool permissions, central identity + permission gate, dynamic per-call re-gating, per-caller tools/list, caller-identity schema injection |
Books TOOL_PERMISSIONS + execute_tool gate, SR _tools_for_caller |
mcp_core.errors |
The frozen tool-error contract ({"error": ...} inside a successful JSON-RPC result) |
both |
mcp_core.audit |
AuditSink protocol + logging sink; one event per dispatch with (org, caller, tool, outcome) |
new (was implicit log lines) |
mcp_core.authz_ui |
Catalog-driven Members + Roles admin pages (0.2.0): PermissionCatalog + AuthzProvider protocol + blueprint factory rendering the roles matrix from the catalog |
Books PERMISSION_LABELS + settings Users & Permissions UI |
App-agnostic by construction: zero imports from any app, no SQLAlchemy
dependency, no models. Every integration point is a typing.Protocol the
app implements over its own tables. Flask is the only runtime dependency
(the transport is a Blueprint); the app decides the Flask/SQLAlchemy
versions in play.
Frozen contracts
These are wire contracts the Phone app, ChatGPT connectors, and cross-app service-token callers already depend on. Changing any of them requires a coordinated migration across every consumer — treat them as frozen:
- Tool errors are successful JSON-RPC results. A refused or failed
tool call returns
"result"whosecontent[0].textis the JSON of{"error": "<message>"}. JSON-RPC"error"objects are protocol-level only (-32700 parse, -32601 unknown method, -32000 unauthenticated, -32603 unexpected handler exception). - Frozen error strings (agents parse and act on these):
Unknown tool: {name}caller_phone or caller_email is required when calling via a service tokenPermission denied: {permission}
- Result serialization: the tool result dict is serialized with
json.dumps(result, indent=2)into a singletextcontent block. - Endpoint semantics:
POST <prefix>(and<prefix>/) for JSON-RPC;GET-> 405 withAllow: POST, DELETE;DELETE-> 200 no-op; missing or invalid bearer -> 401 with JSON-RPC error code -32000.
Fail-closed guarantees (preserved from the shipped slices)
- Unknown tool name -> refused before anything else is inspected.
- Service token without
caller_phone/caller_email-> refused, unless the tool is registeredidentity_exempt=True. Keep that set tiny and structural (machine-to-machine callbacks only — SR'sreceive_call_resultis the canonical member). Never exempt a tool a human invokes. - Unresolvable caller (unknown identity, misconfigured staff role, no
org membership, no resolver configured) -> refused. Resolvers signal this
by raising
IdentityError; the message is surfaced verbatim as the tool error. - Unmapped permission -> a tool declares its requirement at
registration;
dynamic_permissionderivingNonefrom the arguments refuses the call. There is no allow-by-default path anywhere. - No fallback tiers. An unknown caller never becomes a guest. SR-style
anonymous external tiers are an explicit opt-in via
with_external_tiers(staff_resolver, classifier)— and even then the fallback is phone-keyed only; an unknowncaller_emailalways fails closed (an email must never bootstrap an identity).
Usage sketch
from mcp_core import (
AnyOf, Caller, IdentityError, McpServer, TokenContext, ToolRegistry,
hash_token, streamable_http_blueprint,
)
# 1) Bearer validation over the app's oauth_tokens table.
class AppTokenValidator:
def validate(self, bearer):
row = OAuthToken.query.filter_by(access_token_hash=hash_token(bearer)).first()
if not row or not row.is_access_valid():
return None
if row.organization_id:
return TokenContext(org_id=str(row.organization_id), kind="service")
user = User.query.get(row.user_id)
if not user or not user.is_active():
return None
return TokenContext(org_id=None, kind="user", principal_user_id=str(user.id))
# 2) Caller resolution over the app's users/memberships.
class AppCallerResolver:
def resolve(self, org_id, caller_phone=None, caller_email=None):
ou = ... # phone wins over email; search ONLY org_id; normalize app-side
if ou is None:
raise IdentityError("caller not recognized in this organization")
return Caller(user_id=str(ou.user_id), role=ou.role,
permissions=frozenset(perms_for(ou)), extra={"org_user": ou})
class AppPrincipalResolver:
def resolve_principal(self, token, org_id):
ou = ... # verify ACTIVE membership of token.principal_user_id in org_id
if ou is None:
raise IdentityError("Access denied to this organization")
return Caller(...)
# 3) Declare tools with the app's permission keys.
registry = ToolRegistry(
caller_resolver=AppCallerResolver(),
principal_resolver=AppPrincipalResolver(),
permission_evaluator=lambda caller, key: ..., # optional: has_permission semantics
)
@registry.tool("list_invoices", permission="ar.invoices.view",
schema={"type": "object", "properties": {...}})
def list_invoices(ctx, args):
# ctx.caller is the resolved acting person; ctx.org_id the tenant.
return {"invoices": [...]}
# 4) Mount the transport.
server = McpServer(registry=registry, validator=AppTokenValidator(),
name="Books MCP Server", version="1.0.0")
app.register_blueprint(streamable_http_blueprint(server)) # serves POST /mcp
0.1.1 additions (SR pilot)
ToolRegistry(visibility_filter=...): optional app-ownedtools/listview —(token, org_id) -> iterable of visible tool names | None. Returning names advertises exactly those registered tools (registration order);Nonefalls through to the default logic. Advertisement-only; never gates dispatch. Built for apps that migrate withpermission=Noneregistrations (per-handler gates kept in place, e.g. SR): their per-caller listing can't be derived from declared requirements.mcp_core.transport.dispatch_message(server, token, data)is now public: legacy transports still mounted during a migration (SR's/mcp/ssePOST) route their JSON-RPC through the same single dispatch core. The caller owns bearer validation and the HTTP envelope.
0.2.0 additions — mcp_core.authz_ui (catalog-driven admin UI)
New subpackage: shared Members + Roles admin pages generated from a
per-app permission catalog, so all five apps get one consistent roles
matrix instead of five hand-built ones. Same construction rules as the
rest of the library — no database access, no app imports, every
integration point a protocol — but it is admin-UI machinery, not
MCP-server machinery, so it is not re-exported from the top-level
mcp_core namespace. Import from mcp_core.authz_ui directly.
1) Declare a catalog
The catalog is the single source of truth: ordered groups (module ->
entries), each entry a dotted key (module.entity.action[.scope], 2–4
lowercase segments) plus display label and optional description. Keys are
the SAME strings the app's web gates and MCP tool registrations use.
Validation (well-formed keys, uniqueness, non-empty labels/groups) runs at
construction, so a bad catalog fails at app startup, never in front of an
admin.
Books adopts its existing PERMISSION_LABELS mapping verbatim:
from mcp_core.authz_ui import PermissionCatalog
from app.utils.permissions import PERMISSION_LABELS, PERMISSION_INHERITANCE
catalog = PermissionCatalog.from_dict(
PERMISSION_LABELS, inheritance=PERMISSION_INHERITANCE
)
Apps without a labels dict declare groups directly:
catalog = PermissionCatalog(
[
("Properties", [
("pm.properties.view", "View properties"),
("pm.properties.manage", "Manage properties",
"Create, edit, archive properties"),
]),
("Users & Permissions", [
("settings.users.view", "View team members"),
("settings.users.invite", "Invite new users"),
("settings.roles.manage", "Manage roles (create/edit/delete)"),
]),
],
inheritance={"manage": ["view"]}, # display hint only (data-implies +
) # a note); enforcement stays app-side
2) Implement the provider
AuthzProvider is the app-side seam — the package never touches a DB.
Implement it over the app's own tables (Books: organization_users +
roles + role_permissions):
from mcp_core.authz_ui import AuthzError, Member, Role
class AppAuthzProvider:
def can_manage(self, actor): # the single page gate; fail closed
ou = current_org_user()
return ou is not None and (
ou.role in ("owner", "admin")
or has_permission(ou, "settings.users.edit")
)
def list_members(self, org): ... # -> [Member(...), ...]
def get_member(self, org, member_id): ...
def invite_member(self, org, email, role_id): ... # app owns email/token flow
def set_member_role(self, org, member_id, role_id): ...
def deactivate_member(self, org, member_id): ...
def reactivate_member(self, org, member_id): ...
def list_roles(self, org): ... # -> [Role(...), ...]
def get_role(self, org, role_id): ... # None -> 404
def create_role(self, org, name, description=""): ...
def set_role_permissions(self, org, role_id, keys): ... # FULL-set replace
def delete_role(self, org, role_id): ...
Conventions: org is opaque (whatever your org_loader returns — id, ORM
row, or None for single-tenant). Refuse an operation by raising
AuthzError("user-facing message") — HTML pages flash it, JSON endpoints
return it as a 400 {"error": ...}. Mark hardcoded roles (Books'
owner/admin) Role(is_system=True): they render as "Full access /
built-in", are excluded from the invite form, and the blueprint refuses to
edit or delete them even on a forged form (keep the provider refusing too
— defense in depth). Every matrix save is validated against the catalog
before the provider sees it, so unknown/forged keys can never be
written.
3) Mount the blueprint
from mcp_core.authz_ui import authz_admin_blueprint
app.register_blueprint(authz_admin_blueprint(
catalog,
AppAuthzProvider(),
base_template="base.html", # must define a `content` block
url_prefix="/admin/authz", # default
actor_loader=lambda: current_user, # optional; default passes None
org_loader=lambda: session.get("org_id"), # optional; default passes None
))
Routes (all gated by provider.can_manage(actor) -> 403; JSON routes get
a JSON 403 body):
| Route | What it does |
|---|---|
GET /members |
Member table, invite form, per-row role assign + activate toggle |
POST /members/invite, POST /members/<id>/role, POST /members/<id>/active |
Member ops (form posts, redirect + flash) |
GET /roles, POST /roles |
Role list + create form |
GET /roles/<id> |
Permission matrix generated from the catalog: one fieldset per group, one checkbox per key |
POST /roles/<id>/permissions |
Saves the FULL checked set (unchecked = revoked) |
POST /roles/<id>/delete |
Delete role |
GET /api/catalog, GET/POST /api/members..., GET/POST/PUT/DELETE /api/roles... |
JSON mirror of every operation for JS-driven pages (Books' settings page fetches JSON today) — same gate, same validation |
Templates extend the app-supplied base_template and fill its content
block. Markup is plain semantic HTML with Bootstrap-ish class names
(table, btn btn-primary, badge) plus authz-* hooks — styled apps
look native, unstyled apps degrade fine. Inheritance hints render as
data-implies="..." attributes on checkboxes for apps that want Books'
auto-check-view JS behavior.
Adoption: like the MCP migration, each app swaps its existing Users & Permissions pages in its own dedicated slice — nothing imports
authz_uiyet. PM goes first: it has NO roles UI at all today, so it adopts greenfield (declare catalog, implement provider, mount) with nothing to swap. Books is the reference UX this generalizes (app/templates/modules/settings.html); it can keep its JS-driven page on the/api/*endpoints and retire its hand-built matrix last.
Adoption guide (per app)
Each app migrates in its own slice, in four steps:
- Implement
TokenValidatorover the app'soauth_tokenstable (mcp_core.hash_tokenmatches the existingOAuthToken.hash_tokensha256 digests byte for byte, so no data migration). Service tokens map toTokenContext(kind="service", org_id=...); user tokens toTokenContext(kind="user", principal_user_id=...). - Implement
CallerResolver(andPrincipalResolver) over the app's users/memberships tables. Books-style: any miss raisesIdentityError(no guest tier). SR-style external callers: wrap the staff resolver withwith_external_tiers(...)and classifyexternal_known/external_newin the app. Keep resolution stateless across requests; per-request memoization (Flaskg) is fine. - Declare tools via
@registry.tool(name, permission=..., schema=...)using the app's existing permission keys (the same strings the web UI enforces). Port Books'TOOL_PERMISSIONSentries 1:1 — tuples becomeAllOf(...),()becomesAllOf(), per-entity re-gates becomedynamic_permission. Pass the app'shas_permissionsemantics aspermission_evaluatorwhere owner-full-access / manage->view inheritance matter. Mark structural machine-to-machine callbacksidentity_exempt=True— nothing else. - Mount the blueprint: build an
McpServerand registerstreamable_http_blueprint(server)at/mcp. Existing legacy routes (/mcp/sse,/mcp/messages, REST discovery endpoints) can coexist during the migration window and be retired once clients move.
Migration status: SR, Books, CRM, DM, and PM all keep their current hand-rolled MCP servers until each is migrated in its own dedicated slice. Nothing imports
mcp_coreyet; this repo only has to stay byte-compatible with the frozen contracts above so those slices are drop-in.
Development
python -m pip install -e .[dev]
python -m pytest
The test suite is pure library — fake validators/resolvers, no database, no app imports.
Recommended Servers
playwright-mcp
A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.
Magic Component Platform (MCP)
An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.
Audiense Insights MCP Server
Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.
VeyraX MCP
Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.
graphlit-mcp-server
The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.
Kagi MCP Server
An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.
Neon Database
MCP server for interacting with Neon Management API and databases
E2B
Using MCP to run code via e2b.
Exa Search
A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.
Qdrant Server
This repository is an example of how to create a MCP server for Qdrant, a vector search engine.