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.
Files changed (83) hide show
  1. forge_cpp_mcp-0.2.1.dist-info/METADATA +436 -0
  2. forge_cpp_mcp-0.2.1.dist-info/RECORD +83 -0
  3. forge_cpp_mcp-0.2.1.dist-info/WHEEL +4 -0
  4. forge_cpp_mcp-0.2.1.dist-info/entry_points.txt +2 -0
  5. forge_cpp_mcp-0.2.1.dist-info/licenses/LICENSE +21 -0
  6. forgemcp/__init__.py +5 -0
  7. forgemcp/assets/clangd-result.html +119 -0
  8. forgemcp/assets/cmake-build.html +111 -0
  9. forgemcp/assets/cmake-configure.html +111 -0
  10. forgemcp/assets/cmake-profiles.html +111 -0
  11. forgemcp/assets/cmake-test.html +111 -0
  12. forgemcp/assets/process-details.html +113 -0
  13. forgemcp/assets/process-overview.html +111 -0
  14. forgemcp/assets/toolsets.html +111 -0
  15. forgemcp/assets/workspace-file.html +119 -0
  16. forgemcp/assets/workspace-result.html +119 -0
  17. forgemcp/assets/workspace-search.html +119 -0
  18. forgemcp/assets/workspace-tree.html +119 -0
  19. forgemcp/assets.py +62 -0
  20. forgemcp/clangd/__init__.py +1 -0
  21. forgemcp/clangd/errors.py +29 -0
  22. forgemcp/clangd/models.py +94 -0
  23. forgemcp/clangd/service.py +1279 -0
  24. forgemcp/clangd/session.py +520 -0
  25. forgemcp/cmake/__init__.py +1 -0
  26. forgemcp/cmake/errors.py +5 -0
  27. forgemcp/cmake/profiles.py +95 -0
  28. forgemcp/cmake/service.py +895 -0
  29. forgemcp/completion.py +49 -0
  30. forgemcp/icons/clangd.LICENSE +219 -0
  31. forgemcp/icons/clangd.svg +25 -0
  32. forgemcp/icons/cmake.svg +9 -0
  33. forgemcp/icons/process.svg +4 -0
  34. forgemcp/icons/toolchain.svg +1 -0
  35. forgemcp/icons/workspace-edit.svg +1 -0
  36. forgemcp/icons/workspace-file.svg +1 -0
  37. forgemcp/icons/workspace-search.svg +1 -0
  38. forgemcp/icons/workspace.svg +4 -0
  39. forgemcp/markdown.py +308 -0
  40. forgemcp/process/__init__.py +21 -0
  41. forgemcp/process/errors.py +21 -0
  42. forgemcp/process/models.py +171 -0
  43. forgemcp/process/service.py +830 -0
  44. forgemcp/process/transcript.py +116 -0
  45. forgemcp/progress.py +40 -0
  46. forgemcp/server.py +196 -0
  47. forgemcp/toolchain/__init__.py +1 -0
  48. forgemcp/toolchain/discovery.py +25 -0
  49. forgemcp/toolchain/errors.py +41 -0
  50. forgemcp/toolchain/loader.py +48 -0
  51. forgemcp/toolchain/providers/__init__.py +1 -0
  52. forgemcp/toolchain/providers/system.py +52 -0
  53. forgemcp/toolchain/providers/user.py +93 -0
  54. forgemcp/toolchain/providers/visual_studio.py +202 -0
  55. forgemcp/toolchain/service.py +226 -0
  56. forgemcp/toolchain/spec.py +73 -0
  57. forgemcp/toolchain/tools/__init__.py +1 -0
  58. forgemcp/toolchain/tools/clang.py +61 -0
  59. forgemcp/toolchain/tools/clang_cl.py +61 -0
  60. forgemcp/toolchain/tools/clangd.py +280 -0
  61. forgemcp/toolchain/tools/clangxx.py +61 -0
  62. forgemcp/toolchain/tools/cmake.py +280 -0
  63. forgemcp/toolchain/tools/cppvsdbg.py +27 -0
  64. forgemcp/toolchain/tools/ctest.py +202 -0
  65. forgemcp/toolchain/tools/gcc.py +61 -0
  66. forgemcp/toolchain/tools/gdb.py +61 -0
  67. forgemcp/toolchain/tools/git.py +61 -0
  68. forgemcp/toolchain/tools/gxx.py +61 -0
  69. forgemcp/toolchain/tools/link.py +27 -0
  70. forgemcp/toolchain/tools/lld.py +61 -0
  71. forgemcp/toolchain/tools/lld_link.py +61 -0
  72. forgemcp/toolchain/tools/lldb_dap.py +27 -0
  73. forgemcp/toolchain/tools/make.py +61 -0
  74. forgemcp/toolchain/tools/msbuild.py +61 -0
  75. forgemcp/toolchain/tools/msvc.py +27 -0
  76. forgemcp/toolchain/tools/ninja.py +61 -0
  77. forgemcp/workspace/__init__.py +5 -0
  78. forgemcp/workspace/diff.py +181 -0
  79. forgemcp/workspace/errors.py +5 -0
  80. forgemcp/workspace/metadata.py +87 -0
  81. forgemcp/workspace/path.py +71 -0
  82. forgemcp/workspace/providers.py +25 -0
  83. 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))