mosscode 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. mosscode/__init__.py +10 -0
  2. mosscode/__main__.py +3 -0
  3. mosscode/agent.py +486 -0
  4. mosscode/agents/__init__.py +249 -0
  5. mosscode/agents/builtin/explore.md +16 -0
  6. mosscode/agents/builtin/general.md +12 -0
  7. mosscode/agents/builtin/plan.md +16 -0
  8. mosscode/agents/runtime.py +685 -0
  9. mosscode/agents/tasks.py +193 -0
  10. mosscode/appearance.py +184 -0
  11. mosscode/builtin_commands.py +1363 -0
  12. mosscode/cli.py +547 -0
  13. mosscode/commands.py +254 -0
  14. mosscode/compaction.py +259 -0
  15. mosscode/config.py +337 -0
  16. mosscode/context.py +102 -0
  17. mosscode/conversation.py +1046 -0
  18. mosscode/coordinator.py +54 -0
  19. mosscode/doctor.py +303 -0
  20. mosscode/hooks/__init__.py +22 -0
  21. mosscode/hooks/actions.py +151 -0
  22. mosscode/hooks/conditions.py +170 -0
  23. mosscode/hooks/config.py +404 -0
  24. mosscode/hooks/engine.py +401 -0
  25. mosscode/hooks/events.py +48 -0
  26. mosscode/launcher.py +177 -0
  27. mosscode/mcp_client.py +460 -0
  28. mosscode/mcp_config.py +216 -0
  29. mosscode/member_ui.py +289 -0
  30. mosscode/memory.py +291 -0
  31. mosscode/memory_extract.py +245 -0
  32. mosscode/messages.py +216 -0
  33. mosscode/paths.py +31 -0
  34. mosscode/permissions.py +733 -0
  35. mosscode/prompts.py +483 -0
  36. mosscode/providers.py +587 -0
  37. mosscode/scrollback.py +106 -0
  38. mosscode/security.py +282 -0
  39. mosscode/serialization.py +121 -0
  40. mosscode/session.py +563 -0
  41. mosscode/setup.py +248 -0
  42. mosscode/skills/__init__.py +455 -0
  43. mosscode/skills/builtin/commit/SKILL.md +15 -0
  44. mosscode/skills/builtin/review/SKILL.md +15 -0
  45. mosscode/skills/builtin/test/SKILL.md +13 -0
  46. mosscode/skills/builtin/test/scripts/detect.py +96 -0
  47. mosscode/skills/builtin/test/tool.json +9 -0
  48. mosscode/teams/__init__.py +216 -0
  49. mosscode/teams/backends.py +259 -0
  50. mosscode/teams/board.py +230 -0
  51. mosscode/teams/lock.py +173 -0
  52. mosscode/teams/mailbox.py +172 -0
  53. mosscode/teams/member.py +427 -0
  54. mosscode/teams/merge.py +207 -0
  55. mosscode/teams/pane.py +20 -0
  56. mosscode/teams/pane_app.py +167 -0
  57. mosscode/teams/process.py +371 -0
  58. mosscode/teams/runtime.py +1355 -0
  59. mosscode/teams/view.py +182 -0
  60. mosscode/tool_budget.py +86 -0
  61. mosscode/tools/__init__.py +38 -0
  62. mosscode/tools/agent.py +127 -0
  63. mosscode/tools/base.py +384 -0
  64. mosscode/tools/diffing.py +16 -0
  65. mosscode/tools/execution.py +99 -0
  66. mosscode/tools/files.py +287 -0
  67. mosscode/tools/memory.py +65 -0
  68. mosscode/tools/plan.py +30 -0
  69. mosscode/tools/process.py +216 -0
  70. mosscode/tools/search.py +229 -0
  71. mosscode/tools/search_worker.py +243 -0
  72. mosscode/tools/shell.py +121 -0
  73. mosscode/tools/skill.py +118 -0
  74. mosscode/tools/team.py +447 -0
  75. mosscode/tools/windows_job.py +75 -0
  76. mosscode/tui.py +2325 -0
  77. mosscode/usage.py +68 -0
  78. mosscode/windows_inline.py +148 -0
  79. mosscode/worktree/__init__.py +29 -0
  80. mosscode/worktree/changes.py +59 -0
  81. mosscode/worktree/git.py +125 -0
  82. mosscode/worktree/manager.py +462 -0
  83. mosscode/worktree/meta.py +82 -0
  84. mosscode/worktree/names.py +52 -0
  85. mosscode/worktree/setup.py +119 -0
  86. mosscode-0.1.0.dist-info/METADATA +92 -0
  87. mosscode-0.1.0.dist-info/RECORD +91 -0
  88. mosscode-0.1.0.dist-info/WHEEL +5 -0
  89. mosscode-0.1.0.dist-info/entry_points.txt +3 -0
  90. mosscode-0.1.0.dist-info/licenses/LICENSE +21 -0
  91. mosscode-0.1.0.dist-info/top_level.txt +1 -0
