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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,7 @@ cres/*
project management scripts/

.harvester_cache/

### Local MCP client config (machine-specific paths)
.mcp.json

87 changes: 87 additions & 0 deletions application/database/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,27 @@ class UserResourceSelection(BaseModel): # type: ignore
)


class McpGrants(BaseModel): # type: ignore
"""One OpenCRE scope granted to a user's MCP client.

Google's device flow only carries ``openid email profile``, so OpenCRE
permissions cannot live in the token and are recorded here instead.
"""

__tablename__ = "mcp_grants"
id = sqla.Column(sqla.String, primary_key=True, default=generate_uuid)
user_id = sqla.Column(
sqla.String,
sqla.ForeignKey("users.id", onupdate="CASCADE", ondelete="CASCADE"),
nullable=False,
)
scope = sqla.Column(sqla.String, nullable=False)
created_at = sqla.Column(sqla.DateTime, nullable=False)
revoked_at = sqla.Column(sqla.DateTime, nullable=True)

__table_args__ = (sqla.UniqueConstraint(user_id, scope, name="uq_mcp_grants"),)


class ArtifactIngestEvent(BaseModel): # type: ignore
"""Tracks one harvested artifact persisted per import run."""

Expand Down Expand Up @@ -1393,6 +1414,72 @@ def _apply_profile(u: User) -> None:
def get_user_by_sub(self, google_sub: str) -> Optional[User]:
return self.session.query(User).filter(User.google_sub == google_sub).first()

def get_mcp_grants(self, user_id: str) -> List[str]:
"""Return the scopes granted to this user's MCP client, newest last."""
rows = (
self.session.query(McpGrants)
.filter(McpGrants.user_id == user_id)
.filter(McpGrants.revoked_at.is_(None))
.order_by(McpGrants.scope)
.all()
)
return [row.scope for row in rows]

def add_mcp_grant(self, user_id: str, scope: str) -> List[str]:
"""Grant ``scope`` to this user's MCP client, reviving a revoked row."""
from datetime import datetime, timezone

now = datetime.now(timezone.utc)
existing = (
self.session.query(McpGrants)
.filter(McpGrants.user_id == user_id)
.filter(McpGrants.scope == scope)
.first()
)
if existing is None:
self.session.add(McpGrants(user_id=user_id, scope=scope, created_at=now))
else:
existing.revoked_at = None
self.session.commit()
return self.get_mcp_grants(user_id)

def bootstrap_mcp_grants(self, user_id: str, scopes: List[str]) -> List[str]:
"""Grant ``scopes`` the first time an MCP client is seen for a user.

Applies only when the user has no rows at all. Someone who revoked a
scope must never have it silently restored, so a revoked row counts as
history and suppresses the bootstrap.
"""
from datetime import datetime, timezone

seen = (
self.session.query(McpGrants).filter(McpGrants.user_id == user_id).count()
)
if seen:
return self.get_mcp_grants(user_id)

now = datetime.now(timezone.utc)
for scope in scopes:
self.session.add(McpGrants(user_id=user_id, scope=scope, created_at=now))
try:
self.session.commit()
except IntegrityError:
# A concurrent first call inserted the same rows; theirs stand.
self.session.rollback()
return self.get_mcp_grants(user_id)

def revoke_mcp_grant(self, user_id: str, scope: str) -> List[str]:
"""Revoke ``scope`` without deleting the row, so the change is auditable."""
from datetime import datetime, timezone

self.session.query(McpGrants).filter(McpGrants.user_id == user_id).filter(
McpGrants.scope == scope
).filter(McpGrants.revoked_at.is_(None)).update(
{"revoked_at": datetime.now(timezone.utc)}
)
self.session.commit()
return self.get_mcp_grants(user_id)

def get_user_resource_selection(self, user_id: str) -> List[str]:
"""Return the standard names a user has selected, ordered by name."""
rows = (
Expand Down
28 changes: 27 additions & 1 deletion application/mcp/__main__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Entry point: python -m application.mcp"""
"""Entry point: python -m application.mcp [login|logout|status]"""

from __future__ import annotations

Expand All @@ -7,9 +7,35 @@
logger = get_logger(__name__)

import asyncio
import os
import pathlib
import sys


def _load_repo_env() -> None:
"""Fall back to the repo's .env for anything the MCP client did not set.

MCP clients cannot set a working directory, so the path is resolved from
this file rather than the cwd. Values already present in the environment
win, keeping the client config authoritative.
"""
try:
from dotenv import load_dotenv
except ImportError:
return
env_file = pathlib.Path(__file__).resolve().parents[2] / ".env"
if env_file.is_file():
load_dotenv(env_file, override=False)


def main() -> None:
_load_repo_env()
command = sys.argv[1:]
if command:
from application.mcp.cli import parse_and_run_command

raise SystemExit(parse_and_run_command(command))

logger.info("starting OpenCRE MCP stdio server")
from application.mcp.server import run_stdio

Expand Down
208 changes: 208 additions & 0 deletions application/mcp/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
"""Google device-flow credentials for the MCP client.

The only module that knows Google exists. Callers ask for an id_token and get
one, refreshed silently when needed. Never writes to stdout: that stream
carries JSON-RPC when the stdio server is running.
"""

from __future__ import annotations

from cre_logging import get_logger

logger = get_logger(__name__)

import base64
import json
import os
import time
from typing import Any, Callable, Dict, Optional, Tuple

import requests

from application.mcp import token_store

DEVICE_CODE_URL = "https://oauth2.googleapis.com/device/code"
TOKEN_URL = "https://oauth2.googleapis.com/token"
DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"

# Device flow supports only this set, and staying inside it keeps refresh
# tokens exempt from the 7-day expiry Google applies to Testing-mode apps.
SCOPES = "openid email profile"

REQUEST_TIMEOUT_SECONDS = 30.0
EXPIRY_SKEW_SECONDS = 60

_cached_id_token: Optional[str] = None


class AuthError(Exception):
"""Base error for MCP credential failures."""


class NotAuthenticated(AuthError):
"""No usable credential. The caller should run the login command."""


def _client_credentials() -> Tuple[str, str]:
"""Read client config lazily so importing this module never fails."""
client_id = os.environ.get("GOOGLE_MCP_CLIENT_ID")
client_secret = os.environ.get("GOOGLE_MCP_CLIENT_SECRET")
if not client_id or not client_secret:
raise AuthError("GOOGLE_MCP_CLIENT_ID / GOOGLE_MCP_CLIENT_SECRET are not set.")
return client_id, client_secret


def _decode_claims(id_token: str) -> Dict[str, Any]:
"""Read claims without verifying. Display only; REST verifies signatures."""
try:
payload = id_token.split(".")[1]
raw = base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4))
return json.loads(raw)
except (IndexError, ValueError):
return {}


def _is_fresh(id_token: str) -> bool:
exp = _decode_claims(id_token).get("exp")
if not isinstance(exp, (int, float)):
return False
return time.time() < exp - EXPIRY_SKEW_SECONDS


def _refresh(refresh_token: str) -> Optional[Dict[str, Any]]:
"""Trade a refresh token for fresh tokens. None if it is no longer valid."""
client_id, client_secret = _client_credentials()
response = requests.post(
TOKEN_URL,
data={
"client_id": client_id,
"client_secret": client_secret,
"refresh_token": refresh_token,
"grant_type": "refresh_token",
},
timeout=REQUEST_TIMEOUT_SECONDS,
)
if response.status_code == 200:
return response.json()
logger.info("refresh token rejected with status %s", response.status_code)
return None


def login(on_code: Callable[[str, str], None]) -> Dict[str, Any]:
"""Run the device flow. `on_code(verification_url, user_code)` displays the
prompt before polling begins. Returns the id_token claims on success."""
client_id, client_secret = _client_credentials()

response = requests.post(
DEVICE_CODE_URL,
data={"client_id": client_id, "scope": SCOPES},
timeout=REQUEST_TIMEOUT_SECONDS,
)
if response.status_code != 200:
raise AuthError(f"device/code failed [{response.status_code}]: {response.text}")
device = response.json()

# Google spells it verification_url; RFC 8628 says verification_uri.
url = device.get("verification_url") or device.get("verification_uri")
on_code(url, device["user_code"])

interval = device.get("interval", 5)
deadline = time.time() + device["expires_in"]
while time.time() < deadline:
time.sleep(interval)
polled = requests.post(
TOKEN_URL,
data={
"client_id": client_id,
"client_secret": client_secret,
"device_code": device["device_code"],
"grant_type": DEVICE_GRANT,
},
timeout=REQUEST_TIMEOUT_SECONDS,
)
try:
body = polled.json()
except ValueError:
raise AuthError(
f"authorization failed: non-JSON token response "
f"[{polled.status_code}]"
)
if polled.status_code == 200:
return _store(body)
error = body.get("error") if isinstance(body, dict) else None
if error == "authorization_pending":
continue
if error == "slow_down":
interval += 5
continue
raise AuthError(f"authorization failed: {error or body}")
raise AuthError("timed out: the code expired before it was approved")


def _store(tokens: Dict[str, Any]) -> Dict[str, Any]:
"""Persist the refresh token and cache the id_token. Returns its claims."""
global _cached_id_token

# A refresh response omits refresh_token; keep the one already held.
# Login must not succeed with only an in-memory id_token — without a
# durable refresh token the next process cannot continue the session.
refresh_token = tokens.get("refresh_token") or token_store.load()
if not refresh_token:
raise AuthError(
"token endpoint returned no refresh_token and none is stored; "
"cannot persist MCP login"
)
token_store.save(refresh_token)

_cached_id_token = tokens.get("id_token")
return _decode_claims(_cached_id_token) if _cached_id_token else {}
Comment thread
Pa04rth marked this conversation as resolved.


def current_id_token() -> str:
"""A valid id_token, refreshing if needed."""
global _cached_id_token

if _cached_id_token and _is_fresh(_cached_id_token):
return _cached_id_token

refresh_token = token_store.load()
if not refresh_token:
raise NotAuthenticated("Not logged in. Run: python -m application.mcp login")

tokens = _refresh(refresh_token)
if tokens is None:
_cached_id_token = None
raise NotAuthenticated(
"Stored credential is no longer valid. "
"Run: python -m application.mcp login"
)
_store(tokens)
if not _cached_id_token:
raise AuthError("token endpoint returned no id_token")
return _cached_id_token


def logout() -> bool:
global _cached_id_token

_cached_id_token = None
return token_store.clear()


def status() -> Dict[str, Any]:
"""Describe the stored credential without raising when absent."""
client_id = os.environ.get("GOOGLE_MCP_CLIENT_ID")
if not token_store.load():
return {"logged_in": False, "client_id": client_id}
try:
claims = _decode_claims(current_id_token())
except AuthError as exc:
return {"logged_in": False, "client_id": client_id, "error": str(exc)}
return {
"logged_in": True,
"client_id": client_id,
"email": claims.get("email"),
"sub": claims.get("sub"),
"audience": claims.get("aud"),
"expires_in": int(claims.get("exp", 0) - time.time()),
}
Loading
Loading