crabcode 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.
- crabcode-0.1.0.dist-info/METADATA +23 -0
- crabcode-0.1.0.dist-info/RECORD +64 -0
- crabcode-0.1.0.dist-info/WHEEL +4 -0
- crabcode-0.1.0.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +3 -0
- crabcode_cli/app.py +142 -0
- crabcode_cli/banner.py +42 -0
- crabcode_cli/pipe.py +58 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +1263 -0
- crabcode_core/__init__.py +3 -0
- crabcode_core/api/__init__.py +6 -0
- crabcode_core/api/anthropic_adapter.py +250 -0
- crabcode_core/api/base.py +66 -0
- crabcode_core/api/codex_adapter.py +390 -0
- crabcode_core/api/openai_adapter.py +221 -0
- crabcode_core/api/registry.py +40 -0
- crabcode_core/compact/__init__.py +7 -0
- crabcode_core/compact/compact.py +150 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +53 -0
- crabcode_core/config/manager.py +152 -0
- crabcode_core/events.py +392 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +163 -0
- crabcode_core/mcp/config.py +52 -0
- crabcode_core/permissions/__init__.py +3 -0
- crabcode_core/permissions/manager.py +106 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/context.py +209 -0
- crabcode_core/prompts/profile.py +66 -0
- crabcode_core/prompts/system.py +405 -0
- crabcode_core/prompts/templates.py +60 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/loop.py +442 -0
- crabcode_core/session/__init__.py +3 -0
- crabcode_core/session/storage.py +153 -0
- crabcode_core/skills/__init__.py +5 -0
- crabcode_core/skills/loader.py +147 -0
- crabcode_core/tools/__init__.py +29 -0
- crabcode_core/tools/agent.py +160 -0
- crabcode_core/tools/bash.py +127 -0
- crabcode_core/tools/diff_utils.py +120 -0
- crabcode_core/tools/file_edit.py +145 -0
- crabcode_core/tools/file_read.py +123 -0
- crabcode_core/tools/file_write.py +102 -0
- crabcode_core/tools/glob.py +109 -0
- crabcode_core/tools/grep.py +130 -0
- crabcode_core/tools/lint.py +368 -0
- crabcode_core/tools/memory.py +236 -0
- crabcode_core/tools/skill.py +112 -0
- crabcode_core/types/__init__.py +73 -0
- crabcode_core/types/config.py +103 -0
- crabcode_core/types/event.py +105 -0
- crabcode_core/types/message.py +177 -0
- crabcode_core/types/tool.py +138 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +371 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +447 -0
- crabcode_search/indexer.py +212 -0
- crabcode_search/store.py +222 -0
- crabcode_search/tool.py +232 -0
crabcode_core/events.py
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""Core session — the main interface between frontends and the engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any, AsyncGenerator
|
|
8
|
+
|
|
9
|
+
from crabcode_core.types.config import CrabCodeSettings
|
|
10
|
+
from crabcode_core.types.event import (
|
|
11
|
+
CompactEvent,
|
|
12
|
+
CoreEvent,
|
|
13
|
+
PermissionResponseEvent,
|
|
14
|
+
)
|
|
15
|
+
from crabcode_core.types.message import Message
|
|
16
|
+
from crabcode_core.types.tool import Tool, ToolEventCallback
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CoreSession:
|
|
20
|
+
"""Main entry point for frontends to interact with CrabCode.
|
|
21
|
+
|
|
22
|
+
Holds conversation state, tools, and configuration.
|
|
23
|
+
Frontends create a CoreSession and call send_message() to get
|
|
24
|
+
an async stream of CoreEvents.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
cwd: str = ".",
|
|
30
|
+
settings: CrabCodeSettings | None = None,
|
|
31
|
+
tools: list[Tool] | None = None,
|
|
32
|
+
):
|
|
33
|
+
self.cwd = os.path.abspath(cwd)
|
|
34
|
+
self.settings = settings or CrabCodeSettings()
|
|
35
|
+
self.messages: list[Message] = []
|
|
36
|
+
self.tools: list[Tool] = tools or []
|
|
37
|
+
self.session_id: str = ""
|
|
38
|
+
self._permission_queue: asyncio.Queue[PermissionResponseEvent] = asyncio.Queue()
|
|
39
|
+
self._abort_controller: asyncio.Event = asyncio.Event()
|
|
40
|
+
|
|
41
|
+
self.skills: list = []
|
|
42
|
+
self.on_tool_event: ToolEventCallback | None = None
|
|
43
|
+
|
|
44
|
+
self._api_adapter: Any = None
|
|
45
|
+
self._session_storage: Any = None
|
|
46
|
+
self._permission_manager: Any = None
|
|
47
|
+
self._mcp_manager: Any = None
|
|
48
|
+
self._prompt_profile: Any = None
|
|
49
|
+
self._initialized = False
|
|
50
|
+
self._current_model_name: str | None = None
|
|
51
|
+
self.compact_count: int = 0
|
|
52
|
+
|
|
53
|
+
async def initialize(self) -> None:
|
|
54
|
+
"""Late initialization: set up API adapter, load tools, MCP, etc."""
|
|
55
|
+
if self._initialized:
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
from crabcode_core.api import create_adapter
|
|
59
|
+
from crabcode_core.config.manager import ConfigManager
|
|
60
|
+
from crabcode_core.mcp.client import McpManager
|
|
61
|
+
from crabcode_core.mcp.config import load_mcp_configs
|
|
62
|
+
from crabcode_core.permissions.manager import PermissionManager
|
|
63
|
+
from crabcode_core.session.storage import SessionStorage, generate_session_id
|
|
64
|
+
from crabcode_core.tools import get_default_tools
|
|
65
|
+
|
|
66
|
+
config_mgr = ConfigManager(cwd=self.cwd)
|
|
67
|
+
file_settings = config_mgr.load()
|
|
68
|
+
|
|
69
|
+
merged = self.settings
|
|
70
|
+
|
|
71
|
+
for key, val in file_settings.env.items():
|
|
72
|
+
os.environ.setdefault(key, val)
|
|
73
|
+
|
|
74
|
+
if file_settings.api.provider and not self.settings.api.provider:
|
|
75
|
+
merged.api.provider = file_settings.api.provider
|
|
76
|
+
if file_settings.api.model and not self.settings.api.model:
|
|
77
|
+
merged.api.model = file_settings.api.model
|
|
78
|
+
if file_settings.api.base_url and not self.settings.api.base_url:
|
|
79
|
+
merged.api.base_url = file_settings.api.base_url
|
|
80
|
+
if file_settings.api.api_key_env and not self.settings.api.api_key_env:
|
|
81
|
+
merged.api.api_key_env = file_settings.api.api_key_env
|
|
82
|
+
if file_settings.api.format and not self.settings.api.format:
|
|
83
|
+
merged.api.format = file_settings.api.format
|
|
84
|
+
if file_settings.api.thinking_enabled is False and self.settings.api.thinking_enabled:
|
|
85
|
+
merged.api.thinking_enabled = file_settings.api.thinking_enabled
|
|
86
|
+
if file_settings.api.max_tokens != 16384 and self.settings.api.max_tokens == 16384:
|
|
87
|
+
merged.api.max_tokens = file_settings.api.max_tokens
|
|
88
|
+
|
|
89
|
+
if file_settings.models:
|
|
90
|
+
for name, cfg in file_settings.models.items():
|
|
91
|
+
merged.models.setdefault(name, cfg)
|
|
92
|
+
if file_settings.default_model and not merged.default_model:
|
|
93
|
+
merged.default_model = file_settings.default_model
|
|
94
|
+
|
|
95
|
+
if file_settings.extra_tools and not self.settings.extra_tools:
|
|
96
|
+
merged.extra_tools = file_settings.extra_tools
|
|
97
|
+
if file_settings.tool_settings and not self.settings.tool_settings:
|
|
98
|
+
merged.tool_settings = file_settings.tool_settings
|
|
99
|
+
elif file_settings.tool_settings:
|
|
100
|
+
for name, cfg in file_settings.tool_settings.items():
|
|
101
|
+
merged.tool_settings.setdefault(name, {}).update(cfg)
|
|
102
|
+
|
|
103
|
+
# Keep a /model switch that ran before the first initialize() (late init).
|
|
104
|
+
chosen = self._current_model_name
|
|
105
|
+
if chosen is None or chosen not in merged.models:
|
|
106
|
+
chosen = merged.default_model
|
|
107
|
+
self._current_model_name = chosen
|
|
108
|
+
active_api_config = merged.get_api_config(self._current_model_name)
|
|
109
|
+
self._api_adapter = create_adapter(active_api_config)
|
|
110
|
+
|
|
111
|
+
if not self.tools:
|
|
112
|
+
self.tools = get_default_tools()
|
|
113
|
+
|
|
114
|
+
self.session_id = generate_session_id()
|
|
115
|
+
self._session_storage = SessionStorage(self.cwd, self.session_id)
|
|
116
|
+
|
|
117
|
+
self._permission_manager = PermissionManager(
|
|
118
|
+
settings=merged.permissions,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
mcp_configs = load_mcp_configs(self.cwd)
|
|
122
|
+
all_mcp_configs = {**mcp_configs}
|
|
123
|
+
for name, cfg in merged.mcp_servers.items():
|
|
124
|
+
if name not in all_mcp_configs:
|
|
125
|
+
all_mcp_configs[name] = cfg
|
|
126
|
+
|
|
127
|
+
if all_mcp_configs:
|
|
128
|
+
self._mcp_manager = McpManager()
|
|
129
|
+
mcp_tools = await self._mcp_manager.connect(all_mcp_configs)
|
|
130
|
+
existing_names = {t.name for t in self.tools}
|
|
131
|
+
for mcp_tool in mcp_tools:
|
|
132
|
+
if mcp_tool.name not in existing_names:
|
|
133
|
+
self.tools.append(mcp_tool)
|
|
134
|
+
|
|
135
|
+
import importlib
|
|
136
|
+
for tool_path in merged.extra_tools:
|
|
137
|
+
try:
|
|
138
|
+
module_path, class_name = tool_path.rsplit(".", 1)
|
|
139
|
+
mod = importlib.import_module(module_path)
|
|
140
|
+
tool_cls = getattr(mod, class_name)
|
|
141
|
+
self.tools.append(tool_cls())
|
|
142
|
+
except Exception:
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
from crabcode_core.types.tool import ToolContext as _ToolContext
|
|
146
|
+
|
|
147
|
+
async def _setup_tool(tool: Tool) -> None:
|
|
148
|
+
ctx = _ToolContext(
|
|
149
|
+
cwd=self.cwd,
|
|
150
|
+
env=merged.env,
|
|
151
|
+
on_event=self.on_tool_event,
|
|
152
|
+
tool_config=merged.tool_settings.get(tool.name, {}),
|
|
153
|
+
)
|
|
154
|
+
await tool.setup(ctx)
|
|
155
|
+
|
|
156
|
+
await asyncio.gather(*(_setup_tool(t) for t in self.tools))
|
|
157
|
+
|
|
158
|
+
from crabcode_core.prompts.profile import PromptProfile
|
|
159
|
+
from crabcode_core.tools.agent import AgentTool
|
|
160
|
+
|
|
161
|
+
if self.settings.prompt_profile:
|
|
162
|
+
self._prompt_profile = PromptProfile(**self.settings.prompt_profile)
|
|
163
|
+
|
|
164
|
+
has_agent = any(isinstance(t, AgentTool) for t in self.tools)
|
|
165
|
+
if not has_agent:
|
|
166
|
+
sub_tools = list(self.tools)
|
|
167
|
+
agent_cfg = merged.agent
|
|
168
|
+
self.tools.append(AgentTool(
|
|
169
|
+
api_adapter=self._api_adapter,
|
|
170
|
+
tools=sub_tools,
|
|
171
|
+
prompt_profile=self._prompt_profile,
|
|
172
|
+
max_turns=agent_cfg.max_turns,
|
|
173
|
+
timeout=agent_cfg.timeout,
|
|
174
|
+
max_output_chars=agent_cfg.max_output_chars,
|
|
175
|
+
max_display_lines=merged.display.get_max_lines("Agent"),
|
|
176
|
+
))
|
|
177
|
+
|
|
178
|
+
from crabcode_core.skills.loader import load_skills
|
|
179
|
+
from crabcode_core.tools.skill import SkillTool
|
|
180
|
+
|
|
181
|
+
self.skills = load_skills(self.cwd)
|
|
182
|
+
if self.skills:
|
|
183
|
+
self.tools.append(SkillTool(self.skills))
|
|
184
|
+
|
|
185
|
+
await asyncio.gather(*(t.resolve_prompt() for t in self.tools))
|
|
186
|
+
|
|
187
|
+
self._initialized = True
|
|
188
|
+
|
|
189
|
+
async def send_message(
|
|
190
|
+
self,
|
|
191
|
+
text: str,
|
|
192
|
+
max_turns: int = 0,
|
|
193
|
+
) -> AsyncGenerator[CoreEvent, None]:
|
|
194
|
+
"""Send a user message and stream back events."""
|
|
195
|
+
await self.initialize()
|
|
196
|
+
self._abort_controller.clear()
|
|
197
|
+
|
|
198
|
+
from crabcode_core.compact.compact import should_auto_compact, compact_conversation
|
|
199
|
+
from crabcode_core.prompts.context import get_system_context, get_user_context
|
|
200
|
+
from crabcode_core.prompts.profile import PromptProfile
|
|
201
|
+
from crabcode_core.prompts.system import get_system_prompt
|
|
202
|
+
from crabcode_core.query.loop import QueryParams, query_loop
|
|
203
|
+
from crabcode_core.types.event import CompactEvent, TurnCompleteEvent
|
|
204
|
+
from crabcode_core.types.message import create_user_message
|
|
205
|
+
from crabcode_core.types.tool import ToolContext
|
|
206
|
+
|
|
207
|
+
user_msg = create_user_message(content=text)
|
|
208
|
+
self.messages.append(user_msg)
|
|
209
|
+
|
|
210
|
+
if self._session_storage:
|
|
211
|
+
self._session_storage.append_message(user_msg)
|
|
212
|
+
|
|
213
|
+
compact_kwargs: dict[str, Any] = {}
|
|
214
|
+
if self.settings.max_context_length is not None:
|
|
215
|
+
compact_kwargs["threshold"] = self.settings.max_context_length
|
|
216
|
+
|
|
217
|
+
if self.settings.auto_compact_enabled and should_auto_compact(self.messages, **compact_kwargs):
|
|
218
|
+
compact_result = await compact_conversation(
|
|
219
|
+
self.messages,
|
|
220
|
+
api_adapter=self._api_adapter,
|
|
221
|
+
)
|
|
222
|
+
if compact_result:
|
|
223
|
+
old_count = len(self.messages)
|
|
224
|
+
self.messages = compact_result
|
|
225
|
+
self.compact_count += 1
|
|
226
|
+
yield CompactEvent(
|
|
227
|
+
summary="Conversation auto-compacted",
|
|
228
|
+
messages_before=old_count,
|
|
229
|
+
messages_after=len(self.messages),
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
tool_names = [t.name for t in self.tools]
|
|
233
|
+
active_api_cfg = self.settings.get_api_config(self._current_model_name)
|
|
234
|
+
model = active_api_cfg.model or "claude-sonnet-4-20250514"
|
|
235
|
+
|
|
236
|
+
profile: PromptProfile | None = None
|
|
237
|
+
if self.settings.prompt_profile:
|
|
238
|
+
profile = PromptProfile(**self.settings.prompt_profile)
|
|
239
|
+
|
|
240
|
+
system_prompt = get_system_prompt(
|
|
241
|
+
enabled_tools=tool_names,
|
|
242
|
+
model_id=model,
|
|
243
|
+
cwd=self.cwd,
|
|
244
|
+
language=self.settings.language,
|
|
245
|
+
profile=profile,
|
|
246
|
+
)
|
|
247
|
+
system_context = get_system_context(self.cwd)
|
|
248
|
+
user_context = get_user_context(self.cwd)
|
|
249
|
+
|
|
250
|
+
tool_context = ToolContext(
|
|
251
|
+
cwd=self.cwd,
|
|
252
|
+
messages=self.messages,
|
|
253
|
+
session_id=self.session_id,
|
|
254
|
+
env=self.settings.env,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
params = QueryParams(
|
|
258
|
+
messages=list(self.messages),
|
|
259
|
+
system_prompt=system_prompt,
|
|
260
|
+
user_context=user_context,
|
|
261
|
+
system_context=system_context,
|
|
262
|
+
tools=self.tools,
|
|
263
|
+
tool_context=tool_context,
|
|
264
|
+
api_adapter=self._api_adapter,
|
|
265
|
+
max_turns=max_turns or 0,
|
|
266
|
+
permission_manager=self._permission_manager,
|
|
267
|
+
permission_queue=self._permission_queue,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
pre_loop_count = len(self.messages)
|
|
271
|
+
|
|
272
|
+
async for event in query_loop(params):
|
|
273
|
+
if isinstance(event, TurnCompleteEvent):
|
|
274
|
+
self.messages = params.messages
|
|
275
|
+
|
|
276
|
+
if self._session_storage:
|
|
277
|
+
for msg in self.messages[pre_loop_count:]:
|
|
278
|
+
self._session_storage.append_message(msg)
|
|
279
|
+
|
|
280
|
+
yield event
|
|
281
|
+
|
|
282
|
+
async def respond_permission(self, response: PermissionResponseEvent) -> None:
|
|
283
|
+
await self._permission_queue.put(response)
|
|
284
|
+
|
|
285
|
+
async def interrupt(self) -> None:
|
|
286
|
+
self._abort_controller.set()
|
|
287
|
+
|
|
288
|
+
def record_partial_assistant_output(self, text: str) -> None:
|
|
289
|
+
"""Append assistant text when a turn stops mid-stream so the next round keeps context."""
|
|
290
|
+
if not text or not text.strip():
|
|
291
|
+
return
|
|
292
|
+
from crabcode_core.types.message import TextBlock, create_assistant_message
|
|
293
|
+
|
|
294
|
+
assistant_msg = create_assistant_message(content=[TextBlock(text=text)])
|
|
295
|
+
self.messages.append(assistant_msg)
|
|
296
|
+
if self._session_storage:
|
|
297
|
+
self._session_storage.append_message(assistant_msg)
|
|
298
|
+
|
|
299
|
+
def new_session(self) -> str:
|
|
300
|
+
"""Start a fresh session, preserving tools and config. Returns the new session ID."""
|
|
301
|
+
from crabcode_core.session.storage import SessionStorage, generate_session_id
|
|
302
|
+
|
|
303
|
+
self.messages.clear()
|
|
304
|
+
self.session_id = generate_session_id()
|
|
305
|
+
self._session_storage = SessionStorage(self.cwd, self.session_id)
|
|
306
|
+
return self.session_id
|
|
307
|
+
|
|
308
|
+
async def compact(self) -> None:
|
|
309
|
+
"""Manually trigger conversation compaction."""
|
|
310
|
+
from crabcode_core.compact.compact import compact_conversation
|
|
311
|
+
result = await compact_conversation(
|
|
312
|
+
self.messages,
|
|
313
|
+
api_adapter=self._api_adapter,
|
|
314
|
+
)
|
|
315
|
+
if result:
|
|
316
|
+
self.messages = result
|
|
317
|
+
self.compact_count += 1
|
|
318
|
+
|
|
319
|
+
def list_models(self) -> dict[str, str]:
|
|
320
|
+
"""Return a dict of {name -> description} for all configured named models.
|
|
321
|
+
|
|
322
|
+
The description is "<provider>/<model>" or just the model id if available.
|
|
323
|
+
"""
|
|
324
|
+
result: dict[str, str] = {}
|
|
325
|
+
for name, cfg in self.settings.models.items():
|
|
326
|
+
parts = []
|
|
327
|
+
if cfg.provider:
|
|
328
|
+
parts.append(cfg.provider)
|
|
329
|
+
if cfg.model:
|
|
330
|
+
parts.append(cfg.model)
|
|
331
|
+
result[name] = "/".join(parts) if parts else "(no model set)"
|
|
332
|
+
return result
|
|
333
|
+
|
|
334
|
+
def switch_model(self, name: str) -> bool:
|
|
335
|
+
"""Switch to a named model defined in settings.models.
|
|
336
|
+
|
|
337
|
+
Returns True on success, False if the name is not found.
|
|
338
|
+
Must be called after initialize().
|
|
339
|
+
"""
|
|
340
|
+
if name not in self.settings.models:
|
|
341
|
+
return False
|
|
342
|
+
|
|
343
|
+
from crabcode_core.api import create_adapter
|
|
344
|
+
from crabcode_core.tools.agent import AgentTool
|
|
345
|
+
|
|
346
|
+
api_config = self.settings.models[name]
|
|
347
|
+
self._api_adapter = create_adapter(api_config)
|
|
348
|
+
self._current_model_name = name
|
|
349
|
+
|
|
350
|
+
for tool in self.tools:
|
|
351
|
+
if isinstance(tool, AgentTool):
|
|
352
|
+
tool.api_adapter = self._api_adapter
|
|
353
|
+
|
|
354
|
+
return True
|
|
355
|
+
|
|
356
|
+
async def resume(self, session_id: str) -> bool:
|
|
357
|
+
"""Resume a previous session by loading its messages."""
|
|
358
|
+
from crabcode_core.session.storage import SessionStorage
|
|
359
|
+
from crabcode_core.types.message import (
|
|
360
|
+
create_assistant_message,
|
|
361
|
+
create_user_message,
|
|
362
|
+
deserialize_content,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
storage = SessionStorage(self.cwd, session_id)
|
|
366
|
+
raw_messages = storage.load_messages()
|
|
367
|
+
|
|
368
|
+
if not raw_messages:
|
|
369
|
+
return False
|
|
370
|
+
|
|
371
|
+
self.session_id = session_id
|
|
372
|
+
self._session_storage = storage
|
|
373
|
+
self.messages.clear()
|
|
374
|
+
|
|
375
|
+
for raw in raw_messages:
|
|
376
|
+
role = raw.get("type", "user")
|
|
377
|
+
content = deserialize_content(raw.get("content", ""))
|
|
378
|
+
msg_uuid = raw.get("uuid")
|
|
379
|
+
parent_uuid = raw.get("parent_uuid")
|
|
380
|
+
|
|
381
|
+
kwargs: dict[str, Any] = {}
|
|
382
|
+
if msg_uuid:
|
|
383
|
+
kwargs["uuid"] = msg_uuid
|
|
384
|
+
if parent_uuid:
|
|
385
|
+
kwargs["parent_uuid"] = parent_uuid
|
|
386
|
+
|
|
387
|
+
if role == "user":
|
|
388
|
+
self.messages.append(create_user_message(content=content, **kwargs))
|
|
389
|
+
elif role == "assistant":
|
|
390
|
+
self.messages.append(create_assistant_message(content=content, **kwargs))
|
|
391
|
+
|
|
392
|
+
return True
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""MCP client — connects to MCP servers and loads tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from crabcode_core.types.config import McpServerConfig
|
|
12
|
+
from crabcode_core.types.tool import Tool, ToolContext, ToolResult
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _normalize_name(name: str) -> str:
|
|
16
|
+
"""Normalize a server/tool name to a valid identifier."""
|
|
17
|
+
return re.sub(r'[^a-zA-Z0-9_]', '_', name)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_mcp_tool_name(server_name: str, tool_name: str) -> str:
|
|
21
|
+
"""Build the prefixed MCP tool name."""
|
|
22
|
+
return f"mcp__{_normalize_name(server_name)}__{_normalize_name(tool_name)}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class McpToolWrapper(Tool):
|
|
26
|
+
"""Wraps an MCP tool as a CrabCode Tool."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
server_name: str,
|
|
31
|
+
tool_name: str,
|
|
32
|
+
description: str,
|
|
33
|
+
schema: dict[str, Any],
|
|
34
|
+
client: Any,
|
|
35
|
+
):
|
|
36
|
+
self.name = build_mcp_tool_name(server_name, tool_name)
|
|
37
|
+
self.description = description
|
|
38
|
+
self.input_schema = schema
|
|
39
|
+
self.is_read_only = False
|
|
40
|
+
self.is_concurrency_safe = True
|
|
41
|
+
self.is_enabled = True
|
|
42
|
+
|
|
43
|
+
self._server_name = server_name
|
|
44
|
+
self._original_name = tool_name
|
|
45
|
+
self._client = client
|
|
46
|
+
|
|
47
|
+
async def call(
|
|
48
|
+
self,
|
|
49
|
+
tool_input: dict[str, Any],
|
|
50
|
+
context: ToolContext,
|
|
51
|
+
) -> ToolResult:
|
|
52
|
+
try:
|
|
53
|
+
result = await self._client.call_tool(self._original_name, tool_input)
|
|
54
|
+
|
|
55
|
+
text_parts: list[str] = []
|
|
56
|
+
if hasattr(result, "content"):
|
|
57
|
+
for block in result.content:
|
|
58
|
+
if hasattr(block, "text"):
|
|
59
|
+
text_parts.append(block.text)
|
|
60
|
+
|
|
61
|
+
output = "\n".join(text_parts) if text_parts else str(result)
|
|
62
|
+
is_error = getattr(result, "isError", False)
|
|
63
|
+
|
|
64
|
+
return ToolResult(
|
|
65
|
+
data=result,
|
|
66
|
+
result_for_model=output,
|
|
67
|
+
is_error=is_error,
|
|
68
|
+
)
|
|
69
|
+
except Exception as e:
|
|
70
|
+
return ToolResult(
|
|
71
|
+
result_for_model=f"MCP tool error: {e}",
|
|
72
|
+
is_error=True,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class McpManager:
|
|
77
|
+
"""Manages MCP server connections and tool discovery."""
|
|
78
|
+
|
|
79
|
+
def __init__(self) -> None:
|
|
80
|
+
self._connections: dict[str, Any] = {}
|
|
81
|
+
self._tools: list[McpToolWrapper] = []
|
|
82
|
+
|
|
83
|
+
async def connect(
|
|
84
|
+
self,
|
|
85
|
+
servers: dict[str, McpServerConfig],
|
|
86
|
+
) -> list[McpToolWrapper]:
|
|
87
|
+
"""Connect to all configured MCP servers and fetch tools."""
|
|
88
|
+
all_tools: list[McpToolWrapper] = []
|
|
89
|
+
|
|
90
|
+
for name, config in servers.items():
|
|
91
|
+
if config.disabled:
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
tools = await self._connect_server(name, config)
|
|
96
|
+
all_tools.extend(tools)
|
|
97
|
+
except Exception:
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
self._tools = all_tools
|
|
101
|
+
return all_tools
|
|
102
|
+
|
|
103
|
+
async def _connect_server(
|
|
104
|
+
self,
|
|
105
|
+
name: str,
|
|
106
|
+
config: McpServerConfig,
|
|
107
|
+
) -> list[McpToolWrapper]:
|
|
108
|
+
"""Connect to a single MCP server."""
|
|
109
|
+
try:
|
|
110
|
+
from mcp import ClientSession, StdioServerParameters
|
|
111
|
+
from mcp.client.stdio import stdio_client
|
|
112
|
+
except ImportError:
|
|
113
|
+
return []
|
|
114
|
+
|
|
115
|
+
if not config.command:
|
|
116
|
+
return []
|
|
117
|
+
|
|
118
|
+
env = {**os.environ, **config.env}
|
|
119
|
+
server_params = StdioServerParameters(
|
|
120
|
+
command=config.command[0],
|
|
121
|
+
args=config.command[1:] if len(config.command) > 1 else [],
|
|
122
|
+
env=env,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
read_stream, write_stream = await asyncio.wait_for(
|
|
127
|
+
stdio_client(server_params).__aenter__(),
|
|
128
|
+
timeout=30,
|
|
129
|
+
)
|
|
130
|
+
session = ClientSession(read_stream, write_stream)
|
|
131
|
+
await session.initialize()
|
|
132
|
+
self._connections[name] = session
|
|
133
|
+
|
|
134
|
+
tools_response = await session.list_tools()
|
|
135
|
+
tools: list[McpToolWrapper] = []
|
|
136
|
+
for tool_info in tools_response.tools:
|
|
137
|
+
wrapper = McpToolWrapper(
|
|
138
|
+
server_name=name,
|
|
139
|
+
tool_name=tool_info.name,
|
|
140
|
+
description=tool_info.description or "",
|
|
141
|
+
schema=tool_info.inputSchema or {"type": "object", "properties": {}},
|
|
142
|
+
client=session,
|
|
143
|
+
)
|
|
144
|
+
tools.append(wrapper)
|
|
145
|
+
|
|
146
|
+
return tools
|
|
147
|
+
|
|
148
|
+
except Exception:
|
|
149
|
+
return []
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def tools(self) -> list[McpToolWrapper]:
|
|
153
|
+
return self._tools
|
|
154
|
+
|
|
155
|
+
async def disconnect_all(self) -> None:
|
|
156
|
+
"""Disconnect from all MCP servers."""
|
|
157
|
+
for session in self._connections.values():
|
|
158
|
+
try:
|
|
159
|
+
await session.__aexit__(None, None, None)
|
|
160
|
+
except Exception:
|
|
161
|
+
pass
|
|
162
|
+
self._connections.clear()
|
|
163
|
+
self._tools.clear()
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""MCP configuration — multi-scope server config loading and merging."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from crabcode_core.types.config import McpServerConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def load_mcp_configs(cwd: str) -> dict[str, McpServerConfig]:
|
|
13
|
+
"""Load and merge MCP server configurations from multiple scopes.
|
|
14
|
+
|
|
15
|
+
Scopes (later overrides earlier):
|
|
16
|
+
1. User (~/.crabcode/mcp_servers.json)
|
|
17
|
+
2. Project (<project>/.crabcode/mcp_servers.json)
|
|
18
|
+
3. Local (<project>/.crabcode/mcp_servers.local.json)
|
|
19
|
+
"""
|
|
20
|
+
merged: dict[str, dict[str, Any]] = {}
|
|
21
|
+
home = Path.home()
|
|
22
|
+
|
|
23
|
+
paths = [
|
|
24
|
+
home / ".crabcode" / "mcp_servers.json",
|
|
25
|
+
home / ".claude" / "mcp_servers.json",
|
|
26
|
+
Path(cwd).resolve() / ".crabcode" / "mcp_servers.json",
|
|
27
|
+
Path(cwd).resolve() / ".claude" / "mcp_servers.json",
|
|
28
|
+
Path(cwd).resolve() / ".crabcode" / "mcp_servers.local.json",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
for path in paths:
|
|
32
|
+
if not path.exists():
|
|
33
|
+
continue
|
|
34
|
+
try:
|
|
35
|
+
raw = json.loads(path.read_text(errors="replace"))
|
|
36
|
+
if isinstance(raw, dict):
|
|
37
|
+
servers = raw.get("mcpServers", raw)
|
|
38
|
+
if isinstance(servers, dict):
|
|
39
|
+
for name, config in servers.items():
|
|
40
|
+
if isinstance(config, dict):
|
|
41
|
+
merged[name] = {**merged.get(name, {}), **config}
|
|
42
|
+
except (json.JSONDecodeError, OSError):
|
|
43
|
+
continue
|
|
44
|
+
|
|
45
|
+
result: dict[str, McpServerConfig] = {}
|
|
46
|
+
for name, raw in merged.items():
|
|
47
|
+
try:
|
|
48
|
+
result[name] = McpServerConfig.model_validate(raw)
|
|
49
|
+
except Exception:
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
return result
|