mosscode/mcp_client.py ADDED
@@ -0,0 +1,460 @@
1
+ """Tools-only MCP sessions, each with a task owning its SDK context lifetime.
2
+
3
+ Discovery can run in the background: each server registers its tools as soon as
4
+ it is ready, so a slow server never holds up the prompt or the other servers.
5
+ """
6
+
7
+ import asyncio
8
+ import errno
9
+ import hashlib
10
+ import json
11
+ import os
12
+ import re
13
+ from contextlib import AsyncExitStack, asynccontextmanager, suppress
14
+ from copy import deepcopy
15
+ from dataclasses import dataclass
16
+ from datetime import timedelta
17
+ from itertools import islice
18
+ from pathlib import Path
19
+
20
+ import anyio
21
+ import httpx
22
+ from jsonschema import SchemaError, validators
23
+ from mcp import ClientSession, StdioServerParameters, types
24
+ from mcp.client.stdio import stdio_client
25
+ from mcp.client.streamable_http import StreamableHTTPTransport
26
+ from mcp.shared.exceptions import McpError
27
+ from mcp.shared.message import SessionMessage
28
+ from referencing import Registry
29
+
30
+ from .mcp_config import MCPSettings, ServerConfig
31
+ from .messages import ToolDefinition
32
+ from .tools.base import Tool, ToolFailure
33
+
34
+
35
+ def tool_name(server: str, tool: str) -> str:
36
+ name = f"mcp__{server}__{tool}"
37
+ if len(name) <= 64 and re.fullmatch(r"[A-Za-z0-9_]+", server + tool) and "__" not in server and "__" not in tool:
38
+ return name
39
+ digest = hashlib.sha256(json.dumps([server, tool]).encode()).hexdigest()[:12]
40
+ clean = lambda value: re.sub(r"[^A-Za-z0-9_]", "_", value)
41
+ return f"mcp__{clean(server)[:16]}__{clean(tool)[:22]}_{digest}"
42
+
43
+
44
+ class ToolsHTTPTransport(StreamableHTTPTransport):
45
+ # SDK 1.30 also resumes interrupted POST/SSE responses via GET. This pinned
46
+ # hook disables that path as well as the optional notification GET below.
47
+ async def _handle_reconnection(self, ctx, last_event_id, retry_interval_ms, attempt=0):
48
+ request = ctx.session_message.message.root
49
+ await ctx.read_stream_writer.send(
50
+ SessionMessage(
51
+ types.JSONRPCMessage(
52
+ types.JSONRPCError(
53
+ jsonrpc="2.0",
54
+ id=request.id,
55
+ error=types.ErrorData(
56
+ code=types.CONNECTION_CLOSED, message="MCP SSE 响应中断;不自动恢复连接。"
57
+ ),
58
+ )
59
+ )
60
+ )
61
+ )
62
+
63
+
64
+ @asynccontextmanager
65
+ async def tools_http(url: str, client: httpx.AsyncClient):
66
+ """Use SDK HTTP framing without its optional reconnecting notification GET.
67
+
68
+ Tools use POST responses (JSON or SSE). A standalone GET stream is optional
69
+ in MCP and unnecessary here; do not start the SDK's reconnect loop.
70
+ """
71
+ incoming, read = anyio.create_memory_object_stream(0)
72
+ write, outgoing = anyio.create_memory_object_stream(0)
73
+ transport = ToolsHTTPTransport(url)
74
+ try:
75
+ async with anyio.create_task_group() as group:
76
+ group.start_soon(transport.post_writer, client, outgoing, incoming, write, lambda: None, group)
77
+ try:
78
+ yield read, write
79
+ finally:
80
+ with anyio.CancelScope(shield=True):
81
+ if transport.session_id:
82
+ with suppress(Exception):
83
+ with anyio.move_on_after(2):
84
+ await transport.terminate_session(client)
85
+ group.cancel_scope.cancel()
86
+ finally:
87
+ with anyio.CancelScope(shield=True):
88
+ for stream in (incoming, read, write, outgoing):
89
+ await stream.aclose()
90
+
91
+
92
+ def check_schema(schema):
93
+ if not isinstance(schema, dict) or schema.get("type") != "object":
94
+ raise ValueError("工具参数 Schema 必须为 object。")
95
+ if len(json.dumps(schema).encode()) > 65536:
96
+ raise ValueError("工具 Schema 过大。")
97
+
98
+ def walk(value):
99
+ if isinstance(value, dict):
100
+ for key in ("$ref", "$dynamicRef", "$recursiveRef"):
101
+ if key in value and (not isinstance(value[key], str) or not value[key].startswith("#")):
102
+ raise ValueError("本章不加载外部 Schema 引用。")
103
+ for child in value.values():
104
+ walk(child)
105
+ elif isinstance(value, list):
106
+ for child in value:
107
+ walk(child)
108
+
109
+ walk(schema)
110
+ validator = validators.validator_for(schema)
111
+ validator.check_schema(schema)
112
+ # An empty Registry has no network retrieval callback. Local fragments
113
+ # resolve within the root resource; unknown resources fail closed.
114
+ return validator(schema, registry=Registry())
115
+
116
+
117
+ @dataclass
118
+ class MCPArguments:
119
+ values: dict
120
+
121
+ def model_dump(self):
122
+ return deepcopy(self.values)
123
+
124
+
125
+ def json_arguments(validator, raw, kind: str) -> MCPArguments:
126
+ """Arguments checked against a tool's JSON Schema (MCP tools, skill tools); at most 64 KB."""
127
+ try:
128
+ if not isinstance(raw, dict) or len(json.dumps(raw, allow_nan=False).encode()) > 65536:
129
+ raise ValueError
130
+ errors = list(islice(validator.iter_errors(raw), 5))
131
+ if errors:
132
+ fields = [".".join(map(str, e.absolute_path)) or "参数对象" for e in errors[:5]]
133
+ raise ToolFailure(
134
+ "invalid_arguments", f"{kind} 参数不符合工具 JSON Schema,问题字段:" + "、".join(fields), fields=fields
135
+ )
136
+ except ToolFailure:
137
+ raise
138
+ except Exception:
139
+ raise ToolFailure("invalid_arguments", f"无法校验 {kind} JSON 参数或参数过大。") from None
140
+ return MCPArguments(deepcopy(raw))
141
+
142
+
143
+ def describe_failure(exc: BaseException, config: ServerConfig) -> str:
144
+ """A safe category for /mcp; never the URL, headers, environment or server output."""
145
+ while isinstance(exc, BaseExceptionGroup) and exc.exceptions:
146
+ exc = exc.exceptions[0]
147
+ if isinstance(exc, FileNotFoundError) or (isinstance(exc, OSError) and exc.errno == errno.ENOENT):
148
+ return f"找不到命令 {config.command}" if config.type == "stdio" else "找不到所需文件"
149
+ if isinstance(exc, PermissionError):
150
+ return "没有权限启动命令" if config.type == "stdio" else "没有访问权限"
151
+ if isinstance(exc, httpx.HTTPStatusError):
152
+ return f"HTTP {exc.response.status_code}"
153
+ if isinstance(exc, (httpx.TimeoutException, TimeoutError)):
154
+ return "连接超时"
155
+ if isinstance(exc, httpx.TransportError):
156
+ return "无法连接端点"
157
+ if isinstance(exc, McpError):
158
+ if exc.error.code == types.CONNECTION_CLOSED:
159
+ return "Server 在初始化期间断开(进程可能已退出)"
160
+ return f"协议错误({exc.error.code})"
161
+ if isinstance(exc, SchemaError):
162
+ return "工具 Schema 无效"
163
+ if isinstance(exc, ValueError) and exc.args and isinstance(exc.args[0], str) and exc.args[0].endswith("。"):
164
+ return exc.args[0].rstrip("。") # Our own discovery checks.
165
+ if isinstance(exc, OSError):
166
+ return "启动或连接失败(系统错误)"
167
+ return f"连接或发现失败({type(exc).__name__})"
168
+
169
+
170
+ class MCPTool(Tool):
171
+ permission_kind = "mcp"
172
+ read_only = False # Server annotations are hints, not grants, unless the user trusts them.
173
+ timeout = 30.0
174
+
175
+ def __init__(self, root: Path, connection, remote):
176
+ super().__init__(root)
177
+ self.connection = connection
178
+ config = connection.config
179
+ self.server_name, self.remote_name = config.name, remote.name
180
+ self.timeout = config.timeout
181
+ hint = getattr(remote.annotations, "readOnlyHint", None) if remote.annotations else None
182
+ self.read_only = bool(config.trust_read_only_hint and hint is True)
183
+ self.name = tool_name(self.server_name, self.remote_name)
184
+ self.description = f"MCP Server {self.server_name} 的工具 {self.remote_name}。" + (remote.description or "")
185
+ self.prompt_summary = f"外部 MCP 工具:{self.server_name} / {self.remote_name}" + (
186
+ "(只读)" if self.read_only else ""
187
+ )
188
+ self.schema = deepcopy(remote.inputSchema)
189
+ self.validator = check_schema(self.schema)
190
+
191
+ def definition(self):
192
+ return ToolDefinition(self.name, self.description, deepcopy(self.schema), self.prompt_summary)
193
+
194
+ def validate_arguments(self, raw):
195
+ return json_arguments(self.validator, raw, "MCP")
196
+
197
+ async def execute(self, params):
198
+ result = await self.connection.call(self.remote_name, params.model_dump())
199
+ lines, unsupported = [], []
200
+ for block in result.content:
201
+ if block.type == "text":
202
+ lines.append(block.text)
203
+ else:
204
+ unsupported.append(block.type)
205
+ if result.structuredContent is not None:
206
+ lines.append(json.dumps(result.structuredContent, ensure_ascii=False, allow_nan=False))
207
+ if unsupported:
208
+ lines.append("(本章未展示的 MCP 内容类型:" + ", ".join(sorted(set(unsupported))) + ";未自动读取资源。)")
209
+ output = "\n".join(lines) or "MCP 工具未返回正文。"
210
+ details = {"server": self.server_name, "tool": self.remote_name, "unsupported_types": unsupported}
211
+ if result.isError:
212
+ raise ToolFailure("mcp_tool_error", output, **details)
213
+ return output, details
214
+
215
+
216
+ class ServerConnection:
217
+ def __init__(self, root: Path, config: ServerConfig, startup_timeout=10.0):
218
+ self.root, self.config = root.resolve(), config
219
+ self.startup_timeout = config.startup_timeout or startup_timeout
220
+ self.task = None
221
+ self.session = None
222
+ self.stop = asyncio.Event()
223
+ self.ready = None
224
+ self.state = "new"
225
+ self.error = "" # Safe reason shown by /mcp.
226
+
227
+ async def start(self):
228
+ if self.stop.is_set():
229
+ return None
230
+ if self.task is None:
231
+ self.ready = asyncio.get_running_loop().create_future()
232
+ self.task = asyncio.create_task(self._own(), name=f"mcp:{self.config.name}")
233
+ done, _ = await asyncio.wait([self.ready], timeout=self.startup_timeout)
234
+ if not done:
235
+ self.error = f"启动超时({self.startup_timeout:g} 秒)"
236
+ await self.close()
237
+ return None
238
+ return self.ready.result()
239
+
240
+ async def _own(self):
241
+ # AnyIO transport/session scopes must enter and exit in this same task.
242
+ tools = ()
243
+ try:
244
+ async with AsyncExitStack() as stack:
245
+ if self.config.type == "stdio":
246
+ errlog = stack.enter_context(open(os.devnull, "w"))
247
+ streams = await stack.enter_async_context(
248
+ stdio_client(
249
+ StdioServerParameters(
250
+ command=self.config.command,
251
+ args=list(self.config.args),
252
+ env=self.config.env,
253
+ cwd=str(self.root),
254
+ ),
255
+ errlog=errlog,
256
+ )
257
+ )
258
+ else:
259
+ client = await stack.enter_async_context(
260
+ httpx.AsyncClient(
261
+ headers=self.config.headers,
262
+ timeout=httpx.Timeout(30, connect=10),
263
+ follow_redirects=False,
264
+ trust_env=False,
265
+ )
266
+ )
267
+ streams = await stack.enter_async_context(tools_http(self.config.url, client))
268
+ # Discovery requests share the startup window; tool calls pass config.timeout.
269
+ session = await stack.enter_async_context(
270
+ ClientSession(
271
+ *streams, read_timeout_seconds=timedelta(seconds=max(self.config.timeout, self.startup_timeout))
272
+ )
273
+ )
274
+ await session.initialize()
275
+ discovered, cursor, seen = [], None, set()
276
+ for _ in range(100):
277
+ page = await session.list_tools(cursor=cursor)
278
+ discovered.extend(page.tools)
279
+ cursor = page.nextCursor
280
+ if cursor is None:
281
+ break
282
+ if cursor in seen:
283
+ raise ValueError("重复分页游标。")
284
+ seen.add(cursor)
285
+ else:
286
+ raise ValueError("工具列表分页过多。")
287
+ tools = tuple(MCPTool(self.root, self, item) for item in discovered)
288
+ if len({t.name for t in tools}) != len(tools):
289
+ raise ValueError("重复工具名。")
290
+ self.session, self.state = session, "ready"
291
+ self.ready.set_result(tools)
292
+ await self.stop.wait()
293
+ except asyncio.CancelledError:
294
+ self.state = "closed"
295
+ except Exception as exc:
296
+ self.state = "failed"
297
+ if not self.ready.done():
298
+ self.error = describe_failure(exc, self.config)
299
+ finally:
300
+ self.session = None
301
+ if self.state == "ready":
302
+ self.state = "closed"
303
+ if not self.ready.done():
304
+ self.ready.set_result(None)
305
+
306
+ async def call(self, name, arguments):
307
+ if self.state != "ready" or self.session is None:
308
+ raise ToolFailure("mcp_unavailable", "MCP Server 已不可用;本章不自动重连。", executed=False)
309
+ try:
310
+ return await self.session.call_tool(name, arguments, timedelta(seconds=self.config.timeout))
311
+ except asyncio.CancelledError:
312
+ # The server may already have acted. Close this session; never retry.
313
+ self.stop.set()
314
+ self.state = "closed"
315
+ raise
316
+ except McpError as exc:
317
+ if exc.error.code == types.CONNECTION_CLOSED:
318
+ self.state = "failed"
319
+ self.stop.set()
320
+ raise ToolFailure("mcp_disconnected", "MCP 连接中断;可能已有外部效果,未自动重试。") from None
321
+ if exc.error.code == 408:
322
+ self.stop.set()
323
+ self.state = "closed"
324
+ raise ToolFailure("timeout", "MCP 工具等待结果超时,可能已有外部效果。") from None
325
+ raise ToolFailure(
326
+ "mcp_protocol_error", "MCP Server 返回协议错误;未自动重试,可能已有外部效果。", rpc_code=exc.error.code
327
+ ) from None
328
+ except Exception:
329
+ self.state = "failed"
330
+ self.stop.set()
331
+ raise ToolFailure("mcp_disconnected", "MCP 连接中断或响应不合法;可能已有外部效果,未自动重试。") from None
332
+
333
+ async def close(self):
334
+ self.stop.set()
335
+ if self.task is not None:
336
+ # If startup is still waiting for a server, interrupt its own scopes.
337
+ if self.state == "new" and not self.task.done():
338
+ self.task.cancel()
339
+ with suppress(asyncio.CancelledError):
340
+ await self.task
341
+ if self.ready is not None and not self.ready.done():
342
+ self.ready.set_result(None)
343
+
344
+
345
+ @dataclass(frozen=True)
346
+ class ServerStatus:
347
+ name: str
348
+ type: str
349
+ state: str # connecting / ready / failed / skipped / closed
350
+ detail: str = ""
351
+ tools: tuple = () # MCPTool objects once ready.
352
+
353
+ def describe(self) -> str:
354
+ if self.state == "ready":
355
+ return f"已连接 · {len(self.tools)} 个工具"
356
+ return {
357
+ "new": "未启动",
358
+ "connecting": "连接中…",
359
+ "failed": f"失败:{self.detail}",
360
+ "skipped": f"已跳过:{self.detail}",
361
+ "closed": "已关闭(不自动重连)",
362
+ }.get(self.state, self.state)
363
+
364
+ def notice(self) -> str:
365
+ if self.state == "ready":
366
+ return f"MCP {self.name} 已连接 · 注册 {len(self.tools)} 个工具。"
367
+ return f"MCP {self.name} {self.describe()};可用 /mcp 查看。"
368
+
369
+
370
+ class MCPManager:
371
+ def __init__(self, root: Path, settings: MCPSettings, *, startup_timeout=10.0):
372
+ self.connections = [ServerConnection(root, c, startup_timeout) for c in settings.servers]
373
+ self.notices = settings.notices
374
+ self._outcomes: dict[str, ServerStatus] = {}
375
+ self._tasks: list[asyncio.Task] = []
376
+ self._reported = False
377
+ self._closed = False
378
+
379
+ @property
380
+ def pending(self) -> int:
381
+ # A server settles when it reports, which happens just before its task finishes.
382
+ return sum(
383
+ not task.done() and connection.config.name not in self._outcomes
384
+ for task, connection in zip(self._tasks, self.connections)
385
+ )
386
+
387
+ def statuses(self) -> tuple[ServerStatus, ...]:
388
+ result = []
389
+ for connection in self.connections:
390
+ config = connection.config
391
+ outcome = self._outcomes.get(config.name)
392
+ if outcome is None:
393
+ result.append(ServerStatus(config.name, config.type, "connecting" if self._tasks else "new"))
394
+ elif outcome.state == "ready" and connection.state != "ready":
395
+ # Disconnected or timed out after discovery; tools now report mcp_unavailable.
396
+ result.append(ServerStatus(config.name, config.type, "closed", tools=outcome.tools))
397
+ else:
398
+ result.append(outcome)
399
+ return tuple(result)
400
+
401
+ def begin(self, registry, on_update=None) -> None:
402
+ """Start discovery in the background; idempotent. on_update(status) runs as each server settles."""
403
+ if self._tasks or self._closed:
404
+ return
405
+ self._tasks = [
406
+ asyncio.create_task(self._connect(c, registry, on_update), name=f"mcp-start:{c.config.name}")
407
+ for c in self.connections
408
+ ]
409
+
410
+ async def _connect(self, connection, registry, on_update):
411
+ config = connection.config
412
+ tools = await connection.start()
413
+ if self._closed:
414
+ return
415
+ if tools is None:
416
+ status = ServerStatus(config.name, config.type, "failed", connection.error or "连接或发现失败")
417
+ elif any(registry.get(t.name) is not None for t in tools):
418
+ await connection.close()
419
+ status = ServerStatus(config.name, config.type, "skipped", "工具名与已注册工具冲突")
420
+ else:
421
+ # Synchronous: a model request never sees half of a server's tools.
422
+ for tool in tools:
423
+ registry.register(tool)
424
+ status = ServerStatus(config.name, config.type, "ready", tools=tools)
425
+ self._outcomes[config.name] = status
426
+ if on_update is not None:
427
+ on_update(status)
428
+
429
+ async def wait(self) -> None:
430
+ if self._tasks:
431
+ await asyncio.gather(*self._tasks)
432
+
433
+ async def start(self, registry, *, problems_only: bool = False):
434
+ """Discover and register everything, then report; only the first call reports.
435
+
436
+ problems_only drops the "connected" lines, as startup output does; /mcp lists those servers.
437
+ """
438
+ if self._closed:
439
+ return ()
440
+ self.begin(registry)
441
+ try:
442
+ await self.wait()
443
+ except BaseException:
444
+ await self.close()
445
+ raise
446
+ if self._closed or self._reported:
447
+ return ()
448
+ self._reported = True
449
+ statuses = [s for s in self.statuses() if not problems_only or s.state != "ready"]
450
+ return (*self.notices, *(status.notice() for status in statuses))
451
+
452
+ async def close(self):
453
+ if self._closed:
454
+ return
455
+ self._closed = True
456
+ await asyncio.gather(*(c.close() for c in self.connections))
457
+ for task in self._tasks:
458
+ if not task.done():
459
+ task.cancel()
460
+ await asyncio.gather(*self._tasks, return_exceptions=True)
mosscode/mcp_config.py ADDED
@@ -0,0 +1,216 @@
1
+ """Two-layer MCP configuration plus opt-in .mcp.json; credentials stay out of repr and diagnostics."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from urllib.parse import urlsplit
9
+
10
+ import yaml
11
+
12
+
13
+ class MCPConfigError(ValueError):
14
+ pass
15
+
16
+
17
+ DEFAULT_TIMEOUT = 30.0
18
+ DEFAULT_STARTUP_TIMEOUT = 10.0
19
+ SERVER_NAME = r"[A-Za-z0-9_-]{1,64}"
20
+ OPTIONS = {"timeout", "startup_timeout", "trust_read_only_hint"}
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class ServerConfig:
25
+ name: str
26
+ type: str
27
+ command: str | None = None
28
+ args: tuple[str, ...] = ()
29
+ env: dict[str, str] = field(default_factory=dict, repr=False)
30
+ url: str | None = field(default=None, repr=False)
31
+ headers: dict[str, str] = field(default_factory=dict, repr=False)
32
+ timeout: float = DEFAULT_TIMEOUT # One tool call.
33
+ startup_timeout: float | None = None # Connect + discovery; None uses the manager default.
34
+ trust_read_only_hint: bool = False # Treat readOnlyHint tools as read-only tools.
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class MCPSettings:
39
+ servers: tuple[ServerConfig, ...] = ()
40
+ notices: tuple[str, ...] = ()
41
+
42
+
43
+ def config_paths(root: Path, user_path: Path | None = None):
44
+ return user_path or Path.home() / ".mosscode/mcp.yaml", root / ".mosscode/mcp.yaml"
45
+
46
+
47
+ def mcp_json_path(root: Path) -> Path:
48
+ return root / ".mcp.json"
49
+
50
+
51
+ def _read(path: Path) -> tuple[dict, bool]:
52
+ try:
53
+ if not path.exists():
54
+ if path.is_symlink():
55
+ raise MCPConfigError("配置文件是失效链接。")
56
+ return {}, False
57
+ raw = yaml.safe_load(path.read_text(encoding="utf-8"))
58
+ if raw is None:
59
+ return {}, False
60
+ if not isinstance(raw, dict) or set(raw) - {"mcp_servers", "import_mcp_json"}:
61
+ raise MCPConfigError("顶层仅支持 mcp_servers 和 import_mcp_json。")
62
+ if type(raw.get("import_mcp_json", False)) is not bool:
63
+ raise MCPConfigError("import_mcp_json 必须是 true 或 false。")
64
+ servers = raw.get("mcp_servers", {}) or {}
65
+ if not isinstance(servers, dict):
66
+ raise MCPConfigError("mcp_servers 必须是以 Server 名称为键的对象。")
67
+ if any(not isinstance(k, str) or not re.fullmatch(SERVER_NAME, k) for k in servers):
68
+ raise MCPConfigError("Server 名称需为 1–64 位英文字母、数字、下划线或连字符。")
69
+ return servers, raw.get("import_mcp_json", False)
70
+ except (OSError, UnicodeError, yaml.YAMLError, MCPConfigError) as exc:
71
+ detail = str(exc) if isinstance(exc, MCPConfigError) else "无法读取有效 UTF-8 YAML。"
72
+ raise MCPConfigError(f"MCP 配置 {path}:{detail}") from None
73
+
74
+
75
+ PLACEHOLDER = r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}"
76
+
77
+
78
+ def substitute_env(value: str) -> str:
79
+ """${VAR} must be set; ${VAR:-default} falls back when VAR is unset or empty."""
80
+ if "${" in re.sub(PLACEHOLDER, "", value):
81
+ raise MCPConfigError("环境变量占位符需为 ${VAR} 或 ${VAR:-默认值} 格式。")
82
+
83
+ def replace(match):
84
+ value = os.environ.get(match[1])
85
+ if match[2] is not None and not value:
86
+ return match[2]
87
+ if value is None:
88
+ raise MCPConfigError("配置引用的环境变量未设置。")
89
+ return value
90
+
91
+ return re.sub(PLACEHOLDER, replace, value)
92
+
93
+
94
+ def _expand(values, label):
95
+ if not isinstance(values, dict) or any(not isinstance(k, str) or not isinstance(v, str) for k, v in values.items()):
96
+ raise MCPConfigError(f"{label} 必须是字符串键值对象。")
97
+ return {k: substitute_env(v) for k, v in values.items()}
98
+
99
+
100
+ def _options(raw) -> dict:
101
+ options = {}
102
+ for key, default, limit in (("timeout", DEFAULT_TIMEOUT, 600), ("startup_timeout", None, 120)):
103
+ value = raw.get(key, default)
104
+ if value is not None and (type(value) not in {int, float} or not 0 < value <= limit):
105
+ raise MCPConfigError(f"{key} 必须是 (0, {limit}] 秒之间的数字。")
106
+ options[key] = None if value is None else float(value)
107
+ trust = raw.get("trust_read_only_hint", False)
108
+ if type(trust) is not bool:
109
+ raise MCPConfigError("trust_read_only_hint 必须是 true 或 false。")
110
+ options["trust_read_only_hint"] = trust
111
+ return options
112
+
113
+
114
+ def _server(name, raw):
115
+ if not isinstance(raw, dict):
116
+ raise MCPConfigError("Server 配置必须是对象。")
117
+ kind = raw.get("type")
118
+ options = _options(raw)
119
+ if kind == "stdio":
120
+ if set(raw) - {"type", "command", "args", "env"} - OPTIONS:
121
+ raise MCPConfigError("stdio 仅支持 type、command、args、env 及超时和只读提示选项。")
122
+ command, args = raw.get("command"), raw.get("args", [])
123
+ if not isinstance(command, str) or not command.strip() or "\0" in command:
124
+ raise MCPConfigError("command 必须是非空可执行文件名或路径。")
125
+ if not isinstance(args, list) or any(not isinstance(a, str) or "\0" in a for a in args):
126
+ raise MCPConfigError("args 必须是字符串列表。")
127
+ env = _expand(raw.get("env", {}), "env")
128
+ if any(not k or "=" in k or "\0" in k or "\0" in v for k, v in env.items()):
129
+ raise MCPConfigError("env 包含非法名称或空字符。")
130
+ return ServerConfig(name, kind, command, tuple(args), env, **options)
131
+ if kind == "http":
132
+ if set(raw) - {"type", "url", "headers"} - OPTIONS:
133
+ raise MCPConfigError("http 仅支持 type、url、headers 及超时和只读提示选项。")
134
+ url = raw.get("url")
135
+ try:
136
+ if not isinstance(url, str) or any(c.isspace() or ord(c) < 32 for c in url):
137
+ raise ValueError
138
+ parsed = urlsplit(url) if isinstance(url, str) else None
139
+ if (
140
+ not parsed
141
+ or parsed.scheme not in {"http", "https"}
142
+ or not parsed.hostname
143
+ or parsed.username
144
+ or parsed.password
145
+ or parsed.fragment
146
+ ):
147
+ raise ValueError
148
+ _ = parsed.port # Raises ValueError for a port out of range.
149
+ except ValueError:
150
+ raise MCPConfigError("url 必须是无嵌入凭据和 fragment 的 HTTP(S) 地址。") from None
151
+ headers = _expand(raw.get("headers", {}), "headers")
152
+ if any(
153
+ not re.fullmatch(r"[!#$%&'*+.^_`|~0-9A-Za-z-]+", k) or any(c in v for c in "\r\n\0")
154
+ for k, v in headers.items()
155
+ ):
156
+ raise MCPConfigError("headers 含非法字段或换行。")
157
+ return ServerConfig(name, kind, url=url, headers=headers, **options)
158
+ raise MCPConfigError("type 必须是 stdio 或 http(Streamable HTTP)。")
159
+
160
+
161
+ def _read_mcp_json(path: Path) -> tuple[dict, list[str]]:
162
+ """Claude Code's project file, converted to the mcp.yaml shape. Problems are notices, never fatal."""
163
+ try:
164
+ if not path.is_file():
165
+ return {}, []
166
+ raw = json.loads(path.read_text(encoding="utf-8"))
167
+ servers = raw.get("mcpServers", {}) if isinstance(raw, dict) else None
168
+ if not isinstance(servers, dict):
169
+ raise ValueError
170
+ except (OSError, UnicodeError, ValueError):
171
+ return {}, [f"MCP 配置 {path.name}:无法读取有效 UTF-8 JSON 或缺少 mcpServers 对象,已忽略。"]
172
+ converted, notices = {}, []
173
+ for name, entry in servers.items():
174
+ if not isinstance(name, str) or not re.fullmatch(SERVER_NAME, name):
175
+ notices.append(f"MCP 配置 {path.name}:Server 名称需为 1–64 位英文字母、数字、下划线或连字符,已跳过一项。")
176
+ continue
177
+ if not isinstance(entry, dict):
178
+ notices.append(f"MCP {name} 不可用:Server 配置必须是对象。")
179
+ continue
180
+ kind = entry.get("type", "stdio" if "command" in entry else None)
181
+ if kind not in {"stdio", "http"}:
182
+ notices.append(f"MCP {name} 不可用:.mcp.json 中的传输方式 {kind!s} 不受支持(支持 stdio、http)。")
183
+ continue
184
+ keys = {"command", "args", "env"} if kind == "stdio" else {"url", "headers"}
185
+ item = {"type": kind, **{k: entry[k] for k in keys | OPTIONS if k in entry}}
186
+ try:
187
+ # Claude Code expands variables in these fields too, not only env/headers.
188
+ if isinstance(item.get("command"), str):
189
+ item["command"] = substitute_env(item["command"])
190
+ if isinstance(item.get("url"), str):
191
+ item["url"] = substitute_env(item["url"])
192
+ if isinstance(item.get("args"), list):
193
+ item["args"] = [substitute_env(a) if isinstance(a, str) else a for a in item["args"]]
194
+ except MCPConfigError as exc:
195
+ notices.append(f"MCP {name} 不可用:{exc}")
196
+ continue
197
+ converted[name] = item
198
+ return converted, notices
199
+
200
+
201
+ def load_mcp_settings(root: Path, *, user_path: Path | None = None) -> MCPSettings:
202
+ user_file, project_file = config_paths(root, user_path)
203
+ user, user_import = _read(user_file)
204
+ project, project_import = _read(project_file)
205
+ merged, notices = dict(user), []
206
+ if user_import or project_import:
207
+ imported, notices = _read_mcp_json(mcp_json_path(root))
208
+ merged.update(imported)
209
+ merged.update(project) # Replace the complete server, never merge transport fields.
210
+ servers = []
211
+ for name, raw in merged.items():
212
+ try:
213
+ servers.append(_server(name, raw))
214
+ except MCPConfigError as exc:
215
+ notices.append(f"MCP {name} 不可用:{exc}")
216
+ return MCPSettings(tuple(servers), tuple(notices))