Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 59 additions & 14 deletions packages/agami-core/src/mcp_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,13 @@
import admin
import onboarding
import user_store
from oss_adapters import PresenceAuthProvider, SingleTenantOrgResolver
from ports import AuthProvider, Org
from oss_adapters import (
FileActivitySink,
PresenceAuthProvider,
SingleTenantOrgResolver,
WarnOnlyGovernancePolicy,
)
from ports import Adapters, AuthProvider, Org, OrgResolver
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
Expand Down Expand Up @@ -93,6 +98,19 @@ def _build_org_resolver() -> SingleTenantOrgResolver:
return SingleTenantOrgResolver(Org(id=org_id))


def default_adapters() -> Adapters:
"""The OSS default adapters bundled for the composition root (env-driven auth + org, exactly as
today). `create_app(adapters=None)` uses these — so a plain deploy is unchanged. `create_app`
wires `auth_provider` + `org_resolver` into the request path; `activity_sink` + `governance` are
carried on the container for consumers and not yet referenced by a core call site."""
return Adapters(
activity_sink=FileActivitySink(),
org_resolver=_build_org_resolver(),
auth_provider=_build_auth_provider(),
governance=WarnOnlyGovernancePolicy(),
)


def public_base_url() -> str:
"""The explicit public base URL. Required — discovery + redirect URIs are built from it and it
can't be inferred behind a proxy/LB (the OAUTH_ISSUER_URL gotcha)."""
Expand Down Expand Up @@ -173,7 +191,7 @@ class _AuthMiddleware(BaseHTTPMiddleware):
open. On a request that passes auth, resolve the single-tenant org and attach it to
request.state.org — the explicit single-tenant contract + the multi-tenant seam."""

def __init__(self, app, resolver: SingleTenantOrgResolver, auth: AuthProvider) -> None:
def __init__(self, app, resolver: OrgResolver, auth: AuthProvider) -> None:
super().__init__(app)
self._resolver = resolver
self._auth = auth
Expand Down Expand Up @@ -252,24 +270,26 @@ async def _auth_server(request: Request) -> JSONResponse:
)


def build_server():
"""A low-level MCP Server whose tool surface IS the shared registry — list_tools / call_tool
read straight from `tools.TOOLS`, so HTTP advertises exactly what stdio does (no duplicate defs)."""
def build_server(registry: dict | None = None):
"""A low-level MCP Server whose tool surface IS the given registry — list_tools / call_tool read
from it, so HTTP advertises exactly what stdio does (no duplicate defs). Defaults to the shared
`tools.TOOLS`; `create_app` passes a merged copy (base + a consumer's extra tools)."""
import mcp.types as mt
from mcp.server.lowlevel import Server

registry = TOOLS if registry is None else registry
server = Server(SERVER_NAME, version=server_version(), instructions=SERVER_INSTRUCTIONS)

@server.list_tools()
async def _list_tools() -> list:
return [
mt.Tool(name=name, description=meta["description"], inputSchema=meta["inputSchema"])
for name, meta in TOOLS.items()
for name, meta in registry.items()
]

@server.call_tool()
async def _call_tool(name: str, arguments: dict) -> list:
meta = TOOLS.get(name)
meta = registry.get(name)
if meta is None:
raise ValueError(f"Unknown tool: {name}")
# Record every tool call to the admin activity log — timed, attributed to the authenticated
Expand Down Expand Up @@ -299,9 +319,16 @@ async def _call_tool(name: str, arguments: dict) -> list:
return server


def build_app() -> Starlette:
"""The ASGI app: the `.well-known` discovery routes + the streamable-HTTP MCP endpoint at /mcp,
behind the bearer-presence auth middleware."""
def create_app(extra_tools: dict | None = None, adapters: Adapters | None = None) -> Starlette:
"""The ASGI app + the composition factory: the `.well-known` discovery routes + the
streamable-HTTP MCP endpoint at /mcp, behind the auth middleware. Merges `extra_tools` over a
COPY of the shared TOOLS (never mutating the global) and wires the `adapters` into the request
path (auth + org resolution; OSS defaults when None). `create_app()` with no args == the
historical `build_app()` behavior.

Reusing an existing tool name in `extra_tools` overrides that tool in this app's registry copy —
intentional at the composition root (the caller opts in explicitly). `tools.register` is the
guarded path that refuses a duplicate name."""
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager

# Fail fast at construction if PUBLIC_BASE_URL is unset — not per-request inside the middleware
Expand All @@ -317,9 +344,21 @@ def build_app() -> Starlette:
"PUBLIC_BASE_URL must be https:// (OAuth + the Secure admin cookie need TLS)."
)
bootstrap_paths()
auth_provider = _build_auth_provider()
adapters = adapters or default_adapters()
auth_provider = adapters.auth_provider
# Validate consumer-supplied tools up front so a malformed entry fails at construction with a
# clear error, not later as a KeyError/500 inside tools/list or tools/call.
for tool_name, meta in (extra_tools or {}).items():
if not isinstance(meta, dict) or not {"handler", "description", "inputSchema"} <= set(meta):
raise ValueError(
f"extra tool {tool_name!r} must be a dict with handler, description, inputSchema"
)
if not callable(meta["handler"]):
raise ValueError(f"extra tool {tool_name!r} handler must be callable")
# Merge the consumer's extra tools over a COPY of TOOLS — the module global is never mutated.
registry = {**TOOLS, **(extra_tools or {})}
session_manager = StreamableHTTPSessionManager(
app=build_server(), json_response=True, stateless=True
app=build_server(registry), json_response=True, stateless=True
)
Comment on lines +358 to 362

