deeptutor-server 1.7.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.
- deeptutor/api/__init__.py +0 -0
- deeptutor/api/contracts/__init__.py +21 -0
- deeptutor/api/contracts/export.py +110 -0
- deeptutor/api/contracts/turn_protocol.py +313 -0
- deeptutor/api/main.py +547 -0
- deeptutor/api/module_routes.py +42 -0
- deeptutor/api/router_index.json +5752 -0
- deeptutor/api/router_loader.py +139 -0
- deeptutor/api/routers/__init__.py +1 -0
- deeptutor/api/routers/agent_config.py +59 -0
- deeptutor/api/routers/attachments.py +79 -0
- deeptutor/api/routers/auth.py +1937 -0
- deeptutor/api/routers/capabilities.py +44 -0
- deeptutor/api/routers/capabilities_settings.py +38 -0
- deeptutor/api/routers/dashboard.py +292 -0
- deeptutor/api/routers/file_library.py +169 -0
- deeptutor/api/routers/file_preview.py +205 -0
- deeptutor/api/routers/imports.py +146 -0
- deeptutor/api/routers/learning_journal.py +18 -0
- deeptutor/api/routers/mcp_settings.py +133 -0
- deeptutor/api/routers/modules.py +12 -0
- deeptutor/api/routers/multi_user.py +943 -0
- deeptutor/api/routers/outputs.py +97 -0
- deeptutor/api/routers/personas.py +154 -0
- deeptutor/api/routers/plugins.py +416 -0
- deeptutor/api/routers/quiz_judge.py +464 -0
- deeptutor/api/routers/sessions.py +801 -0
- deeptutor/api/routers/settings.py +2503 -0
- deeptutor/api/routers/skills.py +315 -0
- deeptutor/api/routers/space_cli_apps.py +222 -0
- deeptutor/api/routers/space_mcp.py +460 -0
- deeptutor/api/routers/subagents.py +306 -0
- deeptutor/api/routers/system.py +633 -0
- deeptutor/api/routers/task_board.py +139 -0
- deeptutor/api/routers/tools.py +268 -0
- deeptutor/api/routers/unified_ws.py +385 -0
- deeptutor/api/routers/workspace.py +371 -0
- deeptutor/api/run_server.py +99 -0
- deeptutor/api/security_guard.py +91 -0
- deeptutor/api/utils/http_headers.py +27 -0
- deeptutor/api/utils/progress_broadcaster.py +73 -0
- deeptutor/api/utils/task_id_manager.py +120 -0
- deeptutor/api/utils/task_log_stream.py +362 -0
- deeptutor/api/utils/tool_options.py +133 -0
- deeptutor_server-1.7.0.dist-info/METADATA +14 -0
- deeptutor_server-1.7.0.dist-info/RECORD +48 -0
- deeptutor_server-1.7.0.dist-info/WHEEL +5 -0
- deeptutor_server-1.7.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Per-user MCP API
|
|
3
|
+
================
|
|
4
|
+
|
|
5
|
+
The servers an individual configures for themselves, mounted at
|
|
6
|
+
``/api/space/mcp``. Auth-gated, **not** admin-gated: this is the whole point
|
|
7
|
+
of the surface — a learner adds the hosted services they use without an
|
|
8
|
+
administrator in the loop.
|
|
9
|
+
|
|
10
|
+
What keeps that safe is narrow rather than trusting:
|
|
11
|
+
|
|
12
|
+
* **remote transports only** — a ``stdio`` server is a command run on the host
|
|
13
|
+
as the app user, so it stays in the admin registry
|
|
14
|
+
(``/api/settings/mcp``) permanently;
|
|
15
|
+
* **credentials are stored apart from the config** and never returned; a field
|
|
16
|
+
reports only whether it is configured;
|
|
17
|
+
* **every write is scoped to the caller's own file** — the owner is resolved
|
|
18
|
+
server-side and is never accepted from the request.
|
|
19
|
+
|
|
20
|
+
Deployment servers stay visible here read-only, so "which tools do I have?" has
|
|
21
|
+
one answer instead of two.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import asyncio
|
|
27
|
+
import html
|
|
28
|
+
import logging
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from fastapi import APIRouter, HTTPException, Request, Response
|
|
32
|
+
from pydantic import BaseModel, Field, ValidationError
|
|
33
|
+
|
|
34
|
+
from deeptutor.multi_user.paths import current_owner_id
|
|
35
|
+
from deeptutor.services.i18n import t
|
|
36
|
+
from deeptutor.services.mcp import MCPServerConfig, get_mcp_manager, load_mcp_config, oauth
|
|
37
|
+
from deeptutor.services.mcp.catalog import (
|
|
38
|
+
build_server_config,
|
|
39
|
+
category_counts,
|
|
40
|
+
get_entry,
|
|
41
|
+
search_catalog,
|
|
42
|
+
)
|
|
43
|
+
from deeptutor.services.mcp.manager import (
|
|
44
|
+
SHARED_OWNER,
|
|
45
|
+
describe_connect_failure,
|
|
46
|
+
probe_server,
|
|
47
|
+
)
|
|
48
|
+
from deeptutor.services.mcp.secrets import configured_fields, delete_secrets, store_secrets
|
|
49
|
+
from deeptutor.services.mcp.user_config import (
|
|
50
|
+
MAX_SERVERS_PER_OWNER,
|
|
51
|
+
UserMcpError,
|
|
52
|
+
assert_name_available,
|
|
53
|
+
delete_user_server,
|
|
54
|
+
load_user_mcp_config,
|
|
55
|
+
save_user_server,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
logger = logging.getLogger(__name__)
|
|
59
|
+
|
|
60
|
+
router = APIRouter()
|
|
61
|
+
|
|
62
|
+
#: Ceiling on warming connections while rendering the servers list. Long enough
|
|
63
|
+
#: for a healthy hosted server, short enough that a dead one does not hold the
|
|
64
|
+
#: page open.
|
|
65
|
+
_STATUS_WARM_TIMEOUT_S = 5.0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ServerPayload(BaseModel):
|
|
69
|
+
"""A server definition plus the credential values to store beside it."""
|
|
70
|
+
|
|
71
|
+
config: MCPServerConfig
|
|
72
|
+
#: Field name → value. Values are written to the owner's secrets store and
|
|
73
|
+
#: never echoed back; an empty string clears a stored field.
|
|
74
|
+
secrets: dict[str, str] = Field(default_factory=dict)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class InstallPayload(BaseModel):
|
|
78
|
+
"""Install a catalog entry, optionally under a different local name."""
|
|
79
|
+
|
|
80
|
+
name: str = ""
|
|
81
|
+
secrets: dict[str, str] = Field(default_factory=dict)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _shared_names() -> set[str]:
|
|
85
|
+
return set(load_mcp_config().servers)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _refuse(exc: UserMcpError) -> HTTPException:
|
|
89
|
+
return HTTPException(status_code=400, detail={"code": exc.code, "message": str(exc)})
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@router.get("/servers")
|
|
93
|
+
async def list_servers() -> dict[str, Any]:
|
|
94
|
+
"""This surface's servers, in the same shape the admin registry returns.
|
|
95
|
+
|
|
96
|
+
``servers`` + ``status`` deliberately mirror ``/settings/mcp`` so the two
|
|
97
|
+
surfaces share one set of frontend components instead of forking over a
|
|
98
|
+
response shape. Everything specific to this surface is additive.
|
|
99
|
+
"""
|
|
100
|
+
owner = current_owner_id()
|
|
101
|
+
config, rejected = load_user_mcp_config(owner)
|
|
102
|
+
manager = get_mcp_manager()
|
|
103
|
+
# Warm both scopes before reporting status. Nothing else on this route would
|
|
104
|
+
# connect them — ``ensure_scope`` otherwise runs only at turn time — so the
|
|
105
|
+
# page would sit on "connecting / 0 tools" until the user happened to send a
|
|
106
|
+
# message. Bounded and best-effort: a slow third-party host costs its own
|
|
107
|
+
# row's status, not the page.
|
|
108
|
+
for warm in (manager.ensure_started(), manager.ensure_scope(owner)):
|
|
109
|
+
try:
|
|
110
|
+
await asyncio.wait_for(warm, timeout=_STATUS_WARM_TIMEOUT_S)
|
|
111
|
+
except Exception:
|
|
112
|
+
logger.debug("MCP status warm-up did not finish for owner %s", owner, exc_info=True)
|
|
113
|
+
return {
|
|
114
|
+
"servers": {name: cfg.model_dump(mode="json") for name, cfg in config.servers.items()},
|
|
115
|
+
"status": manager.status(owner),
|
|
116
|
+
# Which credentials exist per server, never what they are.
|
|
117
|
+
"configured_secrets": {
|
|
118
|
+
name: sorted(configured_fields(owner, name)) for name in config.servers
|
|
119
|
+
},
|
|
120
|
+
# Which OAuth-backed servers this account has authorized. Presence only —
|
|
121
|
+
# a token never leaves the backend.
|
|
122
|
+
"oauth": {
|
|
123
|
+
name: {"required": True, "authorized": oauth.oauth_state(owner, name).authorized}
|
|
124
|
+
for name, cfg in config.servers.items()
|
|
125
|
+
if cfg.auth == "oauth"
|
|
126
|
+
},
|
|
127
|
+
"rejected": [{"name": row.name, "reason": row.reason} for row in rejected],
|
|
128
|
+
"deployment": {
|
|
129
|
+
# Read-only context: an account cannot edit these but should be able
|
|
130
|
+
# to see which tools it already has through them.
|
|
131
|
+
"servers": sorted(_shared_names()),
|
|
132
|
+
"status": manager.status(SHARED_OWNER),
|
|
133
|
+
},
|
|
134
|
+
"limits": {"max_servers": MAX_SERVERS_PER_OWNER},
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@router.put("/servers/{name}")
|
|
139
|
+
async def upsert_server(name: str, payload: ServerPayload) -> dict[str, Any]:
|
|
140
|
+
owner = current_owner_id()
|
|
141
|
+
try:
|
|
142
|
+
assert_name_available(name, shared_names=_shared_names())
|
|
143
|
+
cfg, secrets = _extract_credentials(owner, name, payload)
|
|
144
|
+
# Off the loop: the write validates the URL, which resolves DNS, and a
|
|
145
|
+
# dead resolver would otherwise stall every request in the process.
|
|
146
|
+
await asyncio.to_thread(save_user_server, owner, name, cfg)
|
|
147
|
+
except UserMcpError as exc:
|
|
148
|
+
raise _refuse(exc) from exc
|
|
149
|
+
except ValidationError as exc:
|
|
150
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
151
|
+
if secrets:
|
|
152
|
+
store_secrets(owner, name, secrets)
|
|
153
|
+
await get_mcp_manager().reload_scope(owner)
|
|
154
|
+
return await list_servers()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@router.delete("/servers/{name}")
|
|
158
|
+
async def remove_server(name: str) -> dict[str, Any]:
|
|
159
|
+
owner = current_owner_id()
|
|
160
|
+
delete_user_server(owner, name)
|
|
161
|
+
delete_secrets(owner, name)
|
|
162
|
+
# An OAuth grant outlives the config row unless it is dropped with it; a
|
|
163
|
+
# refresh token for a server nobody can see is a credential with no owner.
|
|
164
|
+
oauth.forget(owner, name)
|
|
165
|
+
await get_mcp_manager().reload_scope(owner)
|
|
166
|
+
return await list_servers()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@router.post("/servers/{name}/authorize")
|
|
170
|
+
async def authorize_server(name: str, request: Request) -> dict[str, Any]:
|
|
171
|
+
"""Begin an OAuth consent for one of the caller's servers.
|
|
172
|
+
|
|
173
|
+
Returns the URL to send the person to. This is the **only** place a flow may
|
|
174
|
+
start: a background reconnect has nobody in front of it, so it reports
|
|
175
|
+
``needs_auth`` and waits for someone to click.
|
|
176
|
+
"""
|
|
177
|
+
owner = current_owner_id()
|
|
178
|
+
config, _ = load_user_mcp_config(owner)
|
|
179
|
+
cfg = config.servers.get(name)
|
|
180
|
+
if cfg is None:
|
|
181
|
+
raise HTTPException(status_code=404, detail=t("mcp.server_missing", name=name))
|
|
182
|
+
if cfg.auth != "oauth":
|
|
183
|
+
raise HTTPException(
|
|
184
|
+
status_code=400,
|
|
185
|
+
detail={"code": "mcp.not_oauth", "message": t("mcp.not_oauth")},
|
|
186
|
+
)
|
|
187
|
+
try:
|
|
188
|
+
url = await oauth.begin_authorization(
|
|
189
|
+
server_url=cfg.url,
|
|
190
|
+
server_name=name,
|
|
191
|
+
owner_id=owner,
|
|
192
|
+
# The origin the person is actually browsing, so the redirect comes
|
|
193
|
+
# back somewhere their browser can reach without any configuration.
|
|
194
|
+
redirect_uri=oauth.oauth_redirect_uri(_request_origin(request)),
|
|
195
|
+
)
|
|
196
|
+
except Exception as exc:
|
|
197
|
+
logger.warning("could not start MCP OAuth for %s/%s", owner, name, exc_info=True)
|
|
198
|
+
raise HTTPException(
|
|
199
|
+
status_code=400,
|
|
200
|
+
detail={
|
|
201
|
+
"code": "mcp.oauth_start_failed",
|
|
202
|
+
"message": describe_connect_failure(exc),
|
|
203
|
+
},
|
|
204
|
+
) from exc
|
|
205
|
+
return {"authorize_url": url}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@router.get("/oauth/callback")
|
|
209
|
+
async def oauth_callback(
|
|
210
|
+
code: str = "", state: str = "", error: str = "", error_description: str = ""
|
|
211
|
+
) -> Response:
|
|
212
|
+
"""Where the authorization server sends the browser back.
|
|
213
|
+
|
|
214
|
+
Deliberately **not** returning JSON: a person is looking at this, having just
|
|
215
|
+
clicked Approve on somebody else's site. It answers with a small page that
|
|
216
|
+
says what happened and closes itself.
|
|
217
|
+
|
|
218
|
+
An unknown ``state`` completes nothing — see ``complete_authorization``.
|
|
219
|
+
"""
|
|
220
|
+
if error:
|
|
221
|
+
return _callback_page(False, error_description or error)
|
|
222
|
+
if not code or not state:
|
|
223
|
+
return _callback_page(False, t("mcp.oauth_callback_incomplete"))
|
|
224
|
+
if not oauth.complete_authorization(state, code):
|
|
225
|
+
return _callback_page(False, t("mcp.oauth_callback_unknown"))
|
|
226
|
+
return _callback_page(True, "")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _request_origin(request: Request) -> str:
|
|
230
|
+
"""The origin this request arrived on, honouring a reverse proxy's headers."""
|
|
231
|
+
headers = request.headers
|
|
232
|
+
proto = headers.get("x-forwarded-proto", "").split(",")[0].strip() or request.url.scheme
|
|
233
|
+
host = headers.get("x-forwarded-host", "").split(",")[0].strip() or headers.get("host", "")
|
|
234
|
+
return f"{proto}://{host}" if host else ""
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _callback_page(ok: bool, message: str) -> Response:
|
|
238
|
+
"""A minimal self-closing page. No app shell — this tab is disposable."""
|
|
239
|
+
title = t("mcp.oauth_done") if ok else t("mcp.oauth_failed")
|
|
240
|
+
detail = "" if ok else f"<p class='d'>{html.escape(message)}</p>"
|
|
241
|
+
body = f"""<!doctype html><meta charset="utf-8">
|
|
242
|
+
<title>{html.escape(title)}</title>
|
|
243
|
+
<style>
|
|
244
|
+
body{{font:15px/1.6 system-ui,sans-serif;margin:0;display:grid;place-items:center;
|
|
245
|
+
min-height:100vh;background:#fafafa;color:#1c1c1c}}
|
|
246
|
+
.c{{text-align:center;padding:28px 32px}}
|
|
247
|
+
.d{{color:#8a6d3b;font-size:13px;max-width:34rem;word-break:break-word}}
|
|
248
|
+
@media(prefers-color-scheme:dark){{body{{background:#111;color:#e8e8e8}}}}
|
|
249
|
+
</style>
|
|
250
|
+
<div class="c"><p>{html.escape(title)}</p>{detail}
|
|
251
|
+
<script>setTimeout(function(){{window.close()}},{1200 if ok else 6000})</script></div>"""
|
|
252
|
+
return Response(content=body, media_type="text/html", status_code=200 if ok else 400)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@router.post("/servers/{name}/test")
|
|
256
|
+
async def test_server(name: str, payload: ServerPayload) -> dict[str, Any]:
|
|
257
|
+
"""Probe a definition before saving it.
|
|
258
|
+
|
|
259
|
+
Runs under the caller's own owner id so the probe obeys the same address
|
|
260
|
+
policy and redirect rule the real connection will — a Test more permissive
|
|
261
|
+
than the connection it previews is worse than no Test at all.
|
|
262
|
+
|
|
263
|
+
Deliberately side-effect free: nothing is written, so the incoming config is
|
|
264
|
+
probed as given (any literal credential in it is used in memory only). That
|
|
265
|
+
also means a caller may probe under a scratch name the store would refuse,
|
|
266
|
+
which is how the UI tests a draft before it has one.
|
|
267
|
+
"""
|
|
268
|
+
_ = name # part of the path for symmetry with PUT; the probe is stateless
|
|
269
|
+
owner = current_owner_id()
|
|
270
|
+
resolved = _resolve_for_probe(owner, payload.config)
|
|
271
|
+
return await probe_server(resolved, owner=owner)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _resolve_for_probe(owner: str, config: MCPServerConfig) -> MCPServerConfig:
|
|
275
|
+
"""Fill in stored credentials the draft did not re-enter.
|
|
276
|
+
|
|
277
|
+
An edit form shows a saved credential as "configured" rather than echoing
|
|
278
|
+
it, so the draft it submits still carries the reference. Testing that draft
|
|
279
|
+
has to resolve it — otherwise re-testing an unchanged server authenticates
|
|
280
|
+
with an empty string and reports a failure that is not real.
|
|
281
|
+
"""
|
|
282
|
+
from deeptutor.services.mcp.manager import MCPConnectionManager
|
|
283
|
+
|
|
284
|
+
return MCPConnectionManager._materialize(config, owner)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@router.get("/catalog")
|
|
288
|
+
async def get_catalog(
|
|
289
|
+
q: str = "",
|
|
290
|
+
category: str = "",
|
|
291
|
+
tier: str = "",
|
|
292
|
+
cursor: str = "",
|
|
293
|
+
limit: int = 30,
|
|
294
|
+
) -> dict[str, Any]:
|
|
295
|
+
owner = current_owner_id()
|
|
296
|
+
installed = load_user_mcp_config(owner)[0].servers
|
|
297
|
+
# ``self_service_only`` on both calls: stdio entries are catalogued for the
|
|
298
|
+
# deployment admin, and a category chip whose contents are all stdio would
|
|
299
|
+
# open to an empty grid here.
|
|
300
|
+
page = search_catalog(
|
|
301
|
+
q=q,
|
|
302
|
+
category=category,
|
|
303
|
+
tier=tier,
|
|
304
|
+
cursor=cursor,
|
|
305
|
+
limit=limit,
|
|
306
|
+
self_service_only=True,
|
|
307
|
+
)
|
|
308
|
+
return {
|
|
309
|
+
"entries": [_catalog_row(entry, installed) for entry in page.entries],
|
|
310
|
+
"next_cursor": page.next_cursor,
|
|
311
|
+
"total": page.total,
|
|
312
|
+
# Counted under the same q/tier the listing used, so a chip's number
|
|
313
|
+
# cannot contradict what clicking it shows. ``category`` is excluded on
|
|
314
|
+
# purpose — that is what the chips choose between.
|
|
315
|
+
"categories": category_counts(q=q, tier=tier, self_service_only=True),
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
@router.post("/catalog/{entry_id}/install")
|
|
320
|
+
async def install_catalog_entry(entry_id: str, payload: InstallPayload) -> dict[str, Any]:
|
|
321
|
+
owner = current_owner_id()
|
|
322
|
+
entry = get_entry(entry_id)
|
|
323
|
+
if entry is None:
|
|
324
|
+
raise HTTPException(status_code=404, detail=t("mcp.catalog_entry_missing", id=entry_id))
|
|
325
|
+
if not entry.self_service:
|
|
326
|
+
# stdio entries are catalogued for administrators; a user installing one
|
|
327
|
+
# would be asking the host to run a command for them.
|
|
328
|
+
raise HTTPException(
|
|
329
|
+
status_code=403,
|
|
330
|
+
detail={"code": "mcp.entry_admin_only", "message": t("mcp.entry_admin_only")},
|
|
331
|
+
)
|
|
332
|
+
name = payload.name or entry.id
|
|
333
|
+
try:
|
|
334
|
+
built = build_server_config(entry, payload.secrets)
|
|
335
|
+
except ValueError as exc:
|
|
336
|
+
raise HTTPException(
|
|
337
|
+
status_code=400, detail={"code": "mcp.missing_credential", "message": str(exc)}
|
|
338
|
+
) from exc
|
|
339
|
+
try:
|
|
340
|
+
assert_name_available(name, shared_names=_shared_names())
|
|
341
|
+
# Off the loop: the write validates the URL, which resolves DNS, and a
|
|
342
|
+
# dead resolver would otherwise stall every request in the process.
|
|
343
|
+
await asyncio.to_thread(save_user_server, owner, name, built.config)
|
|
344
|
+
except UserMcpError as exc:
|
|
345
|
+
raise _refuse(exc) from exc
|
|
346
|
+
if built.secret_values:
|
|
347
|
+
# Already rendered by the catalog (a header declared ``Bearer {value}``
|
|
348
|
+
# is stored decorated, because the config side holds only a bare
|
|
349
|
+
# reference).
|
|
350
|
+
store_secrets(owner, name, built.secret_values)
|
|
351
|
+
await get_mcp_manager().reload_scope(owner)
|
|
352
|
+
return await list_servers()
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _catalog_row(entry: Any, installed: dict[str, MCPServerConfig]) -> dict[str, Any]:
|
|
356
|
+
installed_as = _installed_names(entry.id, installed)
|
|
357
|
+
return {
|
|
358
|
+
"id": entry.id,
|
|
359
|
+
"display_name": entry.display_name,
|
|
360
|
+
"description_i18n": entry.description_i18n,
|
|
361
|
+
"category": entry.category,
|
|
362
|
+
"tier": entry.tier,
|
|
363
|
+
"transport": entry.transport,
|
|
364
|
+
"homepage": entry.homepage,
|
|
365
|
+
"docs_url": entry.docs_url,
|
|
366
|
+
"requires_i18n": entry.requires_i18n,
|
|
367
|
+
"logo_url": entry.logo_url,
|
|
368
|
+
"trust": entry.trust,
|
|
369
|
+
"self_service": entry.self_service,
|
|
370
|
+
"installed": bool(installed_as),
|
|
371
|
+
# The local names this entry is installed under. The installer chooses
|
|
372
|
+
# the name, so this is what a client needs to offer "test" or "remove"
|
|
373
|
+
# for an entry it did not install under the default name.
|
|
374
|
+
"installed_as": installed_as,
|
|
375
|
+
"fields": [
|
|
376
|
+
{
|
|
377
|
+
"key": field.key,
|
|
378
|
+
"label_i18n": field.label_i18n,
|
|
379
|
+
"secret": field.secret,
|
|
380
|
+
"required": field.required,
|
|
381
|
+
"placeholder": field.placeholder,
|
|
382
|
+
}
|
|
383
|
+
for field in entry.fields
|
|
384
|
+
],
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _installed_names(entry_id: str, installed: dict[str, MCPServerConfig]) -> list[str]:
|
|
389
|
+
"""Local names *entry_id* is installed under, by recorded provenance.
|
|
390
|
+
|
|
391
|
+
A server installed before ``catalog_entry`` existed carries no provenance, so
|
|
392
|
+
a name equal to the entry id counts too — that was the only name the store
|
|
393
|
+
could have written it under.
|
|
394
|
+
"""
|
|
395
|
+
return sorted(
|
|
396
|
+
name
|
|
397
|
+
for name, cfg in installed.items()
|
|
398
|
+
if cfg.catalog_entry == entry_id or (not cfg.catalog_entry and name == entry_id)
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _extract_credentials(
|
|
403
|
+
owner: str, name: str, payload: ServerPayload
|
|
404
|
+
) -> tuple[MCPServerConfig, dict[str, str]]:
|
|
405
|
+
"""Move every credential out of the config, returning both halves.
|
|
406
|
+
|
|
407
|
+
Deliberately **not** driven by what the client labelled as secret. A form
|
|
408
|
+
that lets someone type ``Authorization: Bearer sk-live-…`` into a generic
|
|
409
|
+
header row has no notion of which row is sensitive, so trusting the label
|
|
410
|
+
would mean the plaintext lands on disk and comes straight back out of ``GET
|
|
411
|
+
/servers`` — exactly what the rest of this surface promises it never does.
|
|
412
|
+
|
|
413
|
+
So the rule is positional instead: on a self-configured server, a header or
|
|
414
|
+
env value **is** the credential. Treating a harmless ``Accept`` header as
|
|
415
|
+
sensitive costs only that its value is shown as "configured" rather than
|
|
416
|
+
echoed; the other direction costs a leaked API key.
|
|
417
|
+
|
|
418
|
+
Values already in reference form are left alone, so re-saving a server the
|
|
419
|
+
user did not re-enter credentials for does not overwrite them with the
|
|
420
|
+
placeholder they were shown.
|
|
421
|
+
"""
|
|
422
|
+
from deeptutor.services.mcp.secrets import SECRET_REFERENCE_RE, secret_reference
|
|
423
|
+
|
|
424
|
+
data = payload.config.model_dump(mode="json")
|
|
425
|
+
secrets: dict[str, str] = dict(payload.secrets)
|
|
426
|
+
|
|
427
|
+
for section in ("headers", "env"):
|
|
428
|
+
values = data.get(section)
|
|
429
|
+
if not isinstance(values, dict):
|
|
430
|
+
continue
|
|
431
|
+
for key, value in list(values.items()):
|
|
432
|
+
if not isinstance(value, str) or not value:
|
|
433
|
+
continue
|
|
434
|
+
if SECRET_REFERENCE_RE.match(value):
|
|
435
|
+
continue
|
|
436
|
+
field = f"{section[:-1] if section == 'headers' else section}.{key}"
|
|
437
|
+
secrets[field] = value
|
|
438
|
+
values[key] = secret_reference(name, field)
|
|
439
|
+
|
|
440
|
+
# A value the client *did* label keeps working: swap any remaining literal
|
|
441
|
+
# occurrence of it (a url query parameter, an argument) for its reference.
|
|
442
|
+
literals = {value: key for key, value in payload.secrets.items() if value}
|
|
443
|
+
if literals:
|
|
444
|
+
|
|
445
|
+
def _swap(value: Any) -> Any:
|
|
446
|
+
if isinstance(value, str):
|
|
447
|
+
key = literals.get(value)
|
|
448
|
+
return secret_reference(name, key) if key else value
|
|
449
|
+
if isinstance(value, dict):
|
|
450
|
+
return {inner: _swap(item) for inner, item in value.items()}
|
|
451
|
+
if isinstance(value, list):
|
|
452
|
+
return [_swap(item) for item in value]
|
|
453
|
+
return value
|
|
454
|
+
|
|
455
|
+
data = _swap(data)
|
|
456
|
+
|
|
457
|
+
return MCPServerConfig.model_validate(data), secrets
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
__all__ = ["router"]
|