forge-cpp-mcp 0.2.1__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.
- forge_cpp_mcp-0.2.1.dist-info/METADATA +436 -0
- forge_cpp_mcp-0.2.1.dist-info/RECORD +83 -0
- forge_cpp_mcp-0.2.1.dist-info/WHEEL +4 -0
- forge_cpp_mcp-0.2.1.dist-info/entry_points.txt +2 -0
- forge_cpp_mcp-0.2.1.dist-info/licenses/LICENSE +21 -0
- forgemcp/__init__.py +5 -0
- forgemcp/assets/clangd-result.html +119 -0
- forgemcp/assets/cmake-build.html +111 -0
- forgemcp/assets/cmake-configure.html +111 -0
- forgemcp/assets/cmake-profiles.html +111 -0
- forgemcp/assets/cmake-test.html +111 -0
- forgemcp/assets/process-details.html +113 -0
- forgemcp/assets/process-overview.html +111 -0
- forgemcp/assets/toolsets.html +111 -0
- forgemcp/assets/workspace-file.html +119 -0
- forgemcp/assets/workspace-result.html +119 -0
- forgemcp/assets/workspace-search.html +119 -0
- forgemcp/assets/workspace-tree.html +119 -0
- forgemcp/assets.py +62 -0
- forgemcp/clangd/__init__.py +1 -0
- forgemcp/clangd/errors.py +29 -0
- forgemcp/clangd/models.py +94 -0
- forgemcp/clangd/service.py +1279 -0
- forgemcp/clangd/session.py +520 -0
- forgemcp/cmake/__init__.py +1 -0
- forgemcp/cmake/errors.py +5 -0
- forgemcp/cmake/profiles.py +95 -0
- forgemcp/cmake/service.py +895 -0
- forgemcp/completion.py +49 -0
- forgemcp/icons/clangd.LICENSE +219 -0
- forgemcp/icons/clangd.svg +25 -0
- forgemcp/icons/cmake.svg +9 -0
- forgemcp/icons/process.svg +4 -0
- forgemcp/icons/toolchain.svg +1 -0
- forgemcp/icons/workspace-edit.svg +1 -0
- forgemcp/icons/workspace-file.svg +1 -0
- forgemcp/icons/workspace-search.svg +1 -0
- forgemcp/icons/workspace.svg +4 -0
- forgemcp/markdown.py +308 -0
- forgemcp/process/__init__.py +21 -0
- forgemcp/process/errors.py +21 -0
- forgemcp/process/models.py +171 -0
- forgemcp/process/service.py +830 -0
- forgemcp/process/transcript.py +116 -0
- forgemcp/progress.py +40 -0
- forgemcp/server.py +196 -0
- forgemcp/toolchain/__init__.py +1 -0
- forgemcp/toolchain/discovery.py +25 -0
- forgemcp/toolchain/errors.py +41 -0
- forgemcp/toolchain/loader.py +48 -0
- forgemcp/toolchain/providers/__init__.py +1 -0
- forgemcp/toolchain/providers/system.py +52 -0
- forgemcp/toolchain/providers/user.py +93 -0
- forgemcp/toolchain/providers/visual_studio.py +202 -0
- forgemcp/toolchain/service.py +226 -0
- forgemcp/toolchain/spec.py +73 -0
- forgemcp/toolchain/tools/__init__.py +1 -0
- forgemcp/toolchain/tools/clang.py +61 -0
- forgemcp/toolchain/tools/clang_cl.py +61 -0
- forgemcp/toolchain/tools/clangd.py +280 -0
- forgemcp/toolchain/tools/clangxx.py +61 -0
- forgemcp/toolchain/tools/cmake.py +280 -0
- forgemcp/toolchain/tools/cppvsdbg.py +27 -0
- forgemcp/toolchain/tools/ctest.py +202 -0
- forgemcp/toolchain/tools/gcc.py +61 -0
- forgemcp/toolchain/tools/gdb.py +61 -0
- forgemcp/toolchain/tools/git.py +61 -0
- forgemcp/toolchain/tools/gxx.py +61 -0
- forgemcp/toolchain/tools/link.py +27 -0
- forgemcp/toolchain/tools/lld.py +61 -0
- forgemcp/toolchain/tools/lld_link.py +61 -0
- forgemcp/toolchain/tools/lldb_dap.py +27 -0
- forgemcp/toolchain/tools/make.py +61 -0
- forgemcp/toolchain/tools/msbuild.py +61 -0
- forgemcp/toolchain/tools/msvc.py +27 -0
- forgemcp/toolchain/tools/ninja.py +61 -0
- forgemcp/workspace/__init__.py +5 -0
- forgemcp/workspace/diff.py +181 -0
- forgemcp/workspace/errors.py +5 -0
- forgemcp/workspace/metadata.py +87 -0
- forgemcp/workspace/path.py +71 -0
- forgemcp/workspace/providers.py +25 -0
- forgemcp/workspace/service.py +1766 -0
|
@@ -0,0 +1,1766 @@
|
|
|
1
|
+
"""Workspace files, storage directories, and MCP registration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from copy import deepcopy
|
|
7
|
+
import fnmatch
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import stat
|
|
12
|
+
import tempfile
|
|
13
|
+
from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping, Sequence
|
|
14
|
+
from contextlib import asynccontextmanager, contextmanager
|
|
15
|
+
from functools import wraps
|
|
16
|
+
from datetime import UTC, datetime
|
|
17
|
+
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
18
|
+
from typing import Annotated, Generator, Generic, Literal, TypeVar
|
|
19
|
+
from types import MappingProxyType
|
|
20
|
+
from urllib.parse import quote
|
|
21
|
+
from uuid import uuid4
|
|
22
|
+
|
|
23
|
+
import regex as regex_engine
|
|
24
|
+
from mcp.server import MCPServer
|
|
25
|
+
from mcp.server.apps import Apps
|
|
26
|
+
from mcp.server.mcpserver import Context, Elicit, Resolve
|
|
27
|
+
from mcp.server.mcpserver.exceptions import ResourceError, ResourceNotFoundError, ToolError
|
|
28
|
+
from mcp.server.mcpserver.resources.templates import ResourceSecurity
|
|
29
|
+
from mcp.types import (
|
|
30
|
+
Completion,
|
|
31
|
+
CompletionArgument,
|
|
32
|
+
CompletionContext,
|
|
33
|
+
ResourceTemplateReference,
|
|
34
|
+
ToolAnnotations,
|
|
35
|
+
)
|
|
36
|
+
from pydantic import BaseModel, Field
|
|
37
|
+
|
|
38
|
+
from forgemcp import markdown
|
|
39
|
+
from forgemcp.assets import IconFile, Widget
|
|
40
|
+
from forgemcp.completion import Complete
|
|
41
|
+
from forgemcp.progress import progress
|
|
42
|
+
|
|
43
|
+
from .errors import WorkspaceError
|
|
44
|
+
from .providers import ProviderCall, ResultResource, ResultResources
|
|
45
|
+
from .metadata import file_owner
|
|
46
|
+
from .path import WorkspacePath
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ReadConfirmation(BaseModel):
|
|
50
|
+
allow: bool = Field(description="Allow reading this external file.")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def confirm_read(path: WorkspacePath) -> ReadConfirmation | Elicit[ReadConfirmation]:
|
|
54
|
+
if path.area != "root":
|
|
55
|
+
return ReadConfirmation(allow=True)
|
|
56
|
+
return Elicit(f"Allow reading external file {path.absolute}?", ReadConfirmation)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
WorkspaceRoot = Literal["project", "storage"]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TreeEntry(BaseModel):
|
|
63
|
+
path: WorkspacePath
|
|
64
|
+
kind: Literal["file", "directory", "symlink", "other"]
|
|
65
|
+
children: list[TreeEntry] | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class DirectoryTree(ResultResources):
|
|
69
|
+
path: WorkspacePath
|
|
70
|
+
entries: list[TreeEntry]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class FoundFile(BaseModel):
|
|
74
|
+
path: WorkspacePath
|
|
75
|
+
modified_at: datetime
|
|
76
|
+
size_bytes: int
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class FilePaths(ResultResources):
|
|
80
|
+
paths: list[WorkspacePath]
|
|
81
|
+
files: list[FoundFile] = Field(default_factory=list)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class FileContent(ResultResources):
|
|
85
|
+
path: WorkspacePath
|
|
86
|
+
text: str
|
|
87
|
+
start_line: int
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class FileInfo(ResultResources):
|
|
91
|
+
path: WorkspacePath
|
|
92
|
+
created_at: datetime | None
|
|
93
|
+
modified_at: datetime
|
|
94
|
+
size_bytes: int
|
|
95
|
+
owner: str | None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class SearchMatch(BaseModel):
|
|
99
|
+
path: WorkspacePath
|
|
100
|
+
line: int
|
|
101
|
+
text: str
|
|
102
|
+
spans: list[tuple[int, int]] = Field(
|
|
103
|
+
description="Match ranges as zero-based Unicode code point [start, end) pairs.",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class SearchResult(ResultResources):
|
|
108
|
+
matches: list[SearchMatch]
|
|
109
|
+
skipped_files: list[WorkspacePath]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class FileWriteResult(ResultResources):
|
|
113
|
+
path: WorkspacePath
|
|
114
|
+
action: Literal["created", "overwritten"]
|
|
115
|
+
lines_removed: int
|
|
116
|
+
lines_added: int
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class FileEditResult(ResultResources):
|
|
120
|
+
path: WorkspacePath
|
|
121
|
+
replacements: int
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class PathOperationResult(ResultResources):
|
|
125
|
+
path: WorkspacePath
|
|
126
|
+
action: Literal["moved", "deleted", "created", "already_exists"]
|
|
127
|
+
source: WorkspacePath | None = None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
ContextT = TypeVar("ContextT")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class ResultProvider(Generic[ContextT]):
|
|
134
|
+
"""Typed hooks for Workspace tools; override only operations of interest."""
|
|
135
|
+
|
|
136
|
+
async def before_workspace_list(
|
|
137
|
+
self,
|
|
138
|
+
call_id: str,
|
|
139
|
+
path: WorkspacePath,
|
|
140
|
+
depth: int | None,
|
|
141
|
+
include_hidden: bool,
|
|
142
|
+
) -> ContextT | None:
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
async def after_workspace_list(
|
|
146
|
+
self,
|
|
147
|
+
call_id: str,
|
|
148
|
+
context: ContextT,
|
|
149
|
+
result: DirectoryTree,
|
|
150
|
+
) -> str | None:
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
async def before_workspace_find_files(
|
|
154
|
+
self,
|
|
155
|
+
call_id: str,
|
|
156
|
+
pattern: str,
|
|
157
|
+
path: WorkspacePath,
|
|
158
|
+
) -> ContextT | None:
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
async def after_workspace_find_files(
|
|
162
|
+
self,
|
|
163
|
+
call_id: str,
|
|
164
|
+
context: ContextT,
|
|
165
|
+
result: FilePaths,
|
|
166
|
+
) -> str | None:
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
async def before_workspace_file_info(
|
|
170
|
+
self,
|
|
171
|
+
call_id: str,
|
|
172
|
+
path: WorkspacePath,
|
|
173
|
+
) -> ContextT | None:
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
async def after_workspace_file_info(
|
|
177
|
+
self,
|
|
178
|
+
call_id: str,
|
|
179
|
+
context: ContextT,
|
|
180
|
+
result: FileInfo,
|
|
181
|
+
) -> str | None:
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
async def before_workspace_read_file(
|
|
185
|
+
self,
|
|
186
|
+
call_id: str,
|
|
187
|
+
path: WorkspacePath,
|
|
188
|
+
confirm: ReadConfirmation,
|
|
189
|
+
start_line: int,
|
|
190
|
+
end_line: int | None,
|
|
191
|
+
) -> ContextT | None:
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
async def after_workspace_read_file(
|
|
195
|
+
self,
|
|
196
|
+
call_id: str,
|
|
197
|
+
context: ContextT,
|
|
198
|
+
result: FileContent,
|
|
199
|
+
) -> str | None:
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
async def before_workspace_search(
|
|
203
|
+
self,
|
|
204
|
+
call_id: str,
|
|
205
|
+
query: str,
|
|
206
|
+
path: WorkspacePath,
|
|
207
|
+
regex: bool,
|
|
208
|
+
extensions: list[str] | None,
|
|
209
|
+
case_sensitive: bool,
|
|
210
|
+
) -> ContextT | None:
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
async def after_workspace_search(
|
|
214
|
+
self,
|
|
215
|
+
call_id: str,
|
|
216
|
+
context: ContextT,
|
|
217
|
+
result: SearchResult,
|
|
218
|
+
) -> str | None:
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
async def before_workspace_write_file(
|
|
222
|
+
self,
|
|
223
|
+
call_id: str,
|
|
224
|
+
path: WorkspacePath,
|
|
225
|
+
text: str,
|
|
226
|
+
) -> ContextT | None:
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
async def after_workspace_write_file(
|
|
230
|
+
self,
|
|
231
|
+
call_id: str,
|
|
232
|
+
context: ContextT,
|
|
233
|
+
result: FileWriteResult,
|
|
234
|
+
) -> str | None:
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
async def before_workspace_edit_file(
|
|
238
|
+
self,
|
|
239
|
+
call_id: str,
|
|
240
|
+
path: WorkspacePath,
|
|
241
|
+
old_text: str,
|
|
242
|
+
new_text: str,
|
|
243
|
+
replace_all: bool,
|
|
244
|
+
) -> ContextT | None:
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
async def after_workspace_edit_file(
|
|
248
|
+
self,
|
|
249
|
+
call_id: str,
|
|
250
|
+
context: ContextT,
|
|
251
|
+
result: FileEditResult,
|
|
252
|
+
) -> str | None:
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
async def before_workspace_move(
|
|
256
|
+
self,
|
|
257
|
+
call_id: str,
|
|
258
|
+
source: WorkspacePath,
|
|
259
|
+
destination: WorkspacePath,
|
|
260
|
+
) -> ContextT | None:
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
async def after_workspace_move(
|
|
264
|
+
self,
|
|
265
|
+
call_id: str,
|
|
266
|
+
context: ContextT,
|
|
267
|
+
result: PathOperationResult,
|
|
268
|
+
) -> str | None:
|
|
269
|
+
return None
|
|
270
|
+
|
|
271
|
+
async def before_workspace_delete(
|
|
272
|
+
self,
|
|
273
|
+
call_id: str,
|
|
274
|
+
path: WorkspacePath,
|
|
275
|
+
) -> ContextT | None:
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
async def after_workspace_delete(
|
|
279
|
+
self,
|
|
280
|
+
call_id: str,
|
|
281
|
+
context: ContextT,
|
|
282
|
+
result: PathOperationResult,
|
|
283
|
+
) -> str | None:
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
async def before_workspace_mkdir(
|
|
287
|
+
self,
|
|
288
|
+
call_id: str,
|
|
289
|
+
path: WorkspacePath,
|
|
290
|
+
) -> ContextT | None:
|
|
291
|
+
return None
|
|
292
|
+
|
|
293
|
+
async def after_workspace_mkdir(
|
|
294
|
+
self,
|
|
295
|
+
call_id: str,
|
|
296
|
+
context: ContextT,
|
|
297
|
+
result: PathOperationResult,
|
|
298
|
+
) -> str | None:
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
async def error(self, call_id: str, context: ContextT) -> None:
|
|
302
|
+
pass
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@contextmanager
|
|
306
|
+
def filesystem_errors(path: str | WorkspacePath) -> Generator[None]:
|
|
307
|
+
"""Translate expected failures without exposing absolute filesystem paths."""
|
|
308
|
+
try:
|
|
309
|
+
yield
|
|
310
|
+
except UnicodeError as error:
|
|
311
|
+
raise WorkspaceError(f"{path}: expected valid UTF-8 text.") from error
|
|
312
|
+
except OSError as error:
|
|
313
|
+
raise WorkspaceError(
|
|
314
|
+
f"{path}: {error.strerror or type(error).__name__}."
|
|
315
|
+
) from error
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def read_text(candidate: Path, path: WorkspacePath) -> str:
|
|
319
|
+
"""Read complete UTF-8 text without newline conversion for Workspace and diff."""
|
|
320
|
+
with filesystem_errors(path):
|
|
321
|
+
if not candidate.is_file():
|
|
322
|
+
raise WorkspaceError(f"{path}: expected an existing regular file.")
|
|
323
|
+
text = candidate.read_bytes().decode("utf-8")
|
|
324
|
+
if "\0" in text:
|
|
325
|
+
raise WorkspaceError(f"{path}: expected UTF-8 text, found a binary file.")
|
|
326
|
+
return text
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def replace_text(path: Path, text: str) -> None:
|
|
330
|
+
"""Write UTF-8 without newline translation, then replace the destination."""
|
|
331
|
+
data = text.encode("utf-8")
|
|
332
|
+
temporary: Path | None = None
|
|
333
|
+
try:
|
|
334
|
+
with tempfile.NamedTemporaryFile(
|
|
335
|
+
dir=path.parent,
|
|
336
|
+
prefix=".forgemcp-",
|
|
337
|
+
delete=False,
|
|
338
|
+
) as stream:
|
|
339
|
+
temporary = Path(stream.name)
|
|
340
|
+
stream.write(data)
|
|
341
|
+
if path.exists():
|
|
342
|
+
temporary.chmod(stat.S_IMODE(path.stat().st_mode))
|
|
343
|
+
os.replace(temporary, path)
|
|
344
|
+
finally:
|
|
345
|
+
if temporary is not None and temporary.exists():
|
|
346
|
+
temporary.chmod(temporary.stat().st_mode | stat.S_IWUSR)
|
|
347
|
+
temporary.unlink(missing_ok=True)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
class WorkspaceService:
|
|
351
|
+
"""Workspace: access and modify project files and separate service storage.
|
|
352
|
+
|
|
353
|
+
Paths use / separators: project/... is relative to the project root,
|
|
354
|
+
storage/... to the storage root. Neither permits . or .. segments.
|
|
355
|
+
root/<absolute-path> identifies an external file; each explicit read requires
|
|
356
|
+
approval, and writes and file mirror resources are unavailable there.
|
|
357
|
+
|
|
358
|
+
Text is UTF-8; file resources mirror text or raw bytes. Searches skip dot
|
|
359
|
+
directories and links. Read existing files before editing. Protected paths
|
|
360
|
+
remain readable but cannot be modified.
|
|
361
|
+
"""
|
|
362
|
+
|
|
363
|
+
TREE_WIDGET = Widget("assets/workspace-tree.html")
|
|
364
|
+
FILE_WIDGET = Widget("assets/workspace-file.html")
|
|
365
|
+
SEARCH_WIDGET = Widget("assets/workspace-search.html")
|
|
366
|
+
RESULT_WIDGET = Widget("assets/workspace-result.html")
|
|
367
|
+
ICON = IconFile("icons/workspace.svg")
|
|
368
|
+
FILE_ICON = IconFile("icons/workspace-file.svg")
|
|
369
|
+
SEARCH_ICON = IconFile("icons/workspace-search.svg")
|
|
370
|
+
EDIT_ICON = IconFile("icons/workspace-edit.svg")
|
|
371
|
+
FILE_URI = "forgemcp://workspace/file{/path*}"
|
|
372
|
+
RAW_URI = "forgemcp://workspace/raw{/path*}"
|
|
373
|
+
LIST_URI = "forgemcp://workspace/list{?path,depth,include_hidden}"
|
|
374
|
+
FIND_URI = "forgemcp://workspace/find-files{?pattern,path}"
|
|
375
|
+
INFO_URI = "forgemcp://workspace/file-info{?path}"
|
|
376
|
+
SEARCH_URI = "forgemcp://workspace/search{?query,path,regex,extensions,case_sensitive}"
|
|
377
|
+
RESULT_JSON_URI = "forgemcp://workspace/results/{result_id}/{name}.json"
|
|
378
|
+
RESULT_MARKDOWN_URI = "forgemcp://workspace/results/{result_id}/{name}.md"
|
|
379
|
+
PROVIDER_TOOLS = frozenset(
|
|
380
|
+
(
|
|
381
|
+
"workspace_list",
|
|
382
|
+
"workspace_find_files",
|
|
383
|
+
"workspace_file_info",
|
|
384
|
+
"workspace_read_file",
|
|
385
|
+
"workspace_search",
|
|
386
|
+
"workspace_write_file",
|
|
387
|
+
"workspace_edit_file",
|
|
388
|
+
"workspace_move",
|
|
389
|
+
"workspace_delete",
|
|
390
|
+
"workspace_mkdir",
|
|
391
|
+
),
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
def __init__(
|
|
395
|
+
self,
|
|
396
|
+
workspace_root: Path,
|
|
397
|
+
storage_root: Path | None = None,
|
|
398
|
+
*,
|
|
399
|
+
progress_interval: float = 1.0,
|
|
400
|
+
) -> None:
|
|
401
|
+
self.progress_interval = progress_interval
|
|
402
|
+
with filesystem_errors("project"):
|
|
403
|
+
self.root = workspace_root.resolve()
|
|
404
|
+
if not self.root.is_dir():
|
|
405
|
+
raise WorkspaceError("Project root must be an existing directory.")
|
|
406
|
+
self.storage_root = (
|
|
407
|
+
storage_root or self.root.parent / f".{self.root.name}.forgemcp"
|
|
408
|
+
).resolve()
|
|
409
|
+
if self.root.is_relative_to(self.storage_root):
|
|
410
|
+
raise WorkspaceError(
|
|
411
|
+
"Storage must not be the project root or its ancestor."
|
|
412
|
+
)
|
|
413
|
+
if self.storage_root.exists() and not self.storage_root.is_dir():
|
|
414
|
+
raise WorkspaceError("Storage root must be a directory.")
|
|
415
|
+
self.protected_paths: set[Path] = set()
|
|
416
|
+
self.result_providers: dict[
|
|
417
|
+
str,
|
|
418
|
+
tuple[ResultProvider, frozenset[str] | None],
|
|
419
|
+
] = {}
|
|
420
|
+
self.result_resources: dict[str, dict[str, tuple[str, str]]] = {}
|
|
421
|
+
self.operation_lock = asyncio.Lock()
|
|
422
|
+
self.operation_owner: asyncio.Task[object] | None = None
|
|
423
|
+
|
|
424
|
+
@asynccontextmanager
|
|
425
|
+
async def serialized_operation(self) -> AsyncGenerator[None]:
|
|
426
|
+
"""Serialize Workspace entrypoints, allowing same-task resource delegation."""
|
|
427
|
+
task = asyncio.current_task()
|
|
428
|
+
if task is self.operation_owner:
|
|
429
|
+
yield
|
|
430
|
+
return
|
|
431
|
+
async with self.operation_lock:
|
|
432
|
+
self.operation_owner = task
|
|
433
|
+
try:
|
|
434
|
+
yield
|
|
435
|
+
finally:
|
|
436
|
+
self.operation_owner = None
|
|
437
|
+
|
|
438
|
+
def read_result_resource(self, result_id: str, name: str) -> str:
|
|
439
|
+
"""Return an immutable resource without invoking its provider again."""
|
|
440
|
+
try:
|
|
441
|
+
return self.result_resources[result_id][name][1]
|
|
442
|
+
except KeyError as error:
|
|
443
|
+
raise WorkspaceError("Workspace result resource does not exist.") from error
|
|
444
|
+
|
|
445
|
+
def register_provider(
|
|
446
|
+
self,
|
|
447
|
+
name: str,
|
|
448
|
+
provider: ResultProvider,
|
|
449
|
+
*,
|
|
450
|
+
tools: Sequence[str] | None = None,
|
|
451
|
+
) -> None:
|
|
452
|
+
"""Register a before/after/error observer for selected Workspace tools."""
|
|
453
|
+
if (
|
|
454
|
+
not name
|
|
455
|
+
or not name.isascii()
|
|
456
|
+
or any(not (char.isalnum() or char in "_-") for char in name)
|
|
457
|
+
or name in self.result_providers
|
|
458
|
+
):
|
|
459
|
+
raise WorkspaceError("Provider needs a unique ASCII name.")
|
|
460
|
+
if tools is not None and any(tool not in self.PROVIDER_TOOLS for tool in tools):
|
|
461
|
+
raise WorkspaceError("Provider names an unknown Workspace tool.")
|
|
462
|
+
self.result_providers[name] = (
|
|
463
|
+
provider,
|
|
464
|
+
frozenset(tools) if tools is not None else None,
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
def save_result_resource(
|
|
468
|
+
self,
|
|
469
|
+
call_id: str,
|
|
470
|
+
provider: str,
|
|
471
|
+
mime_type: Literal["application/json", "text/markdown"],
|
|
472
|
+
text: str,
|
|
473
|
+
) -> str:
|
|
474
|
+
"""Store one immutable resource for a provider under the tool call ID."""
|
|
475
|
+
if len(call_id) != 32 or any(char not in "0123456789abcdef" for char in call_id):
|
|
476
|
+
raise WorkspaceError("Invalid Workspace call ID.")
|
|
477
|
+
if provider not in self.result_providers:
|
|
478
|
+
raise WorkspaceError("Unknown Workspace result provider.")
|
|
479
|
+
if not isinstance(text, str):
|
|
480
|
+
raise WorkspaceError("Result resource must contain text.")
|
|
481
|
+
if mime_type == "application/json":
|
|
482
|
+
try:
|
|
483
|
+
json.loads(text)
|
|
484
|
+
except ValueError as error:
|
|
485
|
+
raise WorkspaceError("Result resource must contain valid JSON.") from error
|
|
486
|
+
suffix = ".json"
|
|
487
|
+
elif mime_type == "text/markdown":
|
|
488
|
+
suffix = ".md"
|
|
489
|
+
else:
|
|
490
|
+
raise WorkspaceError("Result resource must be JSON or Markdown.")
|
|
491
|
+
stored = self.result_resources.setdefault(call_id, {})
|
|
492
|
+
if any(f"{provider}{extension}" in stored for extension in (".json", ".md")):
|
|
493
|
+
raise WorkspaceError("Provider already saved a resource for this call.")
|
|
494
|
+
name = provider + suffix
|
|
495
|
+
stored[name] = (mime_type, text)
|
|
496
|
+
return f"forgemcp://workspace/results/{call_id}/{name}"
|
|
497
|
+
|
|
498
|
+
async def before_providers(
|
|
499
|
+
self,
|
|
500
|
+
tool_name: str,
|
|
501
|
+
parameters: Mapping[str, object],
|
|
502
|
+
) -> ProviderCall:
|
|
503
|
+
"""Start every eligible provider with the same randomly generated ID."""
|
|
504
|
+
if tool_name not in self.PROVIDER_TOOLS:
|
|
505
|
+
raise WorkspaceError("Unknown Workspace provider operation.")
|
|
506
|
+
call_id = uuid4().hex
|
|
507
|
+
providers = {
|
|
508
|
+
name: provider
|
|
509
|
+
for name, (provider, tools) in self.result_providers.items()
|
|
510
|
+
if tools is None or tool_name in tools
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
completed: dict[str, object] = {}
|
|
514
|
+
|
|
515
|
+
async def run(name: str, provider: ResultProvider) -> tuple[object]:
|
|
516
|
+
# Keep arbitrary context values distinct from gather's exceptions.
|
|
517
|
+
method = getattr(provider, f"before_{tool_name}")
|
|
518
|
+
context = await method(
|
|
519
|
+
call_id,
|
|
520
|
+
**deepcopy(dict(parameters)),
|
|
521
|
+
)
|
|
522
|
+
if context is not None:
|
|
523
|
+
completed[name] = context
|
|
524
|
+
return (context,)
|
|
525
|
+
|
|
526
|
+
try:
|
|
527
|
+
outcomes = await asyncio.gather(
|
|
528
|
+
*(run(name, provider) for name, provider in providers.items()),
|
|
529
|
+
return_exceptions=True,
|
|
530
|
+
)
|
|
531
|
+
except BaseException:
|
|
532
|
+
# Completed before hooks may hold locks until after/error, even if
|
|
533
|
+
# another provider is still running when this call is cancelled.
|
|
534
|
+
await self.error_providers(
|
|
535
|
+
ProviderCall(call_id, tool_name, MappingProxyType(completed)),
|
|
536
|
+
)
|
|
537
|
+
raise
|
|
538
|
+
contexts = {}
|
|
539
|
+
for name, outcome in zip(providers, outcomes):
|
|
540
|
+
if isinstance(outcome, BaseException):
|
|
541
|
+
logging.getLogger(__name__).warning("Workspace provider before failed: %s", name)
|
|
542
|
+
elif outcome[0] is not None:
|
|
543
|
+
contexts[name] = outcome[0]
|
|
544
|
+
return ProviderCall(call_id, tool_name, MappingProxyType(contexts))
|
|
545
|
+
|
|
546
|
+
async def after_providers(
|
|
547
|
+
self,
|
|
548
|
+
call: ProviderCall,
|
|
549
|
+
result: BaseModel,
|
|
550
|
+
) -> ResultResources:
|
|
551
|
+
"""Wait for all successful before providers and attach their saved URIs."""
|
|
552
|
+
names = list(call.contexts)
|
|
553
|
+
finished: set[str] = set()
|
|
554
|
+
|
|
555
|
+
async def run(name: str) -> str | None:
|
|
556
|
+
try:
|
|
557
|
+
provider = self.result_providers[name][0]
|
|
558
|
+
method = getattr(provider, f"after_{call.tool_name}")
|
|
559
|
+
return await method(
|
|
560
|
+
call.id,
|
|
561
|
+
call.contexts[name],
|
|
562
|
+
result.model_copy(deep=True),
|
|
563
|
+
)
|
|
564
|
+
finally:
|
|
565
|
+
finished.add(name)
|
|
566
|
+
|
|
567
|
+
try:
|
|
568
|
+
outcomes = await asyncio.gather(
|
|
569
|
+
*(run(name) for name in names),
|
|
570
|
+
return_exceptions=True,
|
|
571
|
+
)
|
|
572
|
+
except BaseException:
|
|
573
|
+
# Cancellation can prevent an after task from starting at all.
|
|
574
|
+
await self.error_providers(
|
|
575
|
+
ProviderCall(
|
|
576
|
+
call.id,
|
|
577
|
+
call.tool_name,
|
|
578
|
+
MappingProxyType({
|
|
579
|
+
name: call.contexts[name]
|
|
580
|
+
for name in names
|
|
581
|
+
if name not in finished
|
|
582
|
+
}),
|
|
583
|
+
),
|
|
584
|
+
)
|
|
585
|
+
raise
|
|
586
|
+
stored = self.result_resources.get(call.id, {})
|
|
587
|
+
links = {}
|
|
588
|
+
for name, outcome in zip(names, outcomes):
|
|
589
|
+
if isinstance(outcome, BaseException):
|
|
590
|
+
logging.getLogger(__name__).warning("Workspace provider after failed: %s", name)
|
|
591
|
+
continue
|
|
592
|
+
expected = {
|
|
593
|
+
f"forgemcp://workspace/results/{call.id}/{name}.json",
|
|
594
|
+
f"forgemcp://workspace/results/{call.id}/{name}.md",
|
|
595
|
+
}
|
|
596
|
+
if outcome is None:
|
|
597
|
+
continue
|
|
598
|
+
if (
|
|
599
|
+
not isinstance(outcome, str)
|
|
600
|
+
or outcome not in expected
|
|
601
|
+
or outcome.rsplit("/", 1)[-1] not in stored
|
|
602
|
+
):
|
|
603
|
+
logging.getLogger(__name__).warning(
|
|
604
|
+
"Workspace provider returned no saved URI: %s",
|
|
605
|
+
name,
|
|
606
|
+
)
|
|
607
|
+
continue
|
|
608
|
+
filename = outcome.rsplit("/", 1)[-1]
|
|
609
|
+
links[name] = ResultResource(uri=outcome, mime_type=stored[filename][0])
|
|
610
|
+
if links:
|
|
611
|
+
kept = {link.uri.rsplit("/", 1)[-1] for link in links.values()}
|
|
612
|
+
self.result_resources[call.id] = {
|
|
613
|
+
name: value for name, value in stored.items() if name in kept
|
|
614
|
+
}
|
|
615
|
+
else:
|
|
616
|
+
self.result_resources.pop(call.id, None)
|
|
617
|
+
return ResultResources(resources=links)
|
|
618
|
+
|
|
619
|
+
async def error_providers(self, call: ProviderCall) -> None:
|
|
620
|
+
"""Tell providers with a context that the Workspace tool failed."""
|
|
621
|
+
|
|
622
|
+
async def run(name: str) -> None:
|
|
623
|
+
provider = self.result_providers[name][0]
|
|
624
|
+
await provider.error(call.id, call.contexts[name])
|
|
625
|
+
|
|
626
|
+
try:
|
|
627
|
+
outcomes = await asyncio.gather(
|
|
628
|
+
*(run(name) for name in call.contexts),
|
|
629
|
+
return_exceptions=True,
|
|
630
|
+
)
|
|
631
|
+
for name, outcome in zip(call.contexts, outcomes):
|
|
632
|
+
if isinstance(outcome, BaseException):
|
|
633
|
+
logging.getLogger(__name__).warning("Workspace provider error failed: %s", name)
|
|
634
|
+
finally:
|
|
635
|
+
self.result_resources.pop(call.id, None)
|
|
636
|
+
|
|
637
|
+
def root_path(self, root: WorkspaceRoot) -> Path:
|
|
638
|
+
if root == "project":
|
|
639
|
+
return self.root
|
|
640
|
+
if root == "storage":
|
|
641
|
+
return self.storage_root
|
|
642
|
+
raise WorkspaceError("Root must be 'project' or 'storage'.")
|
|
643
|
+
|
|
644
|
+
def resolve_path(self, path: str, *, root: WorkspaceRoot = "project") -> Path:
|
|
645
|
+
"""Return a checked absolute path, retaining its lexical link components."""
|
|
646
|
+
if not path or "\0" in path:
|
|
647
|
+
raise WorkspaceError("Path must be a nonempty relative path.")
|
|
648
|
+
portable = PurePosixPath(path.replace("\\", "/"))
|
|
649
|
+
windows = PureWindowsPath(path)
|
|
650
|
+
if (
|
|
651
|
+
portable.is_absolute()
|
|
652
|
+
or windows.drive
|
|
653
|
+
or windows.root
|
|
654
|
+
or ".." in portable.parts
|
|
655
|
+
):
|
|
656
|
+
raise WorkspaceError("Path must be relative and cannot contain '..'.")
|
|
657
|
+
if os.name == "nt" and any(os.path.isreserved(part) for part in portable.parts):
|
|
658
|
+
raise WorkspaceError("Path contains a reserved Windows name.")
|
|
659
|
+
base = self.root_path(root)
|
|
660
|
+
candidate = base.joinpath(*portable.parts)
|
|
661
|
+
with filesystem_errors(path):
|
|
662
|
+
if not candidate.resolve().is_relative_to(base):
|
|
663
|
+
raise WorkspaceError("Path resolves outside the selected root.")
|
|
664
|
+
return candidate
|
|
665
|
+
|
|
666
|
+
def relative_path(self, path: Path, *, root: WorkspaceRoot = "project") -> str:
|
|
667
|
+
try:
|
|
668
|
+
relative = path.relative_to(self.root_path(root)).as_posix()
|
|
669
|
+
except ValueError as error:
|
|
670
|
+
raise WorkspaceError("Path is outside the selected root.") from error
|
|
671
|
+
self.resolve_path(relative, root=root)
|
|
672
|
+
return relative
|
|
673
|
+
|
|
674
|
+
def is_link(self, path: Path) -> bool:
|
|
675
|
+
return path.is_symlink() or path.is_junction()
|
|
676
|
+
|
|
677
|
+
def check_path_links(self, path: Path, root: WorkspaceRoot) -> None:
|
|
678
|
+
base = self.root_path(root)
|
|
679
|
+
current = path
|
|
680
|
+
while True:
|
|
681
|
+
if self.is_link(current):
|
|
682
|
+
raise WorkspaceError(
|
|
683
|
+
"This operation cannot traverse symbolic links or junctions."
|
|
684
|
+
)
|
|
685
|
+
if current == base:
|
|
686
|
+
break
|
|
687
|
+
current = current.parent
|
|
688
|
+
|
|
689
|
+
def check_tree_links(self, path: Path) -> None:
|
|
690
|
+
for directory, dirs, files in os.walk(
|
|
691
|
+
path,
|
|
692
|
+
followlinks=False,
|
|
693
|
+
onerror=self.raise_walk_error,
|
|
694
|
+
):
|
|
695
|
+
if any(self.is_link(Path(directory) / name) for name in [*dirs, *files]):
|
|
696
|
+
raise WorkspaceError(
|
|
697
|
+
"This operation cannot move or remove a tree containing links."
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
def protect_path(self, path: WorkspacePath) -> None:
|
|
701
|
+
candidate = self.resolve_workspace_path(path)
|
|
702
|
+
self.protected_paths.update((candidate, candidate.resolve()))
|
|
703
|
+
|
|
704
|
+
def writable_path(
|
|
705
|
+
self,
|
|
706
|
+
path: str,
|
|
707
|
+
*,
|
|
708
|
+
root: WorkspaceRoot,
|
|
709
|
+
subtree: bool = False,
|
|
710
|
+
) -> Path:
|
|
711
|
+
candidate = self.resolve_path(path, root=root)
|
|
712
|
+
self.check_path_links(candidate, root)
|
|
713
|
+
if candidate in (self.root, self.storage_root):
|
|
714
|
+
raise WorkspaceError("Cannot change a workspace root.")
|
|
715
|
+
for protected in self.protected_paths:
|
|
716
|
+
if candidate.is_relative_to(protected) or (
|
|
717
|
+
subtree and protected.is_relative_to(candidate)
|
|
718
|
+
):
|
|
719
|
+
raise WorkspaceError(f"{path}: path is protected.")
|
|
720
|
+
if subtree and self.storage_root.is_relative_to(candidate):
|
|
721
|
+
raise WorkspaceError("Cannot move or remove a parent of the storage root.")
|
|
722
|
+
return candidate
|
|
723
|
+
|
|
724
|
+
def resolve_workspace_path(self, path: WorkspacePath) -> Path:
|
|
725
|
+
return self.resolve_path(path.relative, root=path.area)
|
|
726
|
+
|
|
727
|
+
def qualified_path(self, root: str, path: str) -> WorkspacePath:
|
|
728
|
+
"""Adapt a resource URI's root/path pair to the shared path type."""
|
|
729
|
+
self.root_path(root)
|
|
730
|
+
if not path:
|
|
731
|
+
raise WorkspaceError("Path must not be empty.")
|
|
732
|
+
try:
|
|
733
|
+
return WorkspacePath(f"{root}/{'' if path == '.' else path}")
|
|
734
|
+
except ValueError as error:
|
|
735
|
+
raise WorkspaceError("Expected a canonical relative workspace path.") from error
|
|
736
|
+
|
|
737
|
+
def workspace_path(self, path: Path) -> WorkspacePath:
|
|
738
|
+
"""Represent a native path without granting permission to access it."""
|
|
739
|
+
candidate = path.absolute()
|
|
740
|
+
for area in ("storage", "project"):
|
|
741
|
+
base = self.root_path(area)
|
|
742
|
+
if candidate.is_relative_to(base):
|
|
743
|
+
relative = self.relative_path(candidate, root=area)
|
|
744
|
+
return WorkspacePath(f"{area}/{'' if relative == '.' else relative}")
|
|
745
|
+
return WorkspacePath("root/" + candidate.as_posix())
|
|
746
|
+
|
|
747
|
+
def require_file(self, path: str, root: WorkspaceRoot) -> Path:
|
|
748
|
+
candidate = self.resolve_path(path, root=root)
|
|
749
|
+
if not candidate.is_file():
|
|
750
|
+
raise WorkspaceError(f"{path}: expected an existing regular file.")
|
|
751
|
+
return candidate
|
|
752
|
+
|
|
753
|
+
def raise_walk_error(self, error: OSError) -> None:
|
|
754
|
+
raise error
|
|
755
|
+
|
|
756
|
+
async def iter_files(self, path: str, root: WorkspaceRoot) -> AsyncGenerator[Path]:
|
|
757
|
+
start = self.resolve_path(path, root=root)
|
|
758
|
+
self.check_path_links(start, root)
|
|
759
|
+
relative = start.relative_to(self.root_path(root))
|
|
760
|
+
directory_parts = relative.parts if start.is_dir() else relative.parts[:-1]
|
|
761
|
+
if any(part.startswith(".") for part in directory_parts):
|
|
762
|
+
return
|
|
763
|
+
if start.is_file():
|
|
764
|
+
yield start
|
|
765
|
+
return
|
|
766
|
+
if not start.is_dir():
|
|
767
|
+
raise WorkspaceError(f"{path}: expected an existing file or directory.")
|
|
768
|
+
for directory, dirs, files in os.walk(
|
|
769
|
+
start,
|
|
770
|
+
followlinks=False,
|
|
771
|
+
onerror=self.raise_walk_error,
|
|
772
|
+
):
|
|
773
|
+
await asyncio.sleep(0)
|
|
774
|
+
parent = Path(directory)
|
|
775
|
+
dirs[:] = sorted(
|
|
776
|
+
name
|
|
777
|
+
for name in dirs
|
|
778
|
+
if not name.startswith(".") and not self.is_link(parent / name)
|
|
779
|
+
)
|
|
780
|
+
for name in sorted(files):
|
|
781
|
+
child = parent / name
|
|
782
|
+
if not self.is_link(child) and child.is_file():
|
|
783
|
+
yield child
|
|
784
|
+
await asyncio.sleep(0)
|
|
785
|
+
|
|
786
|
+
def extension_matches(self, path: Path, extensions: Sequence[str] | None) -> bool:
|
|
787
|
+
return extensions is None or path.suffix.removeprefix(".").lower() in {
|
|
788
|
+
extension.removeprefix(".").lower() for extension in extensions
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
def file_uri(self, path: WorkspacePath) -> str:
|
|
792
|
+
if path.area == "root":
|
|
793
|
+
raise WorkspaceError("External files have no workspace mirror resources.")
|
|
794
|
+
return f"forgemcp://workspace/file/{quote(str(path), safe='/')}"
|
|
795
|
+
|
|
796
|
+
def render_markdown(
|
|
797
|
+
self,
|
|
798
|
+
result: DirectoryTree | FilePaths | FileInfo | SearchResult,
|
|
799
|
+
) -> str:
|
|
800
|
+
"""Render the same business results served by tools as Markdown resources."""
|
|
801
|
+
nodes: list[markdown.Node] = []
|
|
802
|
+
if isinstance(result, DirectoryTree):
|
|
803
|
+
lines = [str(result.path)]
|
|
804
|
+
|
|
805
|
+
def visit(entries: list[TreeEntry], prefix: str = "") -> None:
|
|
806
|
+
for index, entry in enumerate(entries):
|
|
807
|
+
last = index == len(entries) - 1
|
|
808
|
+
suffix = (
|
|
809
|
+
"/"
|
|
810
|
+
if entry.kind == "directory"
|
|
811
|
+
else " @" if entry.kind == "symlink" else ""
|
|
812
|
+
)
|
|
813
|
+
lines.append(
|
|
814
|
+
f"{prefix}{'└── ' if last else '├── '}{PurePosixPath(entry.path.relative).name}{suffix}"
|
|
815
|
+
)
|
|
816
|
+
if entry.children is not None:
|
|
817
|
+
visit(entry.children, prefix + (" " if last else "│ "))
|
|
818
|
+
|
|
819
|
+
visit(result.entries)
|
|
820
|
+
nodes.extend(
|
|
821
|
+
[
|
|
822
|
+
markdown.Heading("Directory tree"),
|
|
823
|
+
markdown.CodeBlock("\n".join(lines)),
|
|
824
|
+
]
|
|
825
|
+
)
|
|
826
|
+
elif isinstance(result, FilePaths):
|
|
827
|
+
nodes.extend(
|
|
828
|
+
[
|
|
829
|
+
markdown.Heading("Files"),
|
|
830
|
+
markdown.UnorderedList(
|
|
831
|
+
markdown.Link(str(path), self.file_uri(path))
|
|
832
|
+
for path in result.paths
|
|
833
|
+
),
|
|
834
|
+
]
|
|
835
|
+
)
|
|
836
|
+
elif isinstance(result, FileInfo):
|
|
837
|
+
nodes.append(markdown.Heading("File information"))
|
|
838
|
+
nodes.append(
|
|
839
|
+
markdown.Table(
|
|
840
|
+
["Field", "Value"],
|
|
841
|
+
[
|
|
842
|
+
[key, str(value) if value is not None else "Unavailable"]
|
|
843
|
+
for key, value in result.model_dump(mode="json").items()
|
|
844
|
+
],
|
|
845
|
+
)
|
|
846
|
+
)
|
|
847
|
+
else:
|
|
848
|
+
nodes.append(markdown.Heading("Search results"))
|
|
849
|
+
for match in result.matches:
|
|
850
|
+
nodes.extend(
|
|
851
|
+
[
|
|
852
|
+
markdown.Paragraph(
|
|
853
|
+
f"{markdown.Link(str(match.path), self.file_uri(match.path))}, line {match.line}"
|
|
854
|
+
),
|
|
855
|
+
markdown.CodeBlock(match.text),
|
|
856
|
+
]
|
|
857
|
+
)
|
|
858
|
+
if not result.matches:
|
|
859
|
+
nodes.append(markdown.Paragraph("No matches."))
|
|
860
|
+
nodes.extend(
|
|
861
|
+
[
|
|
862
|
+
markdown.Heading("Skipped files", level=2),
|
|
863
|
+
markdown.UnorderedList(str(path) for path in result.skipped_files),
|
|
864
|
+
]
|
|
865
|
+
)
|
|
866
|
+
return markdown.Document(nodes).render()
|
|
867
|
+
|
|
868
|
+
def register(self, mcp: MCPServer, apps: Apps, complete: Complete) -> None:
|
|
869
|
+
"""Register file tools, mirrors, Markdown resources, and completions."""
|
|
870
|
+
|
|
871
|
+
def serialized[**P, R](
|
|
872
|
+
handler: Callable[P, Awaitable[R]],
|
|
873
|
+
) -> Callable[P, Awaitable[R]]:
|
|
874
|
+
@wraps(handler)
|
|
875
|
+
async def wrapped(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
876
|
+
async with self.serialized_operation():
|
|
877
|
+
return await handler(*args, **kwargs)
|
|
878
|
+
|
|
879
|
+
return wrapped
|
|
880
|
+
|
|
881
|
+
read_only = ToolAnnotations(
|
|
882
|
+
read_only_hint=True,
|
|
883
|
+
destructive_hint=False,
|
|
884
|
+
open_world_hint=False,
|
|
885
|
+
)
|
|
886
|
+
modifying = ToolAnnotations(
|
|
887
|
+
read_only_hint=False,
|
|
888
|
+
destructive_hint=True,
|
|
889
|
+
open_world_hint=False,
|
|
890
|
+
)
|
|
891
|
+
|
|
892
|
+
@apps.tool(
|
|
893
|
+
resource_uri=self.TREE_WIDGET.uri,
|
|
894
|
+
icons=[self.ICON.icon],
|
|
895
|
+
annotations=read_only,
|
|
896
|
+
)
|
|
897
|
+
@serialized
|
|
898
|
+
async def workspace_list(
|
|
899
|
+
ctx: Context,
|
|
900
|
+
path: WorkspacePath = WorkspacePath("project/"),
|
|
901
|
+
depth: int | None = 1,
|
|
902
|
+
include_hidden: bool = False,
|
|
903
|
+
) -> DirectoryTree:
|
|
904
|
+
"""Show a directory tree; null depth expands every directory.
|
|
905
|
+
|
|
906
|
+
File paths are errors. include_hidden controls dot directories;
|
|
907
|
+
dot files remain visible and links are listed without traversal.
|
|
908
|
+
"""
|
|
909
|
+
report_progress = progress(ctx, interval=self.progress_interval)
|
|
910
|
+
call = await self.before_providers(
|
|
911
|
+
"workspace_list",
|
|
912
|
+
{
|
|
913
|
+
"path": path,
|
|
914
|
+
"depth": depth,
|
|
915
|
+
"include_hidden": include_hidden,
|
|
916
|
+
},
|
|
917
|
+
)
|
|
918
|
+
try:
|
|
919
|
+
visited = 0
|
|
920
|
+
await report_progress(0, message="Starting directory scan")
|
|
921
|
+
if depth is not None and depth < 1:
|
|
922
|
+
raise WorkspaceError(
|
|
923
|
+
"Depth must be positive or null for the complete tree."
|
|
924
|
+
)
|
|
925
|
+
directory = self.resolve_workspace_path(path)
|
|
926
|
+
self.check_path_links(directory, path.area)
|
|
927
|
+
if not directory.is_dir():
|
|
928
|
+
raise WorkspaceError(f"{path}: expected an existing directory.")
|
|
929
|
+
|
|
930
|
+
async def entries(
|
|
931
|
+
parent: Path,
|
|
932
|
+
remaining: int | None,
|
|
933
|
+
) -> list[TreeEntry]:
|
|
934
|
+
nonlocal visited
|
|
935
|
+
result: list[TreeEntry] = []
|
|
936
|
+
for child in sorted(parent.iterdir(), key=lambda item: item.name):
|
|
937
|
+
if (
|
|
938
|
+
not include_hidden
|
|
939
|
+
and child.name.startswith(".")
|
|
940
|
+
and child.is_dir()
|
|
941
|
+
):
|
|
942
|
+
continue
|
|
943
|
+
children = None
|
|
944
|
+
if self.is_link(child):
|
|
945
|
+
kind = "symlink"
|
|
946
|
+
elif child.is_dir():
|
|
947
|
+
kind = "directory"
|
|
948
|
+
if remaining is None or remaining > 1:
|
|
949
|
+
children = await entries(
|
|
950
|
+
child,
|
|
951
|
+
None if remaining is None else remaining - 1,
|
|
952
|
+
)
|
|
953
|
+
elif child.is_file():
|
|
954
|
+
kind = "file"
|
|
955
|
+
else:
|
|
956
|
+
kind = "other"
|
|
957
|
+
entry = TreeEntry(
|
|
958
|
+
path=self.qualified_path(
|
|
959
|
+
path.area,
|
|
960
|
+
child.relative_to(self.root_path(path.area)).as_posix(),
|
|
961
|
+
),
|
|
962
|
+
kind=kind,
|
|
963
|
+
children=children,
|
|
964
|
+
)
|
|
965
|
+
result.append(entry)
|
|
966
|
+
visited += 1
|
|
967
|
+
await report_progress(visited, message=f"process {entry.path}")
|
|
968
|
+
await asyncio.sleep(0)
|
|
969
|
+
return result
|
|
970
|
+
|
|
971
|
+
with filesystem_errors(path):
|
|
972
|
+
result = DirectoryTree(
|
|
973
|
+
path=path,
|
|
974
|
+
entries=await entries(directory, depth),
|
|
975
|
+
)
|
|
976
|
+
except BaseException as error:
|
|
977
|
+
await self.error_providers(call)
|
|
978
|
+
if isinstance(error, WorkspaceError):
|
|
979
|
+
raise ToolError(str(error)) from error
|
|
980
|
+
raise
|
|
981
|
+
result.resources = (await self.after_providers(call, result)).resources
|
|
982
|
+
return result
|
|
983
|
+
|
|
984
|
+
@apps.tool(
|
|
985
|
+
resource_uri=self.TREE_WIDGET.uri,
|
|
986
|
+
icons=[self.ICON.icon],
|
|
987
|
+
annotations=read_only,
|
|
988
|
+
)
|
|
989
|
+
@serialized
|
|
990
|
+
async def workspace_find_files(
|
|
991
|
+
ctx: Context,
|
|
992
|
+
pattern: str = "*",
|
|
993
|
+
path: WorkspacePath = WorkspacePath("project/"),
|
|
994
|
+
) -> FilePaths:
|
|
995
|
+
"""Find file paths recursively by case-sensitive glob, skipping dot directories and links.
|
|
996
|
+
|
|
997
|
+
Patterns without / match basenames; patterns with / match paths
|
|
998
|
+
relative to the search directory.
|
|
999
|
+
"""
|
|
1000
|
+
report_progress = progress(ctx, interval=self.progress_interval)
|
|
1001
|
+
call = await self.before_providers(
|
|
1002
|
+
"workspace_find_files",
|
|
1003
|
+
{
|
|
1004
|
+
"pattern": pattern,
|
|
1005
|
+
"path": path,
|
|
1006
|
+
},
|
|
1007
|
+
)
|
|
1008
|
+
try:
|
|
1009
|
+
visited = 0
|
|
1010
|
+
await report_progress(0, message="Files visited")
|
|
1011
|
+
start = self.resolve_workspace_path(path)
|
|
1012
|
+
base = start if start.is_dir() else start.parent
|
|
1013
|
+
if not pattern:
|
|
1014
|
+
raise WorkspaceError("File pattern must not be empty.")
|
|
1015
|
+
paths = []
|
|
1016
|
+
files = []
|
|
1017
|
+
with filesystem_errors(path):
|
|
1018
|
+
async for candidate in self.iter_files(path.relative, path.area):
|
|
1019
|
+
visited += 1
|
|
1020
|
+
await report_progress(visited, message="Files visited")
|
|
1021
|
+
target = candidate.relative_to(base).as_posix()
|
|
1022
|
+
matches = (
|
|
1023
|
+
PurePosixPath(target).full_match(
|
|
1024
|
+
pattern,
|
|
1025
|
+
case_sensitive=True,
|
|
1026
|
+
)
|
|
1027
|
+
if "/" in pattern
|
|
1028
|
+
else fnmatch.fnmatchcase(candidate.name, pattern)
|
|
1029
|
+
)
|
|
1030
|
+
if matches:
|
|
1031
|
+
found = self.qualified_path(
|
|
1032
|
+
path.area,
|
|
1033
|
+
self.relative_path(candidate, root=path.area),
|
|
1034
|
+
)
|
|
1035
|
+
paths.append(found)
|
|
1036
|
+
metadata = candidate.stat()
|
|
1037
|
+
files.append(
|
|
1038
|
+
FoundFile(
|
|
1039
|
+
path=found,
|
|
1040
|
+
modified_at=datetime.fromtimestamp(metadata.st_mtime, UTC),
|
|
1041
|
+
size_bytes=metadata.st_size,
|
|
1042
|
+
)
|
|
1043
|
+
)
|
|
1044
|
+
result = FilePaths(
|
|
1045
|
+
paths=sorted(paths, key=str),
|
|
1046
|
+
files=sorted(files, key=lambda file: str(file.path)),
|
|
1047
|
+
)
|
|
1048
|
+
except BaseException as error:
|
|
1049
|
+
await self.error_providers(call)
|
|
1050
|
+
if isinstance(error, WorkspaceError):
|
|
1051
|
+
raise ToolError(str(error)) from error
|
|
1052
|
+
raise
|
|
1053
|
+
result.resources = (await self.after_providers(call, result)).resources
|
|
1054
|
+
return result
|
|
1055
|
+
|
|
1056
|
+
@apps.tool(
|
|
1057
|
+
resource_uri=self.RESULT_WIDGET.uri,
|
|
1058
|
+
icons=[self.FILE_ICON.icon],
|
|
1059
|
+
annotations=read_only,
|
|
1060
|
+
)
|
|
1061
|
+
@serialized
|
|
1062
|
+
async def workspace_file_info(
|
|
1063
|
+
path: WorkspacePath,
|
|
1064
|
+
ctx: Context,
|
|
1065
|
+
) -> FileInfo:
|
|
1066
|
+
"""Read creation/modification times, byte size, and owner of one file."""
|
|
1067
|
+
report_progress = progress(ctx, interval=self.progress_interval)
|
|
1068
|
+
call = await self.before_providers("workspace_file_info", {"path": path})
|
|
1069
|
+
try:
|
|
1070
|
+
await report_progress(0, total=1, message="Starting file info")
|
|
1071
|
+
with filesystem_errors(path):
|
|
1072
|
+
candidate = self.require_file(path.relative, path.area)
|
|
1073
|
+
metadata = candidate.stat()
|
|
1074
|
+
birth = getattr(metadata, "st_birthtime", None)
|
|
1075
|
+
result = FileInfo(
|
|
1076
|
+
path=path,
|
|
1077
|
+
created_at=(
|
|
1078
|
+
datetime.fromtimestamp(birth, UTC) if birth is not None else None
|
|
1079
|
+
),
|
|
1080
|
+
modified_at=datetime.fromtimestamp(metadata.st_mtime, UTC),
|
|
1081
|
+
size_bytes=metadata.st_size,
|
|
1082
|
+
owner=file_owner(candidate),
|
|
1083
|
+
)
|
|
1084
|
+
except BaseException as error:
|
|
1085
|
+
await self.error_providers(call)
|
|
1086
|
+
if isinstance(error, WorkspaceError):
|
|
1087
|
+
raise ToolError(str(error)) from error
|
|
1088
|
+
raise
|
|
1089
|
+
result.resources = (await self.after_providers(call, result)).resources
|
|
1090
|
+
await report_progress(1, total=1, message="Completed file info")
|
|
1091
|
+
return result
|
|
1092
|
+
|
|
1093
|
+
@apps.tool(
|
|
1094
|
+
resource_uri=self.FILE_WIDGET.uri,
|
|
1095
|
+
icons=[self.FILE_ICON.icon],
|
|
1096
|
+
annotations=read_only,
|
|
1097
|
+
)
|
|
1098
|
+
@serialized
|
|
1099
|
+
async def workspace_read_file(
|
|
1100
|
+
path: WorkspacePath,
|
|
1101
|
+
ctx: Context,
|
|
1102
|
+
confirm: Annotated[ReadConfirmation, Resolve(confirm_read)],
|
|
1103
|
+
start_line: int = 1,
|
|
1104
|
+
end_line: int | None = None,
|
|
1105
|
+
) -> FileContent:
|
|
1106
|
+
"""Read UTF-8 text, optionally selecting an inclusive range of one-based lines."""
|
|
1107
|
+
report_progress = progress(ctx, interval=self.progress_interval)
|
|
1108
|
+
call = await self.before_providers(
|
|
1109
|
+
"workspace_read_file",
|
|
1110
|
+
{
|
|
1111
|
+
"path": path,
|
|
1112
|
+
"confirm": confirm,
|
|
1113
|
+
"start_line": start_line,
|
|
1114
|
+
"end_line": end_line,
|
|
1115
|
+
},
|
|
1116
|
+
)
|
|
1117
|
+
try:
|
|
1118
|
+
if start_line < 1 or (end_line is not None and end_line < start_line):
|
|
1119
|
+
raise WorkspaceError(
|
|
1120
|
+
"Line range must start at 1 or later and end at or after its start."
|
|
1121
|
+
)
|
|
1122
|
+
with filesystem_errors(path):
|
|
1123
|
+
if not confirm.allow:
|
|
1124
|
+
raise WorkspaceError("External file reading was not permitted.")
|
|
1125
|
+
candidate = (
|
|
1126
|
+
path.absolute if path.area == "root"
|
|
1127
|
+
else self.require_file(path.relative, path.area)
|
|
1128
|
+
)
|
|
1129
|
+
if not candidate.is_file():
|
|
1130
|
+
raise WorkspaceError(f"{path}: expected an existing regular file.")
|
|
1131
|
+
text = bytearray()
|
|
1132
|
+
await report_progress(0, message="Bytes read")
|
|
1133
|
+
with candidate.open("rb") as stream:
|
|
1134
|
+
while chunk := stream.read(64 * 1024):
|
|
1135
|
+
text.extend(chunk)
|
|
1136
|
+
await report_progress(len(text), message="Bytes read")
|
|
1137
|
+
await asyncio.sleep(0)
|
|
1138
|
+
decoded = text.decode("utf-8")
|
|
1139
|
+
if "\0" in decoded:
|
|
1140
|
+
raise WorkspaceError(
|
|
1141
|
+
f"{path}: expected UTF-8 text, found a binary file."
|
|
1142
|
+
)
|
|
1143
|
+
result = FileContent(
|
|
1144
|
+
path=path,
|
|
1145
|
+
text="".join(
|
|
1146
|
+
decoded.splitlines(keepends=True)[start_line - 1 : end_line]
|
|
1147
|
+
),
|
|
1148
|
+
start_line=start_line,
|
|
1149
|
+
)
|
|
1150
|
+
except BaseException as error:
|
|
1151
|
+
await self.error_providers(call)
|
|
1152
|
+
if isinstance(error, WorkspaceError):
|
|
1153
|
+
raise ToolError(str(error)) from error
|
|
1154
|
+
raise
|
|
1155
|
+
result.resources = (await self.after_providers(call, result)).resources
|
|
1156
|
+
return result
|
|
1157
|
+
|
|
1158
|
+
@apps.tool(
|
|
1159
|
+
resource_uri=self.SEARCH_WIDGET.uri,
|
|
1160
|
+
icons=[self.SEARCH_ICON.icon],
|
|
1161
|
+
annotations=read_only,
|
|
1162
|
+
)
|
|
1163
|
+
@serialized
|
|
1164
|
+
async def workspace_search(
|
|
1165
|
+
query: str,
|
|
1166
|
+
ctx: Context,
|
|
1167
|
+
path: WorkspacePath = WorkspacePath("project/"),
|
|
1168
|
+
regex: bool = False,
|
|
1169
|
+
extensions: list[str] | None = None,
|
|
1170
|
+
case_sensitive: bool = True,
|
|
1171
|
+
) -> SearchResult:
|
|
1172
|
+
"""Search lines by literal text or regex; report skipped binary/non-UTF-8 files.
|
|
1173
|
+
|
|
1174
|
+
extensions accepts suffixes with or without a leading dot;
|
|
1175
|
+
null selects all files, while an empty list selects none.
|
|
1176
|
+
"""
|
|
1177
|
+
report_progress = progress(ctx, interval=self.progress_interval)
|
|
1178
|
+
call = await self.before_providers(
|
|
1179
|
+
"workspace_search",
|
|
1180
|
+
{
|
|
1181
|
+
"query": query,
|
|
1182
|
+
"path": path,
|
|
1183
|
+
"regex": regex,
|
|
1184
|
+
"extensions": extensions,
|
|
1185
|
+
"case_sensitive": case_sensitive,
|
|
1186
|
+
},
|
|
1187
|
+
)
|
|
1188
|
+
try:
|
|
1189
|
+
visited = 0
|
|
1190
|
+
await report_progress(0, message="Files visited")
|
|
1191
|
+
if not query:
|
|
1192
|
+
raise WorkspaceError("Search query must not be empty.")
|
|
1193
|
+
try:
|
|
1194
|
+
expression = regex_engine.compile(
|
|
1195
|
+
query if regex else regex_engine.escape(query),
|
|
1196
|
+
flags=regex_engine.VERSION1
|
|
1197
|
+
| (0 if case_sensitive else regex_engine.IGNORECASE),
|
|
1198
|
+
)
|
|
1199
|
+
except regex_engine.error as error:
|
|
1200
|
+
raise WorkspaceError(
|
|
1201
|
+
f"Invalid regular expression: {error}"
|
|
1202
|
+
) from error
|
|
1203
|
+
matches, skipped = [], []
|
|
1204
|
+
with filesystem_errors(path):
|
|
1205
|
+
async for candidate in self.iter_files(path.relative, path.area):
|
|
1206
|
+
visited += 1
|
|
1207
|
+
current = self.qualified_path(
|
|
1208
|
+
path.area,
|
|
1209
|
+
self.relative_path(candidate, root=path.area),
|
|
1210
|
+
)
|
|
1211
|
+
await report_progress(visited, message=f"Searching {current}")
|
|
1212
|
+
if not self.extension_matches(candidate, extensions):
|
|
1213
|
+
continue
|
|
1214
|
+
relative = self.qualified_path(
|
|
1215
|
+
path.area,
|
|
1216
|
+
self.relative_path(candidate, root=path.area),
|
|
1217
|
+
)
|
|
1218
|
+
data = candidate.read_bytes()
|
|
1219
|
+
try:
|
|
1220
|
+
text = data.decode("utf-8")
|
|
1221
|
+
except UnicodeDecodeError:
|
|
1222
|
+
skipped.append(relative)
|
|
1223
|
+
continue
|
|
1224
|
+
if "\0" in text:
|
|
1225
|
+
skipped.append(relative)
|
|
1226
|
+
continue
|
|
1227
|
+
for line, value in enumerate(text.splitlines(), 1):
|
|
1228
|
+
if line % 256 == 0:
|
|
1229
|
+
await asyncio.sleep(0)
|
|
1230
|
+
spans = [
|
|
1231
|
+
match.span() for match in expression.finditer(value)
|
|
1232
|
+
]
|
|
1233
|
+
if spans:
|
|
1234
|
+
matches.append(
|
|
1235
|
+
SearchMatch(
|
|
1236
|
+
path=relative,
|
|
1237
|
+
line=line,
|
|
1238
|
+
text=value,
|
|
1239
|
+
spans=spans,
|
|
1240
|
+
)
|
|
1241
|
+
)
|
|
1242
|
+
result = SearchResult(
|
|
1243
|
+
matches=sorted(matches, key=lambda item: (str(item.path), item.line)),
|
|
1244
|
+
skipped_files=sorted(skipped, key=str),
|
|
1245
|
+
)
|
|
1246
|
+
except BaseException as error:
|
|
1247
|
+
await self.error_providers(call)
|
|
1248
|
+
if isinstance(error, WorkspaceError):
|
|
1249
|
+
raise ToolError(str(error)) from error
|
|
1250
|
+
raise
|
|
1251
|
+
result.resources = (await self.after_providers(call, result)).resources
|
|
1252
|
+
return result
|
|
1253
|
+
|
|
1254
|
+
@apps.tool(
|
|
1255
|
+
resource_uri=self.RESULT_WIDGET.uri,
|
|
1256
|
+
icons=[self.EDIT_ICON.icon],
|
|
1257
|
+
annotations=modifying,
|
|
1258
|
+
)
|
|
1259
|
+
@serialized
|
|
1260
|
+
async def workspace_write_file(
|
|
1261
|
+
path: WorkspacePath,
|
|
1262
|
+
text: str,
|
|
1263
|
+
ctx: Context,
|
|
1264
|
+
) -> FileWriteResult:
|
|
1265
|
+
"""Create or fully overwrite a UTF-8 file; its parent directory must exist.
|
|
1266
|
+
|
|
1267
|
+
Return removed/added line counts and linked change resources.
|
|
1268
|
+
"""
|
|
1269
|
+
report_progress = progress(ctx, interval=self.progress_interval)
|
|
1270
|
+
call = await self.before_providers(
|
|
1271
|
+
"workspace_write_file",
|
|
1272
|
+
{
|
|
1273
|
+
"path": path,
|
|
1274
|
+
"text": text,
|
|
1275
|
+
},
|
|
1276
|
+
)
|
|
1277
|
+
try:
|
|
1278
|
+
await report_progress(0, total=1, message="Starting write file")
|
|
1279
|
+
with filesystem_errors(path):
|
|
1280
|
+
candidate = self.writable_path(path.relative, root=path.area)
|
|
1281
|
+
existed = candidate.exists()
|
|
1282
|
+
previous = read_text(candidate, path) if existed else ""
|
|
1283
|
+
replace_text(candidate, text)
|
|
1284
|
+
result = FileWriteResult(
|
|
1285
|
+
path=path,
|
|
1286
|
+
action="overwritten" if existed else "created",
|
|
1287
|
+
lines_removed=len(previous.splitlines()),
|
|
1288
|
+
lines_added=len(text.splitlines()),
|
|
1289
|
+
)
|
|
1290
|
+
except BaseException as error:
|
|
1291
|
+
await self.error_providers(call)
|
|
1292
|
+
if isinstance(error, WorkspaceError):
|
|
1293
|
+
raise ToolError(str(error)) from error
|
|
1294
|
+
raise
|
|
1295
|
+
result.resources = (await self.after_providers(call, result)).resources
|
|
1296
|
+
await report_progress(1, total=1, message="Completed write file")
|
|
1297
|
+
return result
|
|
1298
|
+
|
|
1299
|
+
@apps.tool(
|
|
1300
|
+
resource_uri=self.RESULT_WIDGET.uri,
|
|
1301
|
+
icons=[self.EDIT_ICON.icon],
|
|
1302
|
+
annotations=modifying,
|
|
1303
|
+
)
|
|
1304
|
+
@serialized
|
|
1305
|
+
async def workspace_edit_file(
|
|
1306
|
+
path: WorkspacePath,
|
|
1307
|
+
old_text: str,
|
|
1308
|
+
new_text: str,
|
|
1309
|
+
ctx: Context,
|
|
1310
|
+
replace_all: bool = False,
|
|
1311
|
+
) -> FileEditResult:
|
|
1312
|
+
"""Replace one exact text occurrence, or all with replace_all=true.
|
|
1313
|
+
|
|
1314
|
+
old_text must be nonempty. Missing or ambiguous matches leave the
|
|
1315
|
+
file unchanged. Text and line endings outside replacements are preserved.
|
|
1316
|
+
"""
|
|
1317
|
+
report_progress = progress(ctx, interval=self.progress_interval)
|
|
1318
|
+
call = await self.before_providers(
|
|
1319
|
+
"workspace_edit_file",
|
|
1320
|
+
{
|
|
1321
|
+
"path": path,
|
|
1322
|
+
"old_text": old_text,
|
|
1323
|
+
"new_text": new_text,
|
|
1324
|
+
"replace_all": replace_all,
|
|
1325
|
+
},
|
|
1326
|
+
)
|
|
1327
|
+
try:
|
|
1328
|
+
await report_progress(0, total=1, message="Starting edit file")
|
|
1329
|
+
if not old_text:
|
|
1330
|
+
raise WorkspaceError("old_text must not be empty.")
|
|
1331
|
+
with filesystem_errors(path):
|
|
1332
|
+
candidate = self.writable_path(path.relative, root=path.area)
|
|
1333
|
+
text = read_text(candidate, path)
|
|
1334
|
+
count = text.count(old_text)
|
|
1335
|
+
if count == 0:
|
|
1336
|
+
raise WorkspaceError(
|
|
1337
|
+
"Exact text was not found; the file was not changed."
|
|
1338
|
+
)
|
|
1339
|
+
if count > 1 and not replace_all:
|
|
1340
|
+
raise WorkspaceError(
|
|
1341
|
+
f"Found {count} occurrences; use replace_all or a more specific old_text."
|
|
1342
|
+
)
|
|
1343
|
+
replace_text(candidate, text.replace(old_text, new_text))
|
|
1344
|
+
result = FileEditResult(
|
|
1345
|
+
path=path,
|
|
1346
|
+
replacements=count,
|
|
1347
|
+
)
|
|
1348
|
+
except BaseException as error:
|
|
1349
|
+
await self.error_providers(call)
|
|
1350
|
+
if isinstance(error, WorkspaceError):
|
|
1351
|
+
raise ToolError(str(error)) from error
|
|
1352
|
+
raise
|
|
1353
|
+
result.resources = (await self.after_providers(call, result)).resources
|
|
1354
|
+
await report_progress(1, total=1, message="Completed edit file")
|
|
1355
|
+
return result
|
|
1356
|
+
|
|
1357
|
+
@apps.tool(
|
|
1358
|
+
resource_uri=self.RESULT_WIDGET.uri,
|
|
1359
|
+
icons=[self.EDIT_ICON.icon],
|
|
1360
|
+
annotations=modifying,
|
|
1361
|
+
)
|
|
1362
|
+
@serialized
|
|
1363
|
+
async def workspace_move(
|
|
1364
|
+
source: WorkspacePath,
|
|
1365
|
+
destination: WorkspacePath,
|
|
1366
|
+
ctx: Context,
|
|
1367
|
+
) -> PathOperationResult:
|
|
1368
|
+
"""Move a file or directory within project/ or within storage/.
|
|
1369
|
+
|
|
1370
|
+
The destination must not exist, and its parent directory must exist.
|
|
1371
|
+
"""
|
|
1372
|
+
report_progress = progress(ctx, interval=self.progress_interval)
|
|
1373
|
+
call = await self.before_providers(
|
|
1374
|
+
"workspace_move",
|
|
1375
|
+
{
|
|
1376
|
+
"source": source,
|
|
1377
|
+
"destination": destination,
|
|
1378
|
+
},
|
|
1379
|
+
)
|
|
1380
|
+
try:
|
|
1381
|
+
await report_progress(0, total=1, message="Starting move")
|
|
1382
|
+
if source.area != destination.area:
|
|
1383
|
+
raise WorkspaceError("Move source and destination must use the same root.")
|
|
1384
|
+
with filesystem_errors(source):
|
|
1385
|
+
origin = self.writable_path(
|
|
1386
|
+
source.relative,
|
|
1387
|
+
root=source.area,
|
|
1388
|
+
subtree=True,
|
|
1389
|
+
)
|
|
1390
|
+
target = self.writable_path(
|
|
1391
|
+
destination.relative,
|
|
1392
|
+
root=destination.area,
|
|
1393
|
+
subtree=True,
|
|
1394
|
+
)
|
|
1395
|
+
if not origin.exists():
|
|
1396
|
+
raise WorkspaceError(f"{source}: path does not exist.")
|
|
1397
|
+
if target.exists() or target.is_symlink():
|
|
1398
|
+
raise WorkspaceError(f"{destination}: destination already exists.")
|
|
1399
|
+
if origin.is_dir():
|
|
1400
|
+
if target.is_relative_to(origin):
|
|
1401
|
+
raise WorkspaceError("Cannot move a directory into itself.")
|
|
1402
|
+
self.check_tree_links(origin)
|
|
1403
|
+
origin.rename(target)
|
|
1404
|
+
result = PathOperationResult(
|
|
1405
|
+
path=destination,
|
|
1406
|
+
action="moved",
|
|
1407
|
+
source=source,
|
|
1408
|
+
)
|
|
1409
|
+
except BaseException as error:
|
|
1410
|
+
await self.error_providers(call)
|
|
1411
|
+
if isinstance(error, WorkspaceError):
|
|
1412
|
+
raise ToolError(str(error)) from error
|
|
1413
|
+
raise
|
|
1414
|
+
result.resources = (await self.after_providers(call, result)).resources
|
|
1415
|
+
await report_progress(1, total=1, message="Completed move")
|
|
1416
|
+
return result
|
|
1417
|
+
|
|
1418
|
+
@apps.tool(
|
|
1419
|
+
resource_uri=self.RESULT_WIDGET.uri,
|
|
1420
|
+
icons=[self.EDIT_ICON.icon],
|
|
1421
|
+
annotations=modifying,
|
|
1422
|
+
)
|
|
1423
|
+
@serialized
|
|
1424
|
+
async def workspace_delete(
|
|
1425
|
+
path: WorkspacePath,
|
|
1426
|
+
ctx: Context,
|
|
1427
|
+
) -> PathOperationResult:
|
|
1428
|
+
"""Delete a file or empty directory; protected paths and roots cannot be deleted."""
|
|
1429
|
+
report_progress = progress(ctx, interval=self.progress_interval)
|
|
1430
|
+
call = await self.before_providers("workspace_delete", {"path": path})
|
|
1431
|
+
try:
|
|
1432
|
+
await report_progress(0, total=1, message="Starting delete")
|
|
1433
|
+
with filesystem_errors(path):
|
|
1434
|
+
candidate = self.writable_path(
|
|
1435
|
+
path.relative,
|
|
1436
|
+
root=path.area,
|
|
1437
|
+
subtree=True,
|
|
1438
|
+
)
|
|
1439
|
+
if candidate.is_dir():
|
|
1440
|
+
candidate.rmdir()
|
|
1441
|
+
else:
|
|
1442
|
+
self.require_file(path.relative, path.area).unlink()
|
|
1443
|
+
result = PathOperationResult(
|
|
1444
|
+
path=path,
|
|
1445
|
+
action="deleted",
|
|
1446
|
+
)
|
|
1447
|
+
except BaseException as error:
|
|
1448
|
+
await self.error_providers(call)
|
|
1449
|
+
if isinstance(error, WorkspaceError):
|
|
1450
|
+
raise ToolError(str(error)) from error
|
|
1451
|
+
raise
|
|
1452
|
+
result.resources = (await self.after_providers(call, result)).resources
|
|
1453
|
+
await report_progress(1, total=1, message="Completed delete")
|
|
1454
|
+
return result
|
|
1455
|
+
|
|
1456
|
+
@apps.tool(
|
|
1457
|
+
resource_uri=self.RESULT_WIDGET.uri,
|
|
1458
|
+
icons=[self.ICON.icon],
|
|
1459
|
+
annotations=ToolAnnotations(
|
|
1460
|
+
read_only_hint=False,
|
|
1461
|
+
destructive_hint=False,
|
|
1462
|
+
idempotent_hint=True,
|
|
1463
|
+
open_world_hint=False,
|
|
1464
|
+
),
|
|
1465
|
+
)
|
|
1466
|
+
@serialized
|
|
1467
|
+
async def workspace_mkdir(
|
|
1468
|
+
path: WorkspacePath,
|
|
1469
|
+
ctx: Context,
|
|
1470
|
+
) -> PathOperationResult:
|
|
1471
|
+
"""Create a directory and missing parents, or report that it already exists."""
|
|
1472
|
+
report_progress = progress(ctx, interval=self.progress_interval)
|
|
1473
|
+
call = await self.before_providers("workspace_mkdir", {"path": path})
|
|
1474
|
+
try:
|
|
1475
|
+
await report_progress(0, total=1, message="Starting mkdir")
|
|
1476
|
+
with filesystem_errors(path):
|
|
1477
|
+
candidate = self.writable_path(path.relative, root=path.area)
|
|
1478
|
+
existed = candidate.is_dir()
|
|
1479
|
+
candidate.mkdir(parents=True, exist_ok=True)
|
|
1480
|
+
result = PathOperationResult(
|
|
1481
|
+
path=path,
|
|
1482
|
+
action="already_exists" if existed else "created",
|
|
1483
|
+
)
|
|
1484
|
+
except BaseException as error:
|
|
1485
|
+
await self.error_providers(call)
|
|
1486
|
+
if isinstance(error, WorkspaceError):
|
|
1487
|
+
raise ToolError(str(error)) from error
|
|
1488
|
+
raise
|
|
1489
|
+
result.resources = (await self.after_providers(call, result)).resources
|
|
1490
|
+
await report_progress(1, total=1, message="Completed mkdir")
|
|
1491
|
+
return result
|
|
1492
|
+
|
|
1493
|
+
for widget in (
|
|
1494
|
+
self.TREE_WIDGET,
|
|
1495
|
+
self.FILE_WIDGET,
|
|
1496
|
+
self.SEARCH_WIDGET,
|
|
1497
|
+
self.RESULT_WIDGET,
|
|
1498
|
+
):
|
|
1499
|
+
apps.add_html_resource(widget.uri, widget.content)
|
|
1500
|
+
|
|
1501
|
+
@mcp.resource(
|
|
1502
|
+
self.RESULT_JSON_URI,
|
|
1503
|
+
mime_type="application/json",
|
|
1504
|
+
icons=[self.FILE_ICON.icon],
|
|
1505
|
+
)
|
|
1506
|
+
@serialized
|
|
1507
|
+
async def workspace_result_json(result_id: str, name: str) -> str:
|
|
1508
|
+
"""Read immutable provider JSON from one tool invocation."""
|
|
1509
|
+
try:
|
|
1510
|
+
return self.read_result_resource(result_id, name + ".json")
|
|
1511
|
+
except WorkspaceError as error:
|
|
1512
|
+
raise ResourceNotFoundError(str(error)) from error
|
|
1513
|
+
|
|
1514
|
+
@mcp.resource(
|
|
1515
|
+
self.RESULT_MARKDOWN_URI,
|
|
1516
|
+
mime_type="text/markdown",
|
|
1517
|
+
icons=[self.FILE_ICON.icon],
|
|
1518
|
+
)
|
|
1519
|
+
@serialized
|
|
1520
|
+
async def workspace_result_markdown(result_id: str, name: str) -> str:
|
|
1521
|
+
"""Read a provider's immutable Markdown, independently of syntax token data."""
|
|
1522
|
+
try:
|
|
1523
|
+
return self.read_result_resource(result_id, name + ".md")
|
|
1524
|
+
except WorkspaceError as error:
|
|
1525
|
+
raise ResourceNotFoundError(str(error)) from error
|
|
1526
|
+
|
|
1527
|
+
def resource_path(value: str) -> WorkspacePath:
|
|
1528
|
+
try:
|
|
1529
|
+
# Directory completions end in '/', while WorkspacePath is canonical.
|
|
1530
|
+
area, separator, relative = value.partition("/")
|
|
1531
|
+
if area == "root":
|
|
1532
|
+
raise ResourceNotFoundError("External files have no workspace mirror resources.")
|
|
1533
|
+
if not separator:
|
|
1534
|
+
raise WorkspaceError("Path must start with project/ or storage/.")
|
|
1535
|
+
return self.qualified_path(area, relative.rstrip("/") or ".")
|
|
1536
|
+
except WorkspaceError as error:
|
|
1537
|
+
raise ResourceError(str(error)) from error
|
|
1538
|
+
|
|
1539
|
+
@mcp.resource(
|
|
1540
|
+
self.FILE_URI,
|
|
1541
|
+
mime_type="text/plain",
|
|
1542
|
+
icons=[self.FILE_ICON.icon],
|
|
1543
|
+
)
|
|
1544
|
+
@serialized
|
|
1545
|
+
async def workspace_file_resource(
|
|
1546
|
+
path: list[str] | None = None,
|
|
1547
|
+
) -> str:
|
|
1548
|
+
"""Read the complete UTF-8 file without formatting or metadata."""
|
|
1549
|
+
selected = resource_path("/".join(path or []))
|
|
1550
|
+
try:
|
|
1551
|
+
return read_text(self.resolve_workspace_path(selected), selected)
|
|
1552
|
+
except WorkspaceError as error:
|
|
1553
|
+
raise ResourceError(str(error)) from error
|
|
1554
|
+
|
|
1555
|
+
@mcp.resource(
|
|
1556
|
+
self.RAW_URI,
|
|
1557
|
+
mime_type="application/octet-stream",
|
|
1558
|
+
icons=[self.FILE_ICON.icon],
|
|
1559
|
+
)
|
|
1560
|
+
@serialized
|
|
1561
|
+
async def workspace_raw_resource(
|
|
1562
|
+
path: list[str] | None = None,
|
|
1563
|
+
) -> bytes:
|
|
1564
|
+
"""Read the exact bytes of any file."""
|
|
1565
|
+
selected = resource_path("/".join(path or []))
|
|
1566
|
+
try:
|
|
1567
|
+
with filesystem_errors(selected):
|
|
1568
|
+
return self.require_file(selected.relative, selected.area).read_bytes()
|
|
1569
|
+
except WorkspaceError as error:
|
|
1570
|
+
raise ResourceError(str(error)) from error
|
|
1571
|
+
|
|
1572
|
+
@mcp.resource(self.LIST_URI, mime_type="text/markdown", icons=[self.ICON.icon])
|
|
1573
|
+
@serialized
|
|
1574
|
+
async def workspace_list_resource(
|
|
1575
|
+
ctx: Context,
|
|
1576
|
+
path: str = "project/",
|
|
1577
|
+
depth: int | Literal["all"] = 1,
|
|
1578
|
+
include_hidden: bool = False,
|
|
1579
|
+
) -> str:
|
|
1580
|
+
"""Read a directory tree as Markdown; depth=all expands the whole tree."""
|
|
1581
|
+
try:
|
|
1582
|
+
result = await workspace_list(
|
|
1583
|
+
ctx=ctx,
|
|
1584
|
+
path=resource_path(path),
|
|
1585
|
+
depth=None if depth == "all" else depth,
|
|
1586
|
+
include_hidden=include_hidden,
|
|
1587
|
+
)
|
|
1588
|
+
return self.render_markdown(result)
|
|
1589
|
+
except (WorkspaceError, ToolError) as error:
|
|
1590
|
+
raise ResourceError(str(error)) from error
|
|
1591
|
+
|
|
1592
|
+
@mcp.resource(
|
|
1593
|
+
self.FIND_URI,
|
|
1594
|
+
mime_type="text/markdown",
|
|
1595
|
+
icons=[self.ICON.icon],
|
|
1596
|
+
security=ResourceSecurity(exempt_params={"pattern"}),
|
|
1597
|
+
)
|
|
1598
|
+
@serialized
|
|
1599
|
+
async def workspace_find_files_resource(
|
|
1600
|
+
ctx: Context,
|
|
1601
|
+
pattern: str = "*",
|
|
1602
|
+
path: str = "project/",
|
|
1603
|
+
) -> str:
|
|
1604
|
+
"""Read matching file links as Markdown."""
|
|
1605
|
+
try:
|
|
1606
|
+
result = await workspace_find_files(
|
|
1607
|
+
ctx=ctx,
|
|
1608
|
+
pattern=pattern,
|
|
1609
|
+
path=resource_path(path),
|
|
1610
|
+
)
|
|
1611
|
+
return self.render_markdown(result)
|
|
1612
|
+
except (WorkspaceError, ToolError) as error:
|
|
1613
|
+
raise ResourceError(str(error)) from error
|
|
1614
|
+
|
|
1615
|
+
@mcp.resource(
|
|
1616
|
+
self.INFO_URI,
|
|
1617
|
+
mime_type="text/markdown",
|
|
1618
|
+
icons=[self.FILE_ICON.icon],
|
|
1619
|
+
)
|
|
1620
|
+
@serialized
|
|
1621
|
+
async def workspace_file_info_resource(
|
|
1622
|
+
ctx: Context,
|
|
1623
|
+
path: str = "",
|
|
1624
|
+
) -> str:
|
|
1625
|
+
"""Read one file's metadata as a Markdown table; path is required."""
|
|
1626
|
+
selected = resource_path(path)
|
|
1627
|
+
try:
|
|
1628
|
+
result = await workspace_file_info(
|
|
1629
|
+
path=selected,
|
|
1630
|
+
ctx=ctx,
|
|
1631
|
+
)
|
|
1632
|
+
return self.render_markdown(result)
|
|
1633
|
+
except (WorkspaceError, ToolError) as error:
|
|
1634
|
+
raise ResourceError(str(error)) from error
|
|
1635
|
+
|
|
1636
|
+
@mcp.resource(
|
|
1637
|
+
self.SEARCH_URI,
|
|
1638
|
+
mime_type="text/markdown",
|
|
1639
|
+
icons=[self.SEARCH_ICON.icon],
|
|
1640
|
+
security=ResourceSecurity(exempt_params={"query"}),
|
|
1641
|
+
)
|
|
1642
|
+
@serialized
|
|
1643
|
+
async def workspace_search_resource(
|
|
1644
|
+
ctx: Context,
|
|
1645
|
+
query: str = "",
|
|
1646
|
+
path: str = "project/",
|
|
1647
|
+
regex: bool = False,
|
|
1648
|
+
extensions: str | None = None,
|
|
1649
|
+
case_sensitive: bool = True,
|
|
1650
|
+
) -> str:
|
|
1651
|
+
"""Read text/regex matches and skipped files as Markdown; query is required."""
|
|
1652
|
+
try:
|
|
1653
|
+
result = await workspace_search(
|
|
1654
|
+
ctx=ctx,
|
|
1655
|
+
query=query,
|
|
1656
|
+
path=resource_path(path),
|
|
1657
|
+
regex=regex,
|
|
1658
|
+
extensions=None if extensions is None else extensions.split(","),
|
|
1659
|
+
case_sensitive=case_sensitive,
|
|
1660
|
+
)
|
|
1661
|
+
return self.render_markdown(result)
|
|
1662
|
+
except (WorkspaceError, ToolError) as error:
|
|
1663
|
+
raise ResourceError(str(error)) from error
|
|
1664
|
+
|
|
1665
|
+
async def workspace_completion(
|
|
1666
|
+
ref: ResourceTemplateReference,
|
|
1667
|
+
argument: CompletionArgument,
|
|
1668
|
+
context: CompletionContext | None,
|
|
1669
|
+
) -> Completion | None:
|
|
1670
|
+
if isinstance(ref, ResourceTemplateReference) and ref.uri in (
|
|
1671
|
+
self.RESULT_JSON_URI,
|
|
1672
|
+
self.RESULT_MARKDOWN_URI,
|
|
1673
|
+
):
|
|
1674
|
+
if argument.name == "result_id":
|
|
1675
|
+
values = list(self.result_resources)
|
|
1676
|
+
elif argument.name == "name":
|
|
1677
|
+
result_id = (context.arguments or {}).get("result_id", "") if context else ""
|
|
1678
|
+
suffix = ".json" if ref.uri == self.RESULT_JSON_URI else ".md"
|
|
1679
|
+
values = [
|
|
1680
|
+
name.removesuffix(suffix)
|
|
1681
|
+
for name in self.result_resources.get(result_id, {})
|
|
1682
|
+
if name.endswith(suffix)
|
|
1683
|
+
]
|
|
1684
|
+
else:
|
|
1685
|
+
values = []
|
|
1686
|
+
matches = [value for value in values if value.startswith(argument.value)]
|
|
1687
|
+
return Completion(
|
|
1688
|
+
values=matches[:100],
|
|
1689
|
+
total=len(matches),
|
|
1690
|
+
has_more=len(matches) > 100,
|
|
1691
|
+
)
|
|
1692
|
+
uris = (
|
|
1693
|
+
self.FILE_URI,
|
|
1694
|
+
self.RAW_URI,
|
|
1695
|
+
self.LIST_URI,
|
|
1696
|
+
self.FIND_URI,
|
|
1697
|
+
self.INFO_URI,
|
|
1698
|
+
self.SEARCH_URI,
|
|
1699
|
+
)
|
|
1700
|
+
if not isinstance(ref, ResourceTemplateReference) or ref.uri not in uris:
|
|
1701
|
+
return None
|
|
1702
|
+
values: list[str] = []
|
|
1703
|
+
if argument.name in ("regex", "case_sensitive", "include_hidden"):
|
|
1704
|
+
values = ["false", "true"]
|
|
1705
|
+
elif argument.name == "depth":
|
|
1706
|
+
values = ["1", "2", "3", "all"]
|
|
1707
|
+
elif argument.name == "extensions" and ref.uri == self.SEARCH_URI:
|
|
1708
|
+
selected_path = (
|
|
1709
|
+
(context.arguments or {}).get("path", "project/")
|
|
1710
|
+
if context
|
|
1711
|
+
else "project/"
|
|
1712
|
+
)
|
|
1713
|
+
try:
|
|
1714
|
+
selected = resource_path(selected_path)
|
|
1715
|
+
extensions = sorted(
|
|
1716
|
+
{
|
|
1717
|
+
candidate.suffix.removeprefix(".")
|
|
1718
|
+
async for candidate in self.iter_files(selected.relative, selected.area)
|
|
1719
|
+
}
|
|
1720
|
+
)
|
|
1721
|
+
prefix, separator, tail = argument.value.rpartition(",")
|
|
1722
|
+
values = [
|
|
1723
|
+
prefix + separator + value
|
|
1724
|
+
for value in extensions
|
|
1725
|
+
if value.startswith(tail)
|
|
1726
|
+
]
|
|
1727
|
+
except (WorkspaceError, ResourceError):
|
|
1728
|
+
values = []
|
|
1729
|
+
elif argument.name == "path":
|
|
1730
|
+
if "/" not in argument.value:
|
|
1731
|
+
values = ["project/", "storage/"]
|
|
1732
|
+
else:
|
|
1733
|
+
try:
|
|
1734
|
+
parent = argument.value.rpartition("/")[0]
|
|
1735
|
+
if parent in ("project", "storage"):
|
|
1736
|
+
parent += "/"
|
|
1737
|
+
selected = resource_path(parent)
|
|
1738
|
+
directory = self.resolve_workspace_path(selected)
|
|
1739
|
+
self.check_path_links(directory, selected.area)
|
|
1740
|
+
with filesystem_errors(parent):
|
|
1741
|
+
for child in sorted(directory.iterdir()):
|
|
1742
|
+
await asyncio.sleep(0)
|
|
1743
|
+
if self.is_link(child):
|
|
1744
|
+
continue
|
|
1745
|
+
is_directory = child.is_dir()
|
|
1746
|
+
if (
|
|
1747
|
+
ref.uri in (self.LIST_URI, self.FIND_URI, self.SEARCH_URI)
|
|
1748
|
+
and not is_directory
|
|
1749
|
+
):
|
|
1750
|
+
continue
|
|
1751
|
+
if is_directory or child.is_file():
|
|
1752
|
+
relative = self.relative_path(child, root=selected.area)
|
|
1753
|
+
values.append(
|
|
1754
|
+
str(self.qualified_path(selected.area, relative))
|
|
1755
|
+
+ ("/" if is_directory else "")
|
|
1756
|
+
)
|
|
1757
|
+
except (WorkspaceError, ResourceError):
|
|
1758
|
+
values = []
|
|
1759
|
+
matches = [value for value in values if value.startswith(argument.value)]
|
|
1760
|
+
return Completion(
|
|
1761
|
+
values=matches[:100],
|
|
1762
|
+
total=len(matches),
|
|
1763
|
+
has_more=len(matches) > 100,
|
|
1764
|
+
)
|
|
1765
|
+
|
|
1766
|
+
complete.add_completion(serialized(workspace_completion))
|