feat(server): integrate MCP queries and investigation messaging
Add project-bound OAuth MCP queries and controlled analysis with scoped evidence, limits, timeouts and a nearby-model spatial query using EPSG:4326 inputs. Default nearby searches to 200 meters and five results. Integrate investigation messaging/presentation, health/readiness and typed history; remove duplicate web search and fix online simulation and compressed baseline writes. Include project migrations 023-025; keep local credentials and demo seeding excluded. Validation: 319 focused pytest checks; real PostGIS temporary-table regression; read-only Jinqian bridge radius comparison; cross-language MCP discovery/calls.
This commit is contained in:
+169
@@ -0,0 +1,169 @@
|
||||
"""Verify resource-bound Keycloak tokens without passing them to business APIs."""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
from jose import JWTError, jwt
|
||||
from mcp.server.auth.provider import AccessToken
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from app.mcp.config import McpSettings
|
||||
|
||||
CATALOG_SCOPE = "mcp.catalog.read"
|
||||
QUERY_SCOPE = "mcp.query"
|
||||
CONTEXT_SCOPE = "mcp.context.bind"
|
||||
|
||||
|
||||
class McpTokenVerifier:
|
||||
def __init__(self, settings: McpSettings, client: httpx.AsyncClient | None = None):
|
||||
self.settings = settings
|
||||
self.client = client
|
||||
self._keys: list[dict] = []
|
||||
self._expires = 0.0
|
||||
self._last_refresh = 0.0
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def _load_keys(self, kid: str) -> list[dict]:
|
||||
now = time.monotonic()
|
||||
if self._expires > now and any(k.get("kid") == kid for k in self._keys):
|
||||
return self._keys
|
||||
async with self._lock:
|
||||
now = time.monotonic()
|
||||
if self._expires > now and any(k.get("kid") == kid for k in self._keys):
|
||||
return self._keys
|
||||
if self._expires > now and now - self._last_refresh < 20:
|
||||
return self._keys
|
||||
url = (
|
||||
self.settings.issuer_uri.rstrip("/") + "/protocol/openid-connect/certs"
|
||||
)
|
||||
self._last_refresh = now
|
||||
if self.client:
|
||||
response = await self.client.get(url)
|
||||
else:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=5, trust_env=False, follow_redirects=False
|
||||
) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError:
|
||||
raise httpx.RequestError("invalid OAuth key set response") from None
|
||||
keys = payload.get("keys") if isinstance(payload, dict) else None
|
||||
if not isinstance(keys, list) or len(keys) > 100:
|
||||
raise httpx.RequestError("invalid OAuth key set")
|
||||
self._keys = [
|
||||
k
|
||||
for k in keys
|
||||
if isinstance(k, dict)
|
||||
and k.get("kty") == "RSA"
|
||||
and k.get("use", "sig") == "sig"
|
||||
and k.get("alg", "RS256") == "RS256"
|
||||
]
|
||||
self._expires = now + 300
|
||||
return self._keys
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
if not token or len(token) > 16384:
|
||||
return None
|
||||
try:
|
||||
header = jwt.get_unverified_header(token)
|
||||
kid = header.get("kid")
|
||||
if (
|
||||
header.get("alg") != "RS256"
|
||||
or not isinstance(kid, str)
|
||||
or not 0 < len(kid) <= 128
|
||||
):
|
||||
return None
|
||||
keys = await self._load_keys(kid)
|
||||
key = next((k for k in keys if k.get("kid") == kid), None)
|
||||
if key is None:
|
||||
return None
|
||||
claims = jwt.decode(
|
||||
token,
|
||||
key,
|
||||
algorithms=["RS256"],
|
||||
audience=self.settings.audience,
|
||||
issuer=self.settings.issuer_uri,
|
||||
options={"require_exp": True, "require_iat": True, "require_sub": True},
|
||||
)
|
||||
UUID(claims["sub"])
|
||||
client_id = claims.get("azp")
|
||||
if not isinstance(client_id, str) or not client_id:
|
||||
return None
|
||||
if (
|
||||
not isinstance(claims.get("iat"), (int, float))
|
||||
or not time.time() - 900 < claims["iat"] <= time.time() + 30
|
||||
or claims["exp"] > claims["iat"] + 900
|
||||
):
|
||||
return None
|
||||
return AccessToken(
|
||||
token=token,
|
||||
client_id=client_id,
|
||||
subject=claims["sub"],
|
||||
scopes=str(claims.get("scope", "")).split(),
|
||||
expires_at=int(claims["exp"]),
|
||||
resource=self.settings.resource_uri,
|
||||
claims=claims,
|
||||
)
|
||||
except (JWTError, ValueError, KeyError, TypeError):
|
||||
return None
|
||||
# JWKS/network failures propagate as unavailable, never as empty data.
|
||||
|
||||
|
||||
def challenge(
|
||||
settings: McpSettings, scope: str, *, insufficient: bool = False
|
||||
) -> dict[str, str]:
|
||||
from mcp.server.auth.routes import build_resource_metadata_url
|
||||
|
||||
metadata = build_resource_metadata_url(settings.resource_uri)
|
||||
suffix = ', error="insufficient_scope"' if insufficient else ""
|
||||
return {
|
||||
"WWW-Authenticate": f'Bearer resource_metadata="{metadata}", scope="{scope}"{suffix}'
|
||||
}
|
||||
|
||||
|
||||
class QueryScopeMiddleware:
|
||||
"""HTTP scope challenges before JSON-RPC dispatch; SDK validates mirrored headers."""
|
||||
|
||||
def __init__(self, app, verifier: McpTokenVerifier, settings: McpSettings):
|
||||
self.app, self.verifier, self.settings = app, verifier, settings
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] != "http" or scope.get("path") != "/mcp":
|
||||
return await self.app(scope, receive, send)
|
||||
headers = {
|
||||
k.decode("latin1").lower(): v.decode("latin1")
|
||||
for k, v in scope.get("headers", [])
|
||||
}
|
||||
auth = headers.get("authorization", "")
|
||||
try:
|
||||
token = (
|
||||
await self.verifier.verify_token(auth[7:])
|
||||
if auth.lower().startswith("bearer ")
|
||||
else None
|
||||
)
|
||||
except (httpx.HTTPError, ValueError):
|
||||
return await JSONResponse(
|
||||
{"error": "authentication_unavailable"}, status_code=503
|
||||
)(scope, receive, send)
|
||||
required = (
|
||||
QUERY_SCOPE
|
||||
if headers.get("mcp-method") in {"tools/call", "resources/read"}
|
||||
else CATALOG_SCOPE
|
||||
)
|
||||
if token is None:
|
||||
return await JSONResponse(
|
||||
{"error": "invalid_token"},
|
||||
status_code=401,
|
||||
headers=challenge(self.settings, required),
|
||||
)(scope, receive, send)
|
||||
if required not in token.scopes:
|
||||
return await JSONResponse(
|
||||
{"error": "insufficient_scope"},
|
||||
status_code=403,
|
||||
headers=challenge(self.settings, required, insufficient=True),
|
||||
)(scope, receive, send)
|
||||
await self.app(scope, receive, send)
|
||||
Reference in New Issue
Block a user