monorepoly 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
monorepoly/hosted.py ADDED
@@ -0,0 +1,155 @@
1
+ """Hosted MCP server over HTTP: every request carries its user's GitLab OAuth token."""
2
+
3
+ import asyncio
4
+ import collections
5
+ import hashlib
6
+ import logging
7
+ import threading
8
+ import time
9
+ from collections.abc import Callable
10
+
11
+ import requests
12
+ from mcp.server.auth.middleware.auth_context import get_access_token
13
+ from mcp.server.auth.provider import AccessToken
14
+ from mcp.server.auth.settings import AuthSettings
15
+ from requests.exceptions import RequestException
16
+ from starlette.applications import Starlette
17
+
18
+ from monorepoly.access import Access, load_access
19
+ from monorepoly.config import ServerSettings
20
+ from monorepoly.embed import Embedder, make_embedder
21
+ from monorepoly.exceptions import MonorepolyAccessError
22
+ from monorepoly.gitlab import make_oauth_gitlab
23
+ from monorepoly.judge import Judge, make_judge
24
+ from monorepoly.mcp_server import build_server
25
+ from monorepoly.search import QueryContext
26
+ from monorepoly.store import Store, connect
27
+
28
+ _LOGGER = logging.getLogger(__name__)
29
+
30
+ _ACCESS_TTL_SECONDS = 300
31
+ # Behind Cloud Run the Host header is the service's own name, so listen on every interface;
32
+ # this also leaves the SDK's localhost-only DNS rebinding protection off.
33
+ _HOST = '0.0.0.0'
34
+ _MAX_CACHED_USERS = 1000
35
+ _SCOPE = 'read_api' # Lists the group's projects for `load_access`.
36
+ _TIMEOUT_SECONDS = 10
37
+
38
+
39
+ class GitLabTokenVerifier:
40
+ """Verifies bearer tokens with the GitLab instance that issued them."""
41
+
42
+ def __init__(self, *, gitlab_url: str) -> None:
43
+ self._gitlab_url = gitlab_url.rstrip('/')
44
+
45
+ async def verify_token(self, token: str) -> AccessToken | None:
46
+ """Returns the token's grant, or None unless GitLab confirms it."""
47
+ return await asyncio.to_thread(self._verify, token)
48
+
49
+ def _verify(self, token: str) -> AccessToken | None:
50
+ # Fail closed: GitLab tokens are opaque, so only GitLab can vouch for one. Asked on every
51
+ # request, so a revoked token stops working at once.
52
+ try:
53
+ response = requests.get(
54
+ f'{self._gitlab_url}/oauth/token/info',
55
+ headers={'Authorization': f'Bearer {token}'},
56
+ timeout=_TIMEOUT_SECONDS,
57
+ )
58
+ if response.status_code == 401:
59
+ return None
60
+ response.raise_for_status()
61
+ info = response.json()
62
+ except (RequestException, ValueError) as err:
63
+ _LOGGER.warning('GitLab did not verify a token: %s', err)
64
+ return None
65
+ expires_in = info.get('expires_in')
66
+ return AccessToken(
67
+ client_id=(info.get('application') or {}).get('uid') or '',
68
+ expires_at=int(time.time()) + expires_in if expires_in else None,
69
+ scopes=info.get('scope') or [],
70
+ subject=str(info.get('resource_owner_id')),
71
+ token=token,
72
+ )
73
+
74
+
75
+ class AccessCache:
76
+ """Remembers each user's access briefly: loading it lists the group's projects."""
77
+
78
+ def __init__(self, *, gitlab_url: str, group_id: str) -> None:
79
+ self._entries: collections.OrderedDict[str, tuple[float, Access]] = (
80
+ collections.OrderedDict()
81
+ )
82
+ self._gitlab_url = gitlab_url
83
+ self._group_id = group_id
84
+ self._lock = threading.Lock()
85
+
86
+ def get(self, token: str) -> Access:
87
+ """Returns the token user's access, loading it from GitLab when not cached."""
88
+ # Keyed by hash, so a memory dump does not hand out working tokens.
89
+ key = hashlib.sha256(token.encode()).hexdigest()
90
+ with self._lock:
91
+ entry = self._entries.get(key)
92
+ if entry and entry[0] > time.monotonic():
93
+ return entry[1]
94
+ access = load_access(
95
+ gitlab=make_oauth_gitlab(token=token, url=self._gitlab_url), group_id=self._group_id
96
+ )
97
+ with self._lock:
98
+ self._entries[key] = (time.monotonic() + _ACCESS_TTL_SECONDS, access)
99
+ self._entries.move_to_end(key)
100
+ while len(self._entries) > _MAX_CACHED_USERS:
101
+ self._entries.popitem(last=False)
102
+ return access
103
+
104
+
105
+ def token_context(
106
+ *,
107
+ cache: AccessCache,
108
+ embedder: Embedder,
109
+ judge: Judge,
110
+ store: Store,
111
+ ) -> Callable[[], QueryContext]:
112
+ """Returns a function giving the current request's user their query context."""
113
+
114
+ def context() -> QueryContext:
115
+ token = get_access_token()
116
+ # Fail closed: the auth middleware rejects such requests before any tool runs.
117
+ if token is None:
118
+ raise MonorepolyAccessError('The request has no GitLab token!')
119
+ return QueryContext(
120
+ access=cache.get(token.token), embedder=embedder, judge=judge, store=store
121
+ )
122
+
123
+ return context
124
+
125
+
126
+ def build_app(*, context: Callable[[], QueryContext], settings: ServerSettings) -> Starlette:
127
+ """Returns the ASGI app serving the tools at `/mcp` to GitLab-authenticated users."""
128
+ auth = AuthSettings(
129
+ issuer_url=settings.gitlab_url.rstrip('/'),
130
+ required_scopes=[_SCOPE],
131
+ resource_server_url=f'{settings.public_url.rstrip("/")}/mcp',
132
+ # GitLab tokens name no audience. Any `read_api` token of the user is accepted: it already
133
+ # reads everything the index would show them.
134
+ validate_token_resource=False,
135
+ )
136
+ server = build_server(
137
+ auth=auth,
138
+ context=context,
139
+ token_verifier=GitLabTokenVerifier(gitlab_url=settings.gitlab_url),
140
+ )
141
+ # Stateless, so any Cloud Run instance can answer any request.
142
+ return server.streamable_http_app(json_response=True, host=_HOST, stateless_http=True)
143
+
144
+
145
+ def serve_http(*, port: int, settings: ServerSettings) -> None:
146
+ """Runs the hosted server until stopped."""
147
+ import uvicorn
148
+
149
+ context = token_context(
150
+ cache=AccessCache(gitlab_url=settings.gitlab_url, group_id=settings.gitlab_group_id),
151
+ embedder=make_embedder(project=settings.google_cloud_project),
152
+ judge=make_judge(),
153
+ store=Store(db=connect(db=settings.mongodb_db, uri=settings.mongodb_uri)),
154
+ )
155
+ uvicorn.run(build_app(context=context, settings=settings), host=_HOST, port=port)