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,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()}
|