agent-dispatch 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.
@@ -0,0 +1,710 @@
1
+ """MCP server: exposes list_agents, dispatch, dispatch_session tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import queue
9
+ import sys
10
+
11
+ from mcp.server.fastmcp import Context, FastMCP
12
+
13
+ from . import runner
14
+ from .cache import DispatchCache
15
+ from .config import auto_describe, load_config, save_config
16
+ from .models import AgentConfig, DispatchConfig, validate_agent_name
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ mcp = FastMCP(
21
+ "agent-dispatch",
22
+ instructions=(
23
+ "This server lets you delegate tasks to Claude Code agents in other project "
24
+ "directories. Each agent has its own MCP servers, CLAUDE.md, and tools.\n\n"
25
+ "WHEN TO DISPATCH: Use dispatch when a task needs tools, files, or context "
26
+ "from another project — database queries, container logs, API calls, reading "
27
+ "code you don't have access to. Don't dispatch for things you can do yourself.\n\n"
28
+ "HOW TO USE:\n"
29
+ "1. list_agents() — see who's available and what they can do\n"
30
+ "2. dispatch(agent, task) — delegate a specific task\n"
31
+ "3. Always pass caller= (your project name) and goal= (why you need this)\n"
32
+ "4. Be specific in the task — the agent doesn't see your conversation\n\n"
33
+ "MANAGING AGENTS: Use add_agent() to register a new project directory. "
34
+ "The description is auto-generated from the project's files (CLAUDE.md, "
35
+ "MCP servers, package files). You can also provide a custom description."
36
+ ),
37
+ )
38
+
39
+ _cache: DispatchCache | None = None
40
+ _semaphore: asyncio.Semaphore | None = None
41
+ _semaphore_limit: int = 0
42
+
43
+ _RESOLVED_MARKER = "[RESOLVED]"
44
+
45
+
46
+ def _get_config() -> DispatchConfig:
47
+ """Load config fresh each call so new agents are picked up immediately."""
48
+ return load_config()
49
+
50
+
51
+ def _get_cache(config: DispatchConfig) -> DispatchCache | None:
52
+ """Return the global cache instance, creating it on first call."""
53
+ global _cache # noqa: PLW0603
54
+ if not config.settings.cache.enabled:
55
+ return None
56
+ if _cache is None or _cache._ttl != config.settings.cache.ttl:
57
+ _cache = DispatchCache(ttl=config.settings.cache.ttl)
58
+ return _cache
59
+
60
+
61
+ def _get_semaphore(config: DispatchConfig) -> asyncio.Semaphore:
62
+ """Return concurrency-limiting semaphore, recreated if limit changes."""
63
+ global _semaphore, _semaphore_limit # noqa: PLW0603
64
+ limit = config.settings.max_concurrency
65
+ if _semaphore is None or _semaphore_limit != limit:
66
+ _semaphore = asyncio.Semaphore(limit)
67
+ _semaphore_limit = limit
68
+ return _semaphore
69
+
70
+
71
+ def _validate_agent(config: DispatchConfig, name: str) -> str | None:
72
+ """Return an error JSON string if the agent doesn't exist, else None."""
73
+ if name not in config.agents:
74
+ available = ", ".join(config.agents.keys()) or "(none configured)"
75
+ return json.dumps({"error": f"Unknown agent: {name!r}. Available: {available}"})
76
+ return None
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # MCP Tools
81
+ # ---------------------------------------------------------------------------
82
+
83
+
84
+ @mcp.tool()
85
+ async def list_agents(ctx: Context) -> str:
86
+ """List all configured agents with descriptions and health status.
87
+
88
+ Call this first to see which agents are available and what they can do.
89
+ Use the agent name in dispatch() or dispatch_session() calls.
90
+ """
91
+ config = _get_config()
92
+ if not config.agents:
93
+ return json.dumps(
94
+ {"error": "No agents configured. Run: agent-dispatch add <name> <directory>"},
95
+ indent=2,
96
+ )
97
+
98
+ agents = []
99
+ for name, agent in config.agents.items():
100
+ healthy = agent.directory.is_dir()
101
+ agents.append(
102
+ {
103
+ "name": name,
104
+ "directory": str(agent.directory),
105
+ "description": agent.description,
106
+ "healthy": healthy,
107
+ "has_claude_md": (agent.directory / "CLAUDE.md").exists() if healthy else False,
108
+ "has_mcp_config": (agent.directory / ".mcp.json").exists() if healthy else False,
109
+ }
110
+ )
111
+ await ctx.info(f"Found {len(agents)} configured agents")
112
+ return json.dumps(agents, indent=2)
113
+
114
+
115
+ @mcp.tool()
116
+ async def dispatch(
117
+ agent: str,
118
+ task: str,
119
+ context: str = "",
120
+ caller: str = "",
121
+ goal: str = "",
122
+ ctx: Context | None = None,
123
+ ) -> str:
124
+ """Delegate a task to an agent in another project directory.
125
+
126
+ The agent runs as a separate Claude Code session with its own MCP servers,
127
+ CLAUDE.md, and project context. Results are cached by default.
128
+
129
+ Args:
130
+ agent: Name of the agent (from list_agents).
131
+ task: The task to perform. Be specific and self-contained.
132
+ context: Optional extra context — error messages, code snippets, etc.
133
+ caller: Who is dispatching (your project/role) — helps the agent
134
+ understand the request.
135
+ goal: The broader objective this task serves — the agent can make
136
+ better trade-offs when it knows *why*.
137
+ """
138
+ config = _get_config()
139
+ if err := _validate_agent(config, agent):
140
+ return err
141
+
142
+ # Check cache
143
+ cache = _get_cache(config)
144
+ if cache:
145
+ cached = cache.get(agent, task, context or None)
146
+ if cached:
147
+ if ctx:
148
+ await ctx.info(f"Cache hit for {agent} — returning cached result")
149
+ cached_dict = json.loads(cached.model_dump_json(indent=2, exclude_none=True))
150
+ cached_dict["cached"] = True
151
+ return json.dumps(cached_dict, indent=2)
152
+
153
+ agent_config = config.agents[agent]
154
+ if ctx:
155
+ await ctx.info(f"Dispatching to {agent}: {task[:80]}...")
156
+
157
+ async with _get_semaphore(config):
158
+ result = await asyncio.to_thread(
159
+ runner.dispatch,
160
+ agent,
161
+ task,
162
+ agent_config,
163
+ config.settings,
164
+ context or None,
165
+ caller=caller or None,
166
+ goal=goal or None,
167
+ )
168
+
169
+ # Populate cache
170
+ if cache:
171
+ cache.put(agent, task, result, context or None)
172
+
173
+ return result.model_dump_json(indent=2, exclude_none=True)
174
+
175
+
176
+ @mcp.tool()
177
+ async def dispatch_session(
178
+ agent: str,
179
+ task: str,
180
+ session_id: str = "",
181
+ context: str = "",
182
+ caller: str = "",
183
+ goal: str = "",
184
+ ctx: Context | None = None,
185
+ ) -> str:
186
+ """Multi-turn dispatch: continue a conversation with an agent.
187
+
188
+ First call without session_id starts a new session. Use the returned
189
+ session_id in subsequent calls to continue the conversation — the agent
190
+ retains full context from previous turns.
191
+
192
+ Session dispatches are never cached because each turn builds on the prior.
193
+
194
+ Args:
195
+ agent: Name of the agent.
196
+ task: The task or follow-up message.
197
+ session_id: Session ID from a previous call (empty for new session).
198
+ context: Optional extra context.
199
+ caller: Who is dispatching.
200
+ goal: The broader objective.
201
+ """
202
+ config = _get_config()
203
+ if err := _validate_agent(config, agent):
204
+ return err
205
+
206
+ agent_config = config.agents[agent]
207
+ if ctx:
208
+ turn = "new session" if not session_id else f"resuming {session_id[:12]}..."
209
+ await ctx.info(f"Dispatching to {agent} ({turn}): {task[:80]}...")
210
+
211
+ async with _get_semaphore(config):
212
+ result = await asyncio.to_thread(
213
+ runner.dispatch,
214
+ agent,
215
+ task,
216
+ agent_config,
217
+ config.settings,
218
+ context or None,
219
+ session_id or None,
220
+ caller=caller or None,
221
+ goal=goal or None,
222
+ )
223
+ return result.model_dump_json(indent=2, exclude_none=True)
224
+
225
+
226
+ @mcp.tool()
227
+ async def dispatch_parallel(
228
+ dispatches: str,
229
+ aggregate: str = "",
230
+ ctx: Context | None = None,
231
+ ) -> str:
232
+ """Run multiple dispatch tasks in parallel and return all results at once.
233
+
234
+ Much faster than sequential dispatch() calls when you need answers from
235
+ several agents — all subprocesses run concurrently.
236
+
237
+ Args:
238
+ dispatches: JSON array of requests, each with "agent", "task", and
239
+ optional "context", "caller", "goal". Example:
240
+ [
241
+ {"agent": "infra", "task": "check pod logs for errors"},
242
+ {"agent": "db", "task": "are all migrations applied?"}
243
+ ]
244
+ aggregate: Optional agent name. When set, after all dispatches
245
+ complete their results are sent to this agent for synthesis
246
+ into a single coherent answer.
247
+ """
248
+ try:
249
+ items = json.loads(dispatches)
250
+ except json.JSONDecodeError as e:
251
+ return json.dumps({"error": f"Invalid JSON in dispatches: {e}"})
252
+
253
+ if not isinstance(items, list) or not items:
254
+ return json.dumps({"error": "dispatches must be a non-empty JSON array"})
255
+
256
+ config = _get_config()
257
+ cache = _get_cache(config)
258
+
259
+ # Validate structure and agents up front (including aggregator)
260
+ for i, item in enumerate(items):
261
+ if not isinstance(item, dict):
262
+ return json.dumps({"error": f"dispatches[{i}] must be an object, got {type(item).__name__}"})
263
+ if "agent" not in item or "task" not in item:
264
+ return json.dumps({"error": f"dispatches[{i}] must have 'agent' and 'task' keys"})
265
+ if err := _validate_agent(config, item["agent"]):
266
+ return err
267
+ if aggregate:
268
+ if err := _validate_agent(config, aggregate):
269
+ return err
270
+
271
+ if ctx:
272
+ names = ", ".join(item["agent"] for item in items)
273
+ await ctx.info(f"Dispatching in parallel to: {names}")
274
+
275
+ async def _run_one(item: dict) -> dict:
276
+ name = item["agent"]
277
+ task = item["task"]
278
+ item_context = item.get("context") or None
279
+ item_caller = item.get("caller") or None
280
+ item_goal = item.get("goal") or None
281
+
282
+ # Check cache
283
+ if cache:
284
+ cached = cache.get(name, task, item_context)
285
+ if cached:
286
+ d = json.loads(cached.model_dump_json(exclude_none=True))
287
+ d["cached"] = True
288
+ return d
289
+
290
+ agent_config = config.agents[name]
291
+ async with _get_semaphore(config):
292
+ result = await asyncio.to_thread(
293
+ runner.dispatch,
294
+ name,
295
+ task,
296
+ agent_config,
297
+ config.settings,
298
+ item_context,
299
+ caller=item_caller,
300
+ goal=item_goal,
301
+ )
302
+
303
+ if cache:
304
+ cache.put(name, task, result, item_context)
305
+
306
+ return json.loads(result.model_dump_json(exclude_none=True))
307
+
308
+ results = await asyncio.gather(*[_run_one(item) for item in items], return_exceptions=True)
309
+
310
+ output = []
311
+ for item, res in zip(items, results):
312
+ if isinstance(res, Exception):
313
+ output.append({
314
+ "agent": item["agent"],
315
+ "success": False,
316
+ "result": "",
317
+ "error": str(res),
318
+ })
319
+ else:
320
+ output.append(res)
321
+
322
+ # ---- Aggregation ----
323
+ if not aggregate:
324
+ return json.dumps(output, indent=2)
325
+
326
+ # Build a summary for the aggregator agent
327
+ parts = []
328
+ for item, res in zip(items, output):
329
+ status = "OK" if res.get("success") else "FAILED"
330
+ parts.append(f"## Agent: {item['agent']} [{status}]\n{res.get('result') or res.get('error', '')}")
331
+ summary = "\n\n".join(parts)
332
+
333
+ if ctx:
334
+ await ctx.info(f"Aggregating results via {aggregate}...")
335
+
336
+ agg_task = "Synthesize the results below into a single coherent answer. Highlight key findings, note any conflicts between agents, and provide actionable conclusions."
337
+ agg_config = config.agents[aggregate]
338
+ async with _get_semaphore(config):
339
+ agg_result = await asyncio.to_thread(
340
+ runner.dispatch,
341
+ aggregate,
342
+ agg_task,
343
+ agg_config,
344
+ config.settings,
345
+ summary,
346
+ caller="dispatch_parallel",
347
+ goal="aggregate parallel dispatch results",
348
+ )
349
+
350
+ return json.dumps(
351
+ {
352
+ "individual_results": output,
353
+ "aggregated": json.loads(agg_result.model_dump_json(exclude_none=True)),
354
+ },
355
+ indent=2,
356
+ )
357
+
358
+
359
+ @mcp.tool()
360
+ async def dispatch_stream(
361
+ agent: str,
362
+ task: str,
363
+ context: str = "",
364
+ caller: str = "",
365
+ goal: str = "",
366
+ ctx: Context | None = None,
367
+ ) -> str:
368
+ """Dispatch with streaming progress — see live updates as the agent works.
369
+
370
+ Same as dispatch() but shows intermediate progress via log messages.
371
+ Use this for long-running tasks where you want to monitor what the agent
372
+ is doing while it works.
373
+
374
+ Args:
375
+ agent: Name of the agent.
376
+ task: The task to perform.
377
+ context: Optional extra context.
378
+ caller: Who is dispatching.
379
+ goal: The broader objective.
380
+ """
381
+ config = _get_config()
382
+ if err := _validate_agent(config, agent):
383
+ return err
384
+
385
+ agent_config = config.agents[agent]
386
+ if ctx:
387
+ await ctx.info(f"Dispatching (stream) to {agent}: {task[:80]}...")
388
+
389
+ progress_queue: queue.Queue[str] = queue.Queue()
390
+
391
+ def on_progress(msg: str) -> None:
392
+ progress_queue.put(msg)
393
+
394
+ async with _get_semaphore(config):
395
+ loop = asyncio.get_running_loop()
396
+ future = loop.run_in_executor(
397
+ None,
398
+ lambda: runner.dispatch_stream(
399
+ agent,
400
+ task,
401
+ agent_config,
402
+ config.settings,
403
+ context or None,
404
+ on_progress,
405
+ caller=caller or None,
406
+ goal=goal or None,
407
+ ),
408
+ )
409
+
410
+ # Forward progress messages while the subprocess runs
411
+ while not future.done():
412
+ await asyncio.sleep(0.1)
413
+ while not progress_queue.empty():
414
+ msg = progress_queue.get_nowait()
415
+ if ctx:
416
+ await ctx.info(f"[{agent}] {msg[:300]}")
417
+
418
+ result = await asyncio.wrap_future(future)
419
+
420
+ # Drain any remaining messages
421
+ while not progress_queue.empty():
422
+ msg = progress_queue.get_nowait()
423
+ if ctx:
424
+ await ctx.info(f"[{agent}] {msg[:300]}")
425
+
426
+ return result.model_dump_json(indent=2, exclude_none=True)
427
+
428
+
429
+ # ---------------------------------------------------------------------------
430
+ # Agent-to-agent dialogue
431
+ # ---------------------------------------------------------------------------
432
+
433
+ _DIALOGUE_INITIAL = (
434
+ "You are starting a collaborative dialogue with agent '{other}'.\n"
435
+ "Provide your analysis or ask questions. When you have a complete answer "
436
+ "and no further questions, end your response with {marker}.\n\n"
437
+ "Topic:\n{topic}"
438
+ )
439
+
440
+ _DIALOGUE_REPLY = (
441
+ "Agent '{other}' responds:\n\n{message}\n\n"
442
+ "Continue the discussion. If you have a complete answer and no further "
443
+ "questions, end your response with {marker}."
444
+ )
445
+
446
+
447
+ @mcp.tool()
448
+ async def dispatch_dialogue(
449
+ requester: str,
450
+ responder: str,
451
+ topic: str,
452
+ max_rounds: int = 3,
453
+ ctx: Context | None = None,
454
+ ) -> str:
455
+ """Two agents collaborate through multi-turn dialogue.
456
+
457
+ *requester* poses a problem/question, *responder* provides expertise.
458
+ They alternate turns until one signals completion with [RESOLVED] or
459
+ max_rounds is reached. Each agent maintains context via session IDs
460
+ so the conversation builds naturally.
461
+
462
+ Cost: up to 2 dispatches per round (one per agent).
463
+
464
+ Args:
465
+ requester: Agent with the problem/context.
466
+ responder: Agent with the expertise/tools to help.
467
+ topic: The problem or question to discuss.
468
+ max_rounds: Maximum back-and-forth rounds (default 3, max 10).
469
+ """
470
+ config = _get_config()
471
+ for name in (requester, responder):
472
+ if err := _validate_agent(config, name):
473
+ return err
474
+
475
+ max_rounds = max(1, min(max_rounds, 10))
476
+ conversation: list[dict] = []
477
+ total_cost = 0.0
478
+ total_duration = 0
479
+ session_responder: str | None = None
480
+ session_requester: str | None = None
481
+ resolved = False
482
+ final_answer = ""
483
+
484
+ if ctx:
485
+ await ctx.info(
486
+ f"Starting dialogue: {requester} <-> {responder} (max {max_rounds} rounds)"
487
+ )
488
+
489
+ for round_num in range(1, max_rounds + 1):
490
+ # ---- Responder turn ----
491
+ if round_num == 1:
492
+ resp_task = _DIALOGUE_INITIAL.format(
493
+ other=requester, topic=topic, marker=_RESOLVED_MARKER
494
+ )
495
+ else:
496
+ resp_task = _DIALOGUE_REPLY.format(
497
+ other=requester,
498
+ message=conversation[-1]["message"],
499
+ marker=_RESOLVED_MARKER,
500
+ )
501
+
502
+ resp_config = config.agents[responder]
503
+ async with _get_semaphore(config):
504
+ resp_result = await asyncio.to_thread(
505
+ runner.dispatch,
506
+ responder,
507
+ resp_task,
508
+ resp_config,
509
+ config.settings,
510
+ session_id=session_responder,
511
+ caller=requester,
512
+ goal=topic[:200],
513
+ )
514
+ session_responder = resp_result.session_id
515
+ total_cost += resp_result.cost_usd or 0
516
+ total_duration += resp_result.duration_ms or 0
517
+
518
+ conversation.append({
519
+ "agent": responder,
520
+ "role": "responder",
521
+ "round": round_num,
522
+ "message": resp_result.result,
523
+ "cost_usd": resp_result.cost_usd,
524
+ })
525
+
526
+ if ctx:
527
+ await ctx.info(
528
+ f"[round {round_num}] {responder}: {resp_result.result[:120]}..."
529
+ )
530
+
531
+ if _RESOLVED_MARKER in resp_result.result or not resp_result.success:
532
+ resolved = _RESOLVED_MARKER in resp_result.result
533
+ final_answer = resp_result.result.replace(_RESOLVED_MARKER, "").strip()
534
+ break
535
+
536
+ # ---- Requester turn ----
537
+ req_task = _DIALOGUE_REPLY.format(
538
+ other=responder,
539
+ message=resp_result.result,
540
+ marker=_RESOLVED_MARKER,
541
+ )
542
+
543
+ req_config = config.agents[requester]
544
+ async with _get_semaphore(config):
545
+ req_result = await asyncio.to_thread(
546
+ runner.dispatch,
547
+ requester,
548
+ req_task,
549
+ req_config,
550
+ config.settings,
551
+ session_id=session_requester,
552
+ caller=responder,
553
+ goal=topic[:200],
554
+ )
555
+ session_requester = req_result.session_id
556
+ total_cost += req_result.cost_usd or 0
557
+ total_duration += req_result.duration_ms or 0
558
+
559
+ conversation.append({
560
+ "agent": requester,
561
+ "role": "requester",
562
+ "round": round_num,
563
+ "message": req_result.result,
564
+ "cost_usd": req_result.cost_usd,
565
+ })
566
+
567
+ if ctx:
568
+ await ctx.info(
569
+ f"[round {round_num}] {requester}: {req_result.result[:120]}..."
570
+ )
571
+
572
+ if _RESOLVED_MARKER in req_result.result or not req_result.success:
573
+ resolved = _RESOLVED_MARKER in req_result.result
574
+ final_answer = req_result.result.replace(_RESOLVED_MARKER, "").strip()
575
+ break
576
+
577
+ if not final_answer and conversation:
578
+ final_answer = conversation[-1]["message"]
579
+
580
+ return json.dumps(
581
+ {
582
+ "resolved": resolved,
583
+ "rounds": conversation[-1]["round"] if conversation else 0,
584
+ "total_cost_usd": round(total_cost, 4),
585
+ "total_duration_ms": total_duration,
586
+ "final_answer": final_answer,
587
+ "conversation": conversation,
588
+ },
589
+ indent=2,
590
+ )
591
+
592
+
593
+ # ---------------------------------------------------------------------------
594
+ # Agent management
595
+ # ---------------------------------------------------------------------------
596
+
597
+
598
+ @mcp.tool()
599
+ async def add_agent(
600
+ name: str,
601
+ directory: str,
602
+ description: str = "",
603
+ ctx: Context | None = None,
604
+ ) -> str:
605
+ """Register a project directory as a dispatchable agent.
606
+
607
+ The directory must exist. If no description is provided, one is
608
+ auto-generated from the project's files (CLAUDE.md, MCP servers,
609
+ package.json/pyproject.toml, stack indicators).
610
+
611
+ Args:
612
+ name: Agent name (letters, digits, hyphens, underscores; must start
613
+ with letter or digit).
614
+ directory: Absolute path to the project directory.
615
+ description: What this agent can do. Leave empty for auto-generation.
616
+ """
617
+ try:
618
+ validate_agent_name(name)
619
+ except ValueError as e:
620
+ return json.dumps({"error": str(e)})
621
+
622
+ from pathlib import Path
623
+
624
+ dir_path = Path(directory).expanduser().resolve()
625
+ if not dir_path.is_dir():
626
+ return json.dumps({"error": f"Directory does not exist: {dir_path}"})
627
+
628
+ config = _get_config()
629
+ if name in config.agents:
630
+ return json.dumps({"error": f"Agent '{name}' already exists. Remove it first."})
631
+
632
+ desc = description or auto_describe(dir_path)
633
+
634
+ config.agents[name] = AgentConfig(directory=dir_path, description=desc)
635
+ save_config(config)
636
+
637
+ if ctx:
638
+ await ctx.info(f"Added agent '{name}' -> {dir_path}")
639
+
640
+ return json.dumps(
641
+ {
642
+ "added": name,
643
+ "directory": str(dir_path),
644
+ "description": desc,
645
+ },
646
+ indent=2,
647
+ )
648
+
649
+
650
+ @mcp.tool()
651
+ async def remove_agent(
652
+ name: str,
653
+ ctx: Context | None = None,
654
+ ) -> str:
655
+ """Remove an agent from the dispatch configuration.
656
+
657
+ Args:
658
+ name: Agent name to remove.
659
+ """
660
+ config = _get_config()
661
+ if name not in config.agents:
662
+ available = ", ".join(config.agents.keys()) or "(none)"
663
+ return json.dumps({"error": f"Agent '{name}' not found. Available: {available}"})
664
+
665
+ del config.agents[name]
666
+ save_config(config)
667
+
668
+ if ctx:
669
+ await ctx.info(f"Removed agent '{name}'")
670
+
671
+ return json.dumps({"removed": name})
672
+
673
+
674
+ # ---------------------------------------------------------------------------
675
+ # Cache tools
676
+ # ---------------------------------------------------------------------------
677
+
678
+
679
+ @mcp.tool()
680
+ async def cache_stats(ctx: Context | None = None) -> str:
681
+ """Show dispatch cache statistics: size, hit rate, TTL."""
682
+ config = _get_config()
683
+ cache = _get_cache(config)
684
+ if cache is None:
685
+ return json.dumps({"enabled": False, "message": "Cache is disabled in settings"})
686
+ cache.evict_expired()
687
+ return json.dumps(cache.stats(), indent=2)
688
+
689
+
690
+ @mcp.tool()
691
+ async def cache_clear(ctx: Context | None = None) -> str:
692
+ """Clear all cached dispatch results."""
693
+ config = _get_config()
694
+ cache = _get_cache(config)
695
+ if cache is None:
696
+ return json.dumps({"enabled": False, "message": "Cache is disabled in settings"})
697
+ count = cache.clear()
698
+ if ctx:
699
+ await ctx.info(f"Cleared {count} cached entries")
700
+ return json.dumps({"cleared": count})
701
+
702
+
703
+ def main() -> None:
704
+ """Entry point for the MCP server."""
705
+ logging.basicConfig(
706
+ level=logging.INFO,
707
+ stream=sys.stderr,
708
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
709
+ )
710
+ mcp.run(transport="stdio")