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/__init__.py +1 -0
- monorepoly/__main__.py +161 -0
- monorepoly/access.py +100 -0
- monorepoly/chunk.py +136 -0
- monorepoly/chunk_config.py +466 -0
- monorepoly/config.py +54 -0
- monorepoly/embed.py +66 -0
- monorepoly/exceptions.py +29 -0
- monorepoly/git.py +394 -0
- monorepoly/gitlab.py +234 -0
- monorepoly/hosted.py +155 -0
- monorepoly/indexer.py +557 -0
- monorepoly/judge.py +157 -0
- monorepoly/licensing.py +201 -0
- monorepoly/mcp_server.py +165 -0
- monorepoly/models.py +329 -0
- monorepoly/search.py +284 -0
- monorepoly/skill.py +23 -0
- monorepoly/skills/search/SKILL.md +28 -0
- monorepoly/store.py +290 -0
- monorepoly/trusted_keys/kimvais.pub +1 -0
- monorepoly/wizard.py +251 -0
- monorepoly-0.2.0.dist-info/METADATA +230 -0
- monorepoly-0.2.0.dist-info/RECORD +27 -0
- monorepoly-0.2.0.dist-info/WHEEL +4 -0
- monorepoly-0.2.0.dist-info/entry_points.txt +3 -0
- monorepoly-0.2.0.dist-info/licenses/LICENSE +22 -0
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)
|