async def handle_mcp(scope, receive, send):
Expand Down Expand Up @@ -389,11 +428,17 @@ async def _root(request: Request) -> Response:
# Outermost: normalize the bare `/mcp` → `/mcp/` before routing so Starlette's Mount doesn't
# 307-redirect it (claude.ai posts `{base}/mcp` and won't follow the redirect). See _NormalizeMcpSlash.
Middleware(_NormalizeMcpSlash),
Middleware(_AuthMiddleware, resolver=_build_org_resolver(), auth=auth_provider),
Middleware(_AuthMiddleware, resolver=adapters.org_resolver, auth=auth_provider),
]
return Starlette(routes=routes, middleware=middleware, lifespan=lifespan)


def build_app() -> Starlette:
"""Backwards-compatible entrypoint — `create_app()` with the OSS defaults and no extra tools, so
the existing `python -m mcp_http` / `main()` path is unchanged."""
return create_app()


def main() -> int:
import uvicorn

Expand Down
21 changes: 21 additions & 0 deletions packages/agami-core/src/ports.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,24 @@ class GovernancePolicy(Protocol):
OSS default = warn-only ("basic governance warning"); enforcement is a paid tier."""

def evaluate(self, ctx: object | None = None) -> GovernanceVerdict: ...


# ---------------------------------------------------------------------------
# Composition-root container — the four adapters passed as one argument
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class Adapters:
"""The four port adapters, bundled so ``mcp_http.create_app`` takes them as one argument.

A consumer builds this with its own implementations of the four ports (its own ``OrgResolver``,
``AuthProvider``, ``ActivitySink``, and ``GovernancePolicy``); passing ``adapters=None`` to
``create_app`` uses the OSS defaults (``mcp_http.default_adapters``). Today ``create_app`` wires
``auth_provider`` + ``org_resolver`` into the request path; ``activity_sink`` + ``governance``
are carried here for consumers and not yet referenced by a core call site."""

activity_sink: ActivitySink
org_resolver: OrgResolver
auth_provider: AuthProvider
governance: GovernancePolicy
17 changes: 17 additions & 0 deletions packages/agami-core/src/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import subprocess
import sys
import time
from collections.abc import Callable
from pathlib import Path
from typing import Any

Expand Down Expand Up @@ -1179,3 +1180,19 @@ def _record_tool_call(rec: dict[str, Any]) -> None:
},
},
}


def register(
name: str,
handler: Callable[[dict[str, Any]], str],
description: str,
inputSchema: dict[str, Any],
) -> None:
"""Add a tool to the shared TOOLS registry — the supported consumer extension point.

Raises on a duplicate name so a consumer can't silently shadow a core tool (e.g. execute_sql).
Note create_app merges a consumer's extra tools over a *copy* of TOOLS; register() mutates the
module global directly (the stdio path uses it), so its dup-guard is the safety net either way."""
if name in TOOLS:
raise ValueError(f"tool {name!r} is already registered")
TOOLS[name] = {"handler": handler, "description": description, "inputSchema": inputSchema}
Comment on lines +1185 to +1198
141 changes: 141 additions & 0 deletions tests/test_tool_extension_seam.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
"""The tool-extension seam: tools.register + mcp_http.create_app(extra_tools, adapters).

Proves the seam is additive and no-op by default: create_app() == the historical build_app(),
extra tools merge over a COPY of TOOLS (execute_sql byte-identical, the global untouched), the
duplicate-name guard holds, and a passed Adapters(...) is used (OSS defaults when None).

