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.
Files changed (48) hide show
  1. deeptutor/api/__init__.py +0 -0
  2. deeptutor/api/contracts/__init__.py +21 -0
  3. deeptutor/api/contracts/export.py +110 -0
  4. deeptutor/api/contracts/turn_protocol.py +313 -0
  5. deeptutor/api/main.py +547 -0
  6. deeptutor/api/module_routes.py +42 -0
  7. deeptutor/api/router_index.json +5752 -0
  8. deeptutor/api/router_loader.py +139 -0
  9. deeptutor/api/routers/__init__.py +1 -0
  10. deeptutor/api/routers/agent_config.py +59 -0
  11. deeptutor/api/routers/attachments.py +79 -0
  12. deeptutor/api/routers/auth.py +1937 -0
  13. deeptutor/api/routers/capabilities.py +44 -0
  14. deeptutor/api/routers/capabilities_settings.py +38 -0
  15. deeptutor/api/routers/dashboard.py +292 -0
  16. deeptutor/api/routers/file_library.py +169 -0
  17. deeptutor/api/routers/file_preview.py +205 -0
  18. deeptutor/api/routers/imports.py +146 -0
  19. deeptutor/api/routers/learning_journal.py +18 -0
  20. deeptutor/api/routers/mcp_settings.py +133 -0
  21. deeptutor/api/routers/modules.py +12 -0
  22. deeptutor/api/routers/multi_user.py +943 -0
  23. deeptutor/api/routers/outputs.py +97 -0
  24. deeptutor/api/routers/personas.py +154 -0
  25. deeptutor/api/routers/plugins.py +416 -0
  26. deeptutor/api/routers/quiz_judge.py +464 -0
  27. deeptutor/api/routers/sessions.py +801 -0
  28. deeptutor/api/routers/settings.py +2503 -0
  29. deeptutor/api/routers/skills.py +315 -0
  30. deeptutor/api/routers/space_cli_apps.py +222 -0
  31. deeptutor/api/routers/space_mcp.py +460 -0
  32. deeptutor/api/routers/subagents.py +306 -0
  33. deeptutor/api/routers/system.py +633 -0
  34. deeptutor/api/routers/task_board.py +139 -0
  35. deeptutor/api/routers/tools.py +268 -0
  36. deeptutor/api/routers/unified_ws.py +385 -0
  37. deeptutor/api/routers/workspace.py +371 -0
  38. deeptutor/api/run_server.py +99 -0
  39. deeptutor/api/security_guard.py +91 -0
  40. deeptutor/api/utils/http_headers.py +27 -0
  41. deeptutor/api/utils/progress_broadcaster.py +73 -0
  42. deeptutor/api/utils/task_id_manager.py +120 -0
  43. deeptutor/api/utils/task_log_stream.py +362 -0
  44. deeptutor/api/utils/tool_options.py +133 -0
  45. deeptutor_server-1.7.0.dist-info/METADATA +14 -0
  46. deeptutor_server-1.7.0.dist-info/RECORD +48 -0
  47. deeptutor_server-1.7.0.dist-info/WHEEL +5 -0
  48. 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"]