Skip to main content
Glama
rwgrooms

mcp-core

by rwgrooms

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:

  1. Tool errors are successful JSON-RPC results. A refused or failed tool call returns "result" whose content[0].text is the JSON of {"error": "<message>"}. JSON-RPC "error" objects are protocol-level only (-32700 parse, -32601 unknown method, -32000 unauthenticated, -32603 unexpected handler exception).

  2. Frozen error strings (agents parse and act on these):

    • Unknown tool: {name}

    • caller_phone or caller_email is required when calling via a service token

    • Permission denied: {permission}

  3. Result serialization: the tool result dict is serialized with json.dumps(result, indent=2) into a single text content block.

  4. Endpoint semantics: POST <prefix> (and <prefix>/) for JSON-RPC; GET -> 405 with Allow: POST, DELETE; DELETE -> 200 no-op; missing or invalid bearer -> 401 with JSON-RPC error code -32000.

Related MCP server: @variant/mcp-server

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 registered identity_exempt=True. Keep that set tiny and structural (machine-to-machine callbacks only — SR's receive_call_result is 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_permission deriving None from 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 unknown caller_email always 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-owned tools/list view — (token, org_id) -> iterable of visible tool names | None. Returning names advertises exactly those registered tools (registration order); None falls through to the default logic. Advertisement-only; never gates dispatch. Built for apps that migrate with permission=None registrations (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/sse POST) 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_ui yet. 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:

  1. Implement TokenValidator over the app's oauth_tokens table (mcp_core.hash_token matches the existing OAuthToken.hash_token sha256 digests byte for byte, so no data migration). Service tokens map to TokenContext(kind="service", org_id=...); user tokens to TokenContext(kind="user", principal_user_id=...).

  2. Implement CallerResolver (and PrincipalResolver) over the app's users/memberships tables. Books-style: any miss raises IdentityError (no guest tier). SR-style external callers: wrap the staff resolver with with_external_tiers(...) and classify external_known / external_new in the app. Keep resolution stateless across requests; per-request memoization (Flask g) is fine.

  3. 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_PERMISSIONS entries 1:1 — tuples become AllOf(...), () becomes AllOf(), per-entity re-gates become dynamic_permission. Pass the app's has_permission semantics as permission_evaluator where owner-full-access / manage->view inheritance matter. Mark structural machine-to-machine callbacks identity_exempt=True — nothing else.

  4. Mount the blueprint: build an McpServer and register streamable_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_core yet; 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.

Related MCP Connectors

Related MCP Servers

  • F
    license
    Not graded
    quality
    D
    maintenance
    Framework for building and running MCP servers as HTTP services. Define tools as pure Python functions, wire up with two lines, run with one command.
    -
  • F
    license
    Not graded
    quality
    B
    maintenance
    Shared MCP HTTP server infrastructure for plugin projects, providing Express + Streamable HTTP transport, OAuth/OIDC auth, runtime configuration, tool registration, and widget support.
    5 npm
    -
  • A
    license
    Not graded
    quality
    C
    maintenance
    A universal MCP server for registering internal, external, and OpenAPI-based APIs as MCP tools. It exposes them to MCP clients via Streamable HTTP and provides admin portal, RBAC/session auth, credential injection, and audit logging.
    Academic Free v1.1
  • A
    license
    Not graded
    quality
    B
    maintenance
    Provides a reusable MCP server foundation with explicit tool registration, operation-mode separation, scope-based permissions, structured results, and built-in health/status tools.
    MIT