Needs the [server] extra (MCP SDK + ASGI); skipped cleanly without it.
"""

from __future__ import annotations

import json

import pytest

pytest.importorskip("mcp")
pytest.importorskip("starlette")

import mcp_http # noqa: E402
import tools # noqa: E402
from oss_adapters import ( # noqa: E402
FileActivitySink,
PresenceAuthProvider,
SingleTenantOrgResolver,
WarnOnlyGovernancePolicy,
)
from ports import Adapters, Org # noqa: E402
from starlette.testclient import TestClient # noqa: E402

BASE = "https://demo.example.com"
PRODUCT_TOOLS = {"list_datasources", "get_datasource_schema", "get_prompt_examples", "execute_sql"}

_DEMO = {
"handler": lambda args: "ok",
"description": "a demo tool registered by a consumer",
"inputSchema": {"type": "object", "additionalProperties": False},
}


def _auth_middleware_kwargs(app):
"""The kwargs the _AuthMiddleware was wired with (robust across Starlette's .kwargs/.options)."""
for m in app.user_middleware:
if m.cls is mcp_http._AuthMiddleware:
return getattr(m, "kwargs", None) or getattr(m, "options", {})
raise AssertionError("the auth middleware should be wired")


@pytest.fixture
def base_url(monkeypatch):
monkeypatch.setenv("PUBLIC_BASE_URL", BASE)
# Deterministic OSS defaults: no ambient signing secret (presence auth, not JWT).
monkeypatch.delenv("AGAMI_SIGNING_SECRET", raising=False)
return BASE


# --- tools.register -------------------------------------------------------


def test_register_adds_a_tool(monkeypatch):
monkeypatch.setattr(tools, "TOOLS", dict(tools.TOOLS)) # isolate the module global
tools.register("demo_probe", lambda a: "ok", "demo", {"type": "object"})
assert "demo_probe" in tools.TOOLS


def test_register_rejects_a_duplicate_name(monkeypatch):
monkeypatch.setattr(tools, "TOOLS", dict(tools.TOOLS))
with pytest.raises(ValueError, match="already registered"):
tools.register("execute_sql", lambda a: "x", "dup", {}) # can't shadow a core tool


# --- create_app: the registry merge is additive + non-mutating ------------


def test_extra_tools_merge_keeps_execute_sql_byte_identical():
before = json.dumps(tools.TOOLS["execute_sql"]["inputSchema"], sort_keys=True)
merged = {**tools.TOOLS, "demo_probe": _DEMO} # the exact op create_app performs
after = json.dumps(merged["execute_sql"]["inputSchema"], sort_keys=True)
assert before == after # execute_sql schema untouched by the extension
assert "demo_probe" in merged # the extra tool is present
assert PRODUCT_TOOLS <= set(merged) # the four core tools remain


def test_create_app_does_not_mutate_the_global_registry(base_url):
before = set(tools.TOOLS)
mcp_http.create_app(extra_tools={"demo_probe": _DEMO})
assert "demo_probe" not in tools.TOOLS # create_app merges a COPY, never the global
assert set(tools.TOOLS) == before # a second create_app() would be clean


def test_create_app_extra_tools_none_matches_no_args(base_url):
# extra_tools defaults to None (not a mutable {}); an explicit None must behave like no args.
c = TestClient(mcp_http.create_app(extra_tools=None))
r = c.post("/mcp", json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
assert r.status_code == 401 # same auth challenge as build_app() / create_app()
assert r.headers.get("www-authenticate", "").startswith("Bearer ")


def test_create_app_rejects_a_malformed_extra_tool(base_url):
# A consumer entry missing a required field (or a non-callable handler) fails fast at
# construction with a clear error — not later as a KeyError/500 inside tools/list or tools/call.
with pytest.raises(ValueError, match="handler, description, inputSchema"):
mcp_http.create_app(extra_tools={"bad": {"description": "no handler/schema"}})
with pytest.raises(ValueError, match="must be callable"):
mcp_http.create_app(
extra_tools={"bad": {"handler": "x", "description": "d", "inputSchema": {}}}
)


# --- create_app: adapter injection ----------------------------------------


def test_adapters_none_uses_the_oss_defaults(base_url):
a = mcp_http.default_adapters()
assert isinstance(a.org_resolver, SingleTenantOrgResolver)
assert isinstance(a.auth_provider, PresenceAuthProvider) # presence when no signing secret
assert isinstance(a.activity_sink, FileActivitySink)
assert isinstance(a.governance, WarnOnlyGovernancePolicy)


def test_create_app_uses_the_passed_adapters(base_url):
resolver = SingleTenantOrgResolver(Org(id="sentinel"))
auth = PresenceAuthProvider(subject="sentinel")
adapters = Adapters(
activity_sink=FileActivitySink(),
org_resolver=resolver,
auth_provider=auth,
governance=WarnOnlyGovernancePolicy(),
)
kwargs = _auth_middleware_kwargs(mcp_http.create_app(adapters=adapters))
assert kwargs["resolver"] is resolver # the passed adapters are used at the composition root
assert kwargs["auth"] is auth


# --- backwards-compat: build_app() is a thin create_app() wrapper ---------


def test_build_app_still_serves_the_same_auth_challenge(base_url):
c = TestClient(mcp_http.build_app())
r = c.post("/mcp", json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
assert r.status_code == 401 # unchanged entrypoint behavior
assert r.headers.get("www-authenticate", "").startswith("Bearer ")
Loading