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,205 @@
1
+ """Authenticated, same-origin Office document preview as a paginated PDF."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from contextlib import nullcontext
7
+ from pathlib import Path
8
+ import re
9
+ from urllib.parse import parse_qs, unquote, urlsplit
10
+
11
+ from fastapi import APIRouter, File, HTTPException, Request, UploadFile
12
+ from fastapi.responses import Response
13
+
14
+ from deeptutor.api.utils.http_headers import content_disposition
15
+ from deeptutor.multi_user.paths import get_current_path_service
16
+ from deeptutor.services.office_preview import (
17
+ MAX_OFFICE_BYTES,
18
+ OFFICE_SUFFIXES,
19
+ OfficePreviewConversionFailed,
20
+ OfficePreviewInvalid,
21
+ OfficePreviewTimeout,
22
+ OfficePreviewUnavailable,
23
+ render_office_pdf,
24
+ )
25
+ from deeptutor.services.workspace.context import workspace_context
26
+ from deeptutor.services.workspace.models import WorkspaceError
27
+
28
+ router = APIRouter()
29
+
30
+
31
+ def _source_path(source: str, request: Request) -> tuple[Path, str, Path]:
32
+ """Resolve only local URLs using their existing authorization boundaries."""
33
+ if len(source) > 8192:
34
+ raise HTTPException(status_code=400, detail="Invalid preview source")
35
+ parsed = urlsplit(source)
36
+ if parsed.scheme or parsed.netloc or parsed.fragment or not parsed.path.startswith("/"):
37
+ raise HTTPException(status_code=400, detail="Preview source must be a local file URL")
38
+ query = parse_qs(parsed.query, keep_blank_values=True)
39
+ if any(
40
+ name not in {"dt_workspace", "workspace", "resource_library"} or len(values) != 1
41
+ for name, values in query.items()
42
+ ):
43
+ raise HTTPException(status_code=400, detail="Invalid preview source query")
44
+ pinned_scope = query.get("dt_workspace", [None])[0]
45
+ legacy_scope = query.get("workspace", [None])[0]
46
+ if pinned_scope is not None and legacy_scope is not None and pinned_scope != legacy_scope:
47
+ raise HTTPException(status_code=400, detail="Conflicting workspace scopes")
48
+ inner_scope = pinned_scope if pinned_scope is not None else legacy_scope
49
+ outer_scopes = request.query_params.getlist("dt_workspace")
50
+ if len(outer_scopes) > 1:
51
+ raise HTTPException(status_code=400, detail="Conflicting workspace scopes")
52
+ outer_scope = outer_scopes[0] if outer_scopes else request.headers.get("x-deeptutor-workspace")
53
+ if inner_scope is not None and outer_scope is not None and inner_scope != outer_scope:
54
+ raise HTTPException(status_code=400, detail="Conflicting workspace scopes")
55
+ library = query.get("resource_library", ["false"])[0]
56
+ if library not in {"true", "false"}:
57
+ raise HTTPException(status_code=400, detail="Invalid preview source query")
58
+ if library == "true" and not parsed.path.startswith("/api/knowledge-bases/"):
59
+ raise HTTPException(status_code=400, detail="Invalid preview source query")
60
+
61
+ # The authentication dependency selects the outer request scope. Historic
62
+ # file URLs may carry only an inner scope, so restore that scope here after
63
+ # auth; workspace_context validates that it belongs to the caller.
64
+ if library == "true":
65
+ # The canonical knowledge-base route selects the account library,
66
+ # independent of the currently selected content workspace.
67
+ context = workspace_context("")
68
+ elif inner_scope is not None and outer_scope is None:
69
+ context = workspace_context(inner_scope)
70
+ else:
71
+ context = nullcontext()
72
+ canonical_path = unquote(parsed.path)
73
+ _assert_surface("reading" if canonical_path.startswith("/api/knowledge-bases/") else "chat")
74
+ try:
75
+ with context:
76
+ target, filename = _resolve_authorized_file(canonical_path, query)
77
+ return target, filename, _cache_dir()
78
+ except WorkspaceError as exc:
79
+ raise HTTPException(status_code=404, detail="Preview source not found") from exc
80
+
81
+
82
+ def _resolve_authorized_file(path: str, query: dict[str, list[str]]) -> tuple[Path, str]:
83
+ if match := re.fullmatch(r"/files/attachments/([^/]+)/([^/]+)/([^/]+)", path):
84
+ from deeptutor.services.storage import LocalDiskAttachmentStore, get_attachment_store
85
+
86
+ store = get_attachment_store()
87
+ if not isinstance(store, LocalDiskAttachmentStore):
88
+ raise HTTPException(status_code=501, detail="Attachment backend not servable")
89
+ target = store.resolve_path(
90
+ session_id=match.group(1), attachment_id=match.group(2), filename=match.group(3)
91
+ )
92
+ if target is None:
93
+ raise HTTPException(status_code=404, detail="Preview source not found")
94
+ return target, match.group(3)
95
+
96
+ if path.startswith("/files/outputs/"):
97
+ from deeptutor.api.routers.outputs import (
98
+ _request_path_service,
99
+ _resolve_output,
100
+ _resolve_partner_output,
101
+ )
102
+
103
+ relative = path.removeprefix("/files/outputs/")
104
+ try:
105
+ target = _resolve_output(_request_path_service(), relative)
106
+ except HTTPException:
107
+ target = _resolve_partner_output(relative)
108
+ if target is None:
109
+ raise HTTPException(status_code=404, detail="Preview source not found")
110
+ return target, target.name
111
+
112
+ if match := re.fullmatch(r"/files/workspace-items/([^/]+)/([^/]+)", path):
113
+ from deeptutor.api.routers.workspace import _resolve_partner_item
114
+ from deeptutor.services.workspace import get_content_workspace_service
115
+
116
+ try:
117
+ target, item = get_content_workspace_service().resolve_published_item(*match.groups())
118
+ except WorkspaceError:
119
+ resolved = _resolve_partner_item(*match.groups())
120
+ if resolved is None:
121
+ raise HTTPException(status_code=404, detail="Preview source not found")
122
+ target, item = resolved
123
+ return target, item.filename
124
+
125
+ if match := re.fullmatch(r"/api/knowledge-bases/([^/]+)/files/(.+)", path):
126
+ from deeptutor.api.routers.knowledge import _resolve_kb_raw_file_or_404
127
+ from deeptutor.services.workspace.knowledge import library_request
128
+
129
+ library = query.get("resource_library", ["false"])[0]
130
+ token = library_request.set(library == "true")
131
+ try:
132
+ target = _resolve_kb_raw_file_or_404(match.group(1), match.group(2))
133
+ finally:
134
+ library_request.reset(token)
135
+ return target, target.name
136
+
137
+ raise HTTPException(status_code=400, detail="Unsupported preview source URL")
138
+
139
+
140
+ def _cache_dir() -> Path:
141
+ return get_current_path_service().get_user_root() / "cache" / "office_previews"
142
+
143
+
144
+ def _read_bounded(path: Path) -> bytes:
145
+ try:
146
+ with path.open("rb") as source:
147
+ return source.read(MAX_OFFICE_BYTES + 1)
148
+ except OSError as exc:
149
+ raise HTTPException(status_code=404, detail="Preview source not found") from exc
150
+
151
+
152
+ def _assert_surface(surface: str) -> None:
153
+ from deeptutor.multi_user.learning_access import assert_learning_surface
154
+
155
+ try:
156
+ assert_learning_surface(surface)
157
+ except PermissionError as exc:
158
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
159
+
160
+
161
+ async def _pdf_response(data: bytes, filename: str, cache_dir: Path) -> Response:
162
+ if Path(filename).suffix.lower() not in OFFICE_SUFFIXES:
163
+ raise HTTPException(status_code=415, detail="Unsupported Office file type")
164
+ try:
165
+ pdf = await render_office_pdf(data, filename, cache_dir)
166
+ except OfficePreviewInvalid as exc:
167
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
168
+ except OfficePreviewUnavailable as exc:
169
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
170
+ except OfficePreviewTimeout as exc:
171
+ raise HTTPException(status_code=504, detail=str(exc)) from exc
172
+ except OfficePreviewConversionFailed as exc:
173
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
174
+ pdf_name = "".join(char if char.isprintable() else "_" for char in Path(filename).stem) + ".pdf"
175
+ return Response(
176
+ content=pdf,
177
+ media_type="application/pdf",
178
+ headers={
179
+ "Content-Disposition": content_disposition(pdf_name),
180
+ "Cache-Control": "private, no-store",
181
+ "X-Content-Type-Options": "nosniff",
182
+ },
183
+ )
184
+
185
+
186
+ @router.get("/pdf")
187
+ async def preview_office_source(source: str, request: Request) -> Response:
188
+ """Render a file URL that this same authenticated user could open."""
189
+ path, filename, cache_dir = _source_path(source, request)
190
+ data = await asyncio.to_thread(_read_bounded, path)
191
+ return await _pdf_response(data, filename, cache_dir)
192
+
193
+
194
+ @router.post("/pdf")
195
+ async def preview_office_upload(file: UploadFile = File(...)) -> Response:
196
+ """Render a bounded pending Blob/data attachment that has no file URL."""
197
+ _assert_surface("chat")
198
+ filename = file.filename or ""
199
+ if Path(filename).suffix.lower() not in OFFICE_SUFFIXES:
200
+ raise HTTPException(status_code=415, detail="Unsupported Office file type")
201
+ try:
202
+ data = await file.read(MAX_OFFICE_BYTES + 1)
203
+ finally:
204
+ await file.close()
205
+ return await _pdf_response(data, filename, _cache_dir())
@@ -0,0 +1,146 @@
1
+ """
2
+ Import chat histories from ChatGPT exports and external coding CLIs (Claude
3
+ Code, Codex) into the user's learning space as normal, re-openable sessions.
4
+
5
+ Reading the user's local ``~/.claude`` / ``~/.codex`` or ChatGPT export happens
6
+ in the browser — those files live on the user's machine, not the server. The
7
+ browser normalizes each conversation to the small JSON shape below and POSTs
8
+ it here; this router only validates and persists. Imported sessions
9
+ share the session tables with native chats (so the chat loop can re-open and
10
+ continue them) but carry an ``imported_`` id prefix that keeps them in their
11
+ own Space category. Re-importing the same folder is idempotent (dedup by id).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from typing import Any
18
+
19
+ from fastapi import APIRouter, HTTPException, Query
20
+ from pydantic import BaseModel, Field, field_validator
21
+
22
+ from deeptutor.services.session import (
23
+ get_sqlite_session_store,
24
+ make_imported_session_id,
25
+ )
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ router = APIRouter()
30
+
31
+ # Browser adapters only emit these; reject anything else so a malformed payload
32
+ # can never seed an unsupported provider category.
33
+ _ALLOWED_SOURCES = {"chatgpt", "claude_code", "codex"}
34
+
35
+ # Defensive ceilings — a single import request should never grow unbounded.
36
+ _MAX_SESSIONS_PER_REQUEST = 1000
37
+ _MAX_MESSAGES_PER_SESSION = 10000
38
+
39
+
40
+ class ImportedMessage(BaseModel):
41
+ role: str = Field(..., pattern="^(user|assistant)$")
42
+ content: str = ""
43
+ created_at: float | None = None
44
+ metadata: dict[str, Any] = Field(default_factory=dict)
45
+
46
+
47
+ class ImportedSession(BaseModel):
48
+ external_id: str = Field(..., min_length=1, max_length=256)
49
+ title: str = ""
50
+ source_cwd: str = ""
51
+ created_at: float
52
+ updated_at: float
53
+ messages: list[ImportedMessage] = Field(default_factory=list)
54
+
55
+
56
+ class ChatHistoryImportRequest(BaseModel):
57
+ source: str = Field(..., min_length=1)
58
+ # All sessions in one request belong to one agent (a named, scoped slice of
59
+ # a source folder). Empty when the client hasn't adopted the agent model yet
60
+ # — the backend stays backwards-compatible by simply omitting attribution.
61
+ agent_id: str = Field(default="", max_length=256)
62
+ agent_name: str = Field(default="", max_length=256)
63
+ sessions: list[ImportedSession] = Field(default_factory=list)
64
+
65
+ @field_validator("source")
66
+ @classmethod
67
+ def _normalize_source(cls, value: str) -> str:
68
+ normalized = (value or "").strip().lower()
69
+ if normalized not in _ALLOWED_SOURCES:
70
+ raise ValueError(f"Unsupported import source: {value!r}")
71
+ return normalized
72
+
73
+
74
+ @router.post("/chat-history")
75
+ async def import_chat_history(payload: ChatHistoryImportRequest) -> dict[str, Any]:
76
+ if not payload.sessions:
77
+ raise HTTPException(status_code=400, detail="No sessions to import")
78
+ if len(payload.sessions) > _MAX_SESSIONS_PER_REQUEST:
79
+ raise HTTPException(
80
+ status_code=413,
81
+ detail=f"Too many sessions in one request (max {_MAX_SESSIONS_PER_REQUEST})",
82
+ )
83
+
84
+ store = get_sqlite_session_store()
85
+ imported = 0
86
+ skipped = 0
87
+ results: list[dict[str, Any]] = []
88
+
89
+ for incoming in payload.sessions:
90
+ # Drop content-less rows (e.g. tool-only turns the adapter could not
91
+ # reduce to text) so the transcript stays a clean human conversation.
92
+ messages = [m for m in incoming.messages if (m.content or "").strip()]
93
+ if not messages:
94
+ skipped += 1
95
+ results.append(
96
+ {"external_id": incoming.external_id, "imported": False, "reason": "empty"}
97
+ )
98
+ continue
99
+ if len(messages) > _MAX_MESSAGES_PER_SESSION:
100
+ messages = messages[:_MAX_MESSAGES_PER_SESSION]
101
+
102
+ session_id = make_imported_session_id(payload.source, incoming.external_id)
103
+ import_meta: dict[str, Any] = {
104
+ "source": payload.source,
105
+ "source_cwd": incoming.source_cwd,
106
+ "external_id": incoming.external_id,
107
+ }
108
+ if payload.agent_id:
109
+ import_meta["agent_id"] = payload.agent_id
110
+ if payload.agent_name:
111
+ import_meta["agent_name"] = payload.agent_name
112
+ preferences = {"import": import_meta}
113
+ try:
114
+ result = await store.import_session(
115
+ session_id,
116
+ incoming.title,
117
+ incoming.created_at,
118
+ incoming.updated_at,
119
+ preferences,
120
+ [m.model_dump() for m in messages],
121
+ )
122
+ except Exception:
123
+ logger.exception("failed to import session %s", incoming.external_id)
124
+ skipped += 1
125
+ results.append(
126
+ {"external_id": incoming.external_id, "imported": False, "reason": "error"}
127
+ )
128
+ continue
129
+
130
+ if result.get("imported"):
131
+ imported += 1
132
+ else:
133
+ skipped += 1
134
+ results.append({"external_id": incoming.external_id, **result})
135
+
136
+ return {"imported": imported, "skipped": skipped, "sessions": results}
137
+
138
+
139
+ @router.get("/chat-history")
140
+ async def list_imported_chat_history(
141
+ limit: int = Query(default=50, ge=1, le=200),
142
+ offset: int = Query(default=0, ge=0),
143
+ ) -> dict[str, Any]:
144
+ store = get_sqlite_session_store()
145
+ sessions = await store.list_imported_sessions(limit=limit, offset=offset)
146
+ return {"sessions": sessions}
@@ -0,0 +1,18 @@
1
+ """Read-only view of the workspace's conversational learning journal."""
2
+
3
+ from fastapi import APIRouter, HTTPException
4
+
5
+ from deeptutor.services.learning_journal import get_learning_journal_store
6
+
7
+ router = APIRouter()
8
+
9
+
10
+ @router.get("")
11
+ def overview():
12
+ try:
13
+ return get_learning_journal_store().overview()
14
+ except (OSError, ValueError) as exc:
15
+ raise HTTPException(
16
+ status_code=409,
17
+ detail="Learning journal could not be read. Its original file has been preserved.",
18
+ ) from exc
@@ -0,0 +1,133 @@
1
+ """
2
+ MCP Settings API Router
3
+ =======================
4
+
5
+ Manage the deployment-global MCP server registry: read/update the config,
6
+ inspect live connection status, and probe a server before saving.
7
+
8
+ Mounted at ``/api/settings/mcp``. Admin-gated: the registry is
9
+ deployment-global state, and a stdio server's ``command`` runs on the host
10
+ as the app user — letting non-admins edit it would be privilege escalation.
11
+ Per-user MCP access is granted through the multi-user grant whitelist
12
+ (``mcp_tools``), not by sharing this registry.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any
18
+
19
+ from fastapi import APIRouter, Depends, HTTPException
20
+ from pydantic import BaseModel, Field, ValidationError
21
+
22
+ from deeptutor.api.routers.auth import require_admin
23
+ from deeptutor.services.i18n import t
24
+ from deeptutor.services.mcp import (
25
+ MCPConfig,
26
+ MCPServerConfig,
27
+ get_mcp_manager,
28
+ load_mcp_config,
29
+ save_mcp_config,
30
+ validate_mcp_url,
31
+ )
32
+ from deeptutor.services.mcp.manager import probe_server
33
+
34
+ router = APIRouter(dependencies=[Depends(require_admin)])
35
+
36
+
37
+ class MCPSettingsPayload(BaseModel):
38
+ servers: dict[str, MCPServerConfig] = Field(default_factory=dict)
39
+
40
+
41
+ def _validate_servers(config: MCPConfig) -> None:
42
+ for name, cfg in config.servers.items():
43
+ transport = cfg.resolved_type()
44
+ if transport is None:
45
+ raise HTTPException(
46
+ status_code=400,
47
+ detail=t("mcp.configure_command_or_url", name=name),
48
+ )
49
+ if transport in {"sse", "streamableHttp"}:
50
+ ok, error = validate_mcp_url(cfg.url)
51
+ if not ok:
52
+ raise HTTPException(
53
+ status_code=400, detail=t("mcp.server_error", name=name, error=error)
54
+ )
55
+
56
+
57
+ @router.get("")
58
+ async def get_mcp_settings() -> dict[str, Any]:
59
+ config = load_mcp_config()
60
+ manager = get_mcp_manager()
61
+ await manager.ensure_started()
62
+ return {
63
+ "servers": {name: cfg.model_dump(mode="json") for name, cfg in config.servers.items()},
64
+ "status": manager.status(),
65
+ }
66
+
67
+
68
+ @router.put("")
69
+ async def update_mcp_settings(payload: MCPSettingsPayload) -> dict[str, Any]:
70
+ try:
71
+ config = MCPConfig(servers=payload.servers)
72
+ except (ValidationError, ValueError) as exc:
73
+ raise HTTPException(status_code=400, detail=str(exc))
74
+ _validate_servers(config)
75
+ save_mcp_config(config)
76
+ manager = get_mcp_manager()
77
+ await manager.reload()
78
+ return {"status": manager.status()}
79
+
80
+
81
+ @router.put("/servers/{name}")
82
+ async def upsert_mcp_server(name: str, cfg: MCPServerConfig) -> dict[str, Any]:
83
+ """Upsert one server, leaving every other entry byte-identical.
84
+
85
+ The whole-map ``PUT`` above cannot express "change this one": a client has to
86
+ send back everything it read, so it silently drops any field it does not
87
+ model (a hand-written ``disabled_tools`` blocklist) and overwrites whatever
88
+ a second administrator saved in between.
89
+ """
90
+ config = load_mcp_config()
91
+ servers = dict(config.servers)
92
+ servers[name] = cfg
93
+ try:
94
+ updated = MCPConfig(servers=servers)
95
+ except (ValidationError, ValueError) as exc:
96
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
97
+ _validate_servers(MCPConfig(servers={name: cfg}))
98
+ save_mcp_config(updated)
99
+ manager = get_mcp_manager()
100
+ await manager.reload()
101
+ return {
102
+ "servers": {key: value.model_dump(mode="json") for key, value in updated.servers.items()},
103
+ "status": manager.status(),
104
+ }
105
+
106
+
107
+ @router.delete("/servers/{name}")
108
+ async def delete_mcp_server(name: str) -> dict[str, Any]:
109
+ config = load_mcp_config()
110
+ servers = {key: value for key, value in config.servers.items() if key != name}
111
+ updated = MCPConfig(servers=servers)
112
+ save_mcp_config(updated)
113
+ manager = get_mcp_manager()
114
+ await manager.reload()
115
+ return {
116
+ "servers": {key: value.model_dump(mode="json") for key, value in updated.servers.items()},
117
+ "status": manager.status(),
118
+ }
119
+
120
+
121
+ @router.post("/test")
122
+ async def test_mcp_server(cfg: MCPServerConfig) -> dict[str, Any]:
123
+ transport = cfg.resolved_type()
124
+ if transport is None:
125
+ raise HTTPException(
126
+ status_code=400,
127
+ detail=t("mcp.configure_before_testing"),
128
+ )
129
+ if transport in {"sse", "streamableHttp"}:
130
+ ok, error = validate_mcp_url(cfg.url)
131
+ if not ok:
132
+ raise HTTPException(status_code=400, detail=error)
133
+ return await probe_server(cfg)
@@ -0,0 +1,12 @@
1
+ """Read-only deployment inventory; code mutations run in the offline CLI."""
2
+
3
+ from fastapi import APIRouter
4
+
5
+ from deeptutor.modules.catalog import module_statuses
6
+
7
+ router = APIRouter()
8
+
9
+
10
+ @router.get("")
11
+ def list_modules():
12
+ return {"modules": module_statuses()}