agentic-loop 0.3.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,31 @@
1
+ """Agentic Loop — lightweight Loop Engineering orchestrator."""
2
+
3
+ from agentic_loop.api import execute_run, iter_run
4
+ from agentic_loop.config import RunConfig
5
+ from agentic_loop.connectors.base import ConnectorRegistry, ConnectorResult
6
+ from agentic_loop.connectors.mcp import MCPConnector, MCPServerConfig
7
+ from agentic_loop.loop import query_loop, run_loop
8
+ from agentic_loop.orchestration.orchestrator import Orchestrator
9
+ from agentic_loop.terminal import Terminal, TerminalKind
10
+ from agentic_loop.tools.mcp_bridge import register_mcp_tools
11
+ from agentic_loop.tools.registry import ToolRegistry, build_default_registry
12
+
13
+ __all__ = [
14
+ "ConnectorRegistry",
15
+ "ConnectorResult",
16
+ "MCPConnector",
17
+ "MCPServerConfig",
18
+ "Orchestrator",
19
+ "RunConfig",
20
+ "Terminal",
21
+ "TerminalKind",
22
+ "ToolRegistry",
23
+ "build_default_registry",
24
+ "execute_run",
25
+ "iter_run",
26
+ "query_loop",
27
+ "register_mcp_tools",
28
+ "run_loop",
29
+ ]
30
+
31
+ __version__ = "0.3.0"
agentic_loop/abort.py ADDED
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+
5
+
6
+ class AbortController:
7
+ """Cooperative cancellation signal checked between loop steps."""
8
+
9
+ def __init__(self) -> None:
10
+ self._event = asyncio.Event()
11
+
12
+ def abort(self) -> None:
13
+ self._event.set()
14
+
15
+ def is_set(self) -> bool:
16
+ return self._event.is_set()
17
+
18
+ def clear(self) -> None:
19
+ self._event.clear()
agentic_loop/api.py ADDED
@@ -0,0 +1,119 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from collections.abc import AsyncIterator, Callable
5
+ from typing import Any
6
+
7
+ from agentic_loop.config import RunConfig
8
+ from agentic_loop.llm.openai_compat import OpenAICompatClient
9
+ from agentic_loop.loop import LoopEvent, query_loop, terminal_from_event
10
+ from agentic_loop.observability.journal import RunJournal
11
+ from agentic_loop.terminal import Terminal
12
+ from agentic_loop.tools.registry import build_default_registry
13
+
14
+
15
+ async def execute_run(
16
+ prompt: str,
17
+ *,
18
+ config: RunConfig,
19
+ system_prompt: str | None = None,
20
+ on_event: Callable[[LoopEvent], None] | None = None,
21
+ ) -> tuple[Terminal, RunJournal]:
22
+ if config.dry_run:
23
+ journal = RunJournal(config.runs_dir, run_id="dry-run")
24
+ terminal = Terminal.completed(
25
+ f"[dry-run] Would run with model={config.model}, tools={build_default_registry(cwd=config.cwd, allow_bash=config.allow_bash).list_names()}, stream={config.stream}",
26
+ turns=0,
27
+ )
28
+ return terminal, journal
29
+
30
+ config.require_api_key()
31
+ journal = RunJournal(config.runs_dir)
32
+ started = time.perf_counter()
33
+
34
+ journal.started(
35
+ prompt=prompt,
36
+ config={
37
+ "model": config.model,
38
+ "cwd": str(config.cwd),
39
+ "max_turns": config.max_turns,
40
+ "allow_bash": config.allow_bash,
41
+ "stream": config.stream,
42
+ },
43
+ )
44
+
45
+ messages: list[dict[str, Any]] = []
46
+ if system_prompt:
47
+ messages.append({"role": "system", "content": system_prompt})
48
+ messages.append({"role": "user", "content": prompt})
49
+
50
+ llm = OpenAICompatClient(
51
+ api_key=config.api_key or "",
52
+ base_url=config.base_url,
53
+ model=config.model,
54
+ max_retries=config.max_retries,
55
+ )
56
+ tools = build_default_registry(cwd=config.cwd, allow_bash=config.allow_bash)
57
+
58
+ terminal: Terminal | None = None
59
+ async for event in query_loop(
60
+ messages,
61
+ tools=tools,
62
+ llm=llm,
63
+ max_turns=config.max_turns,
64
+ journal=journal,
65
+ stream=config.stream,
66
+ tool_timeout=config.tool_timeout,
67
+ ):
68
+ if on_event:
69
+ on_event(event)
70
+ if event.kind == "terminal":
71
+ terminal = terminal_from_event(event.data)
72
+
73
+ if terminal is None:
74
+ terminal = Terminal.failed("Run ended without terminal event", turns=0)
75
+
76
+ journal.finished(
77
+ terminal=terminal.to_dict(),
78
+ duration_ms=(time.perf_counter() - started) * 1000,
79
+ )
80
+ return terminal, journal
81
+
82
+
83
+ async def iter_run(
84
+ prompt: str,
85
+ *,
86
+ config: RunConfig,
87
+ system_prompt: str | None = None,
88
+ ) -> AsyncIterator[LoopEvent]:
89
+ """Public API: stream loop events for integrations."""
90
+ if config.dry_run:
91
+ yield LoopEvent(
92
+ kind="terminal",
93
+ data=Terminal.completed("[dry-run]", turns=0).to_dict(),
94
+ )
95
+ return
96
+
97
+ config.require_api_key()
98
+ messages: list[dict[str, Any]] = []
99
+ if system_prompt:
100
+ messages.append({"role": "system", "content": system_prompt})
101
+ messages.append({"role": "user", "content": prompt})
102
+
103
+ llm = OpenAICompatClient(
104
+ api_key=config.api_key or "",
105
+ base_url=config.base_url,
106
+ model=config.model,
107
+ max_retries=config.max_retries,
108
+ )
109
+ tools = build_default_registry(cwd=config.cwd, allow_bash=config.allow_bash)
110
+
111
+ async for event in query_loop(
112
+ messages,
113
+ tools=tools,
114
+ llm=llm,
115
+ max_turns=config.max_turns,
116
+ stream=config.stream,
117
+ tool_timeout=config.tool_timeout,
118
+ ):
119
+ yield event
agentic_loop/cli.py ADDED
@@ -0,0 +1,299 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from agentic_loop.abort import AbortController
10
+ from agentic_loop.api import execute_run
11
+ from agentic_loop.config import RunConfig
12
+ from agentic_loop.loop import LoopEvent
13
+ from agentic_loop.orchestration.automations import parse_interval
14
+ from agentic_loop.orchestration.orchestrator import Orchestrator
15
+
16
+ RUN_EPILOG = """
17
+ Examples:
18
+ agentic-loop run "List Python files" --dry-run
19
+ agentic-loop run "Find TODO comments" --max-turns 10
20
+ agentic-loop run "Explain README" --no-stream
21
+ """
22
+
23
+ LOOP_EPILOG = """
24
+ Examples:
25
+ agentic-loop loop --every 5m "Triage open issues" --once
26
+ agentic-loop loop --every 30s "Check deploy" --skill triage --dry-run
27
+ """
28
+
29
+ GOAL_EPILOG = """
30
+ Examples:
31
+ agentic-loop goal "pytest tests/ passes" "Fix failing tests" --max-rounds 5
32
+ agentic-loop goal "lint is clean" "Run linter and fix issues" --dry-run
33
+ """
34
+
35
+ STATE_EPILOG = """
36
+ Examples:
37
+ agentic-loop state show
38
+ agentic-loop state reset
39
+ """
40
+
41
+
42
+ def build_parser() -> argparse.ArgumentParser:
43
+ parser = argparse.ArgumentParser(
44
+ prog="agentic-loop",
45
+ description="Lightweight Loop Engineering orchestrator",
46
+ formatter_class=argparse.RawDescriptionHelpFormatter,
47
+ )
48
+ sub = parser.add_subparsers(dest="command", required=True)
49
+
50
+ run = sub.add_parser("run", help="Single agent run", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=RUN_EPILOG)
51
+ run.add_argument("prompt")
52
+ _add_common_run_flags(run)
53
+
54
+ loop = sub.add_parser("loop", help="Scheduled automation", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=LOOP_EPILOG)
55
+ loop.add_argument("prompt")
56
+ loop.add_argument("--every", required=True, help="Interval: 30s, 5m, 2h, 1d")
57
+ loop.add_argument("--once", action="store_true", help="Run once instead of forever")
58
+ _add_common_run_flags(loop)
59
+
60
+ goal = sub.add_parser("goal", help="Run until goal condition passes", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=GOAL_EPILOG)
61
+ goal.add_argument("condition", help="Verifiable stopping condition")
62
+ goal.add_argument("prompt", help="Worker task prompt")
63
+ goal.add_argument("--max-rounds", type=int, default=10)
64
+ _add_common_run_flags(goal)
65
+
66
+ state = sub.add_parser("state", help="Project memory", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=STATE_EPILOG)
67
+ state_sub = state.add_subparsers(dest="state_cmd", required=True)
68
+ state_show = state_sub.add_parser("show", help="Print state markdown")
69
+ state_show.add_argument("--cwd", type=Path, default=Path.cwd())
70
+ state_show.add_argument("--json", action="store_true")
71
+ state_reset = state_sub.add_parser("reset", help="Clear state and triage")
72
+ state_reset.add_argument("--cwd", type=Path, default=Path.cwd())
73
+ state_reset.add_argument("--yes", action="store_true", help="Skip confirmation")
74
+
75
+ return parser
76
+
77
+
78
+ def _add_common_run_flags(parser: argparse.ArgumentParser) -> None:
79
+ parser.add_argument("--cwd", type=Path, default=Path.cwd())
80
+ parser.add_argument("--max-turns", type=int, default=20)
81
+ parser.add_argument("--model")
82
+ parser.add_argument("--api-base", dest="base_url")
83
+ parser.add_argument("--allow-bash", action="store_true")
84
+ parser.add_argument("--dry-run", action="store_true")
85
+ parser.add_argument("--no-stream", action="store_true")
86
+ parser.add_argument("--json", action="store_true")
87
+ parser.add_argument("--skill", help="Skill to load into system prompt")
88
+ parser.add_argument("--agent", help="Sub-agent role")
89
+
90
+
91
+ def _print_error(message: str) -> None:
92
+ print(f"Error: {message}", file=sys.stderr)
93
+
94
+
95
+ def _config_from_args(args: argparse.Namespace) -> RunConfig:
96
+ overrides = {
97
+ "cwd": args.cwd.resolve(),
98
+ "max_turns": getattr(args, "max_turns", 20),
99
+ "allow_bash": getattr(args, "allow_bash", False),
100
+ "dry_run": getattr(args, "dry_run", False),
101
+ "stream": not getattr(args, "no_stream", False),
102
+ }
103
+ if getattr(args, "model", None):
104
+ overrides["model"] = args.model
105
+ if getattr(args, "base_url", None):
106
+ overrides["base_url"] = args.base_url
107
+ return RunConfig.from_env(overrides=overrides)
108
+
109
+
110
+ def _make_event_handler(*, stream_output: bool, json_mode: bool):
111
+ streamed_header = False
112
+
113
+ def on_event(event: LoopEvent) -> None:
114
+ nonlocal streamed_header
115
+ if json_mode:
116
+ return
117
+ if event.kind == "turn_start":
118
+ print(f"\n--- turn {event.data.get('turn')} ---", file=sys.stderr)
119
+ elif event.kind == "assistant_delta" and stream_output:
120
+ if not streamed_header:
121
+ print("\n--- assistant ---", file=sys.stderr)
122
+ streamed_header = True
123
+ print(event.data.get("text", ""), end="", flush=True)
124
+ elif event.kind == "tool_result":
125
+ print(f"\n[tool:{event.data.get('tool')}]", file=sys.stderr)
126
+
127
+ return on_event
128
+
129
+
130
+ def _print_terminal(terminal, journal, *, config: RunConfig, json_mode: bool) -> int:
131
+ if json_mode:
132
+ print(json.dumps({"run_id": journal.run_id, "terminal": terminal.to_dict()}, ensure_ascii=False, indent=2))
133
+ return terminal.exit_code
134
+ if config.stream:
135
+ print()
136
+ print(f"run_id: {journal.run_id}")
137
+ print(f"terminal: {terminal.kind.value}")
138
+ print(f"turns: {terminal.turns}")
139
+ if terminal.content:
140
+ print("\n--- result ---\n")
141
+ print(terminal.content)
142
+ if terminal.error and terminal.kind.value != "completed":
143
+ print(f"\nerror: {terminal.error}", file=sys.stderr)
144
+ return terminal.exit_code
145
+
146
+
147
+ async def _cmd_run(args: argparse.Namespace) -> int:
148
+ config = _config_from_args(args)
149
+ on_event = _make_event_handler(stream_output=config.stream, json_mode=args.json)
150
+ try:
151
+ if args.skill or args.agent:
152
+ orch = Orchestrator(config)
153
+ terminal, run_id = await orch.run(
154
+ args.prompt,
155
+ skill=args.skill,
156
+ agent=args.agent,
157
+ on_event=on_event,
158
+ )
159
+ if args.json:
160
+ print(json.dumps({"run_id": run_id, "terminal": terminal.to_dict()}, ensure_ascii=False, indent=2))
161
+ return terminal.exit_code
162
+ print(f"run_id: {run_id}")
163
+ print(f"terminal: {terminal.kind.value}")
164
+ if terminal.content:
165
+ print(terminal.content)
166
+ return terminal.exit_code
167
+
168
+ terminal, journal = await execute_run(args.prompt, config=config, on_event=on_event)
169
+ except ValueError as exc:
170
+ _print_error(str(exc))
171
+ return 1
172
+ except KeyboardInterrupt:
173
+ print("\nAborted.", file=sys.stderr)
174
+ return 130
175
+ return _print_terminal(terminal, journal, config=config, json_mode=args.json)
176
+
177
+
178
+ async def _cmd_loop(args: argparse.Namespace) -> int:
179
+ try:
180
+ parse_interval(args.every)
181
+ except ValueError as exc:
182
+ _print_error(str(exc))
183
+ return 1
184
+
185
+ config = _config_from_args(args)
186
+ orch = Orchestrator(config)
187
+ on_event = _make_event_handler(stream_output=config.stream, json_mode=args.json)
188
+
189
+ if args.dry_run:
190
+ print(f"[dry-run] Would run every {args.every}: {args.prompt}")
191
+ return 0
192
+
193
+ print(f"Automation started: every {args.every}" + (" (once)" if args.once else ""), file=sys.stderr)
194
+ try:
195
+ result = await orch.automation(
196
+ args.prompt,
197
+ every=args.every,
198
+ skill=args.skill,
199
+ once=args.once,
200
+ on_event=on_event,
201
+ )
202
+ except KeyboardInterrupt:
203
+ print("\nAutomation stopped.", file=sys.stderr)
204
+ return 130
205
+
206
+ if args.json:
207
+ print(json.dumps({"runs": result.runs, "last_error": result.last_error}, indent=2))
208
+ else:
209
+ print(f"runs: {result.runs}")
210
+ if result.last_error:
211
+ print(f"last_error: {result.last_error}", file=sys.stderr)
212
+ return 0 if not result.last_error else 1
213
+
214
+
215
+ async def _cmd_goal(args: argparse.Namespace) -> int:
216
+ config = _config_from_args(args)
217
+ orch = Orchestrator(config)
218
+ on_event = _make_event_handler(stream_output=config.stream, json_mode=args.json)
219
+ try:
220
+ terminal, evaluations = await orch.run_goal(
221
+ condition=args.condition,
222
+ prompt=args.prompt,
223
+ skill=args.skill,
224
+ agent=args.agent,
225
+ max_rounds=args.max_rounds,
226
+ on_event=on_event,
227
+ )
228
+ except ValueError as exc:
229
+ _print_error(str(exc))
230
+ return 1
231
+ except KeyboardInterrupt:
232
+ print("\nAborted.", file=sys.stderr)
233
+ return 130
234
+
235
+ if args.json:
236
+ print(
237
+ json.dumps(
238
+ {
239
+ "terminal": terminal.to_dict(),
240
+ "evaluations": [e.__dict__ for e in evaluations],
241
+ },
242
+ ensure_ascii=False,
243
+ indent=2,
244
+ )
245
+ )
246
+ else:
247
+ print(f"terminal: {terminal.kind.value}")
248
+ for idx, ev in enumerate(evaluations, start=1):
249
+ print(f"round {idx}: satisfied={ev.satisfied} reason={ev.reason}")
250
+ if terminal.content:
251
+ print("\n--- result ---\n")
252
+ print(terminal.content)
253
+ return terminal.exit_code
254
+
255
+
256
+ async def _cmd_state(args: argparse.Namespace) -> int:
257
+ config = RunConfig.from_env(overrides={"cwd": args.cwd.resolve()})
258
+ store = Orchestrator(config).memory
259
+
260
+ if args.state_cmd == "show":
261
+ if args.json:
262
+ print(json.dumps(store.load_state().__dict__, ensure_ascii=False, indent=2))
263
+ else:
264
+ print(store.format_markdown())
265
+ return 0
266
+
267
+ if args.state_cmd == "reset":
268
+ if not args.yes:
269
+ _print_error("Pass --yes to clear state.json and triage.json")
270
+ return 1
271
+ if store.state_path.exists():
272
+ store.state_path.unlink()
273
+ if store.triage_path.exists():
274
+ store.triage_path.unlink()
275
+ print("State cleared.")
276
+ return 0
277
+
278
+ return 1
279
+
280
+
281
+ def main(argv: list[str] | None = None) -> int:
282
+ parser = build_parser()
283
+ args = parser.parse_args(argv)
284
+
285
+ if args.command == "run":
286
+ return asyncio.run(_cmd_run(args))
287
+ if args.command == "loop":
288
+ return asyncio.run(_cmd_loop(args))
289
+ if args.command == "goal":
290
+ return asyncio.run(_cmd_goal(args))
291
+ if args.command == "state":
292
+ return asyncio.run(_cmd_state(args))
293
+
294
+ parser.print_help()
295
+ return 1
296
+
297
+
298
+ if __name__ == "__main__":
299
+ raise SystemExit(main())
agentic_loop/config.py ADDED
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from dotenv import load_dotenv
9
+
10
+
11
+ def _find_project_root(start: Path | None = None) -> Path:
12
+ current = (start or Path.cwd()).resolve()
13
+ for path in [current, *current.parents]:
14
+ if (path / "agentic-loop.toml").exists() or (path / "pyproject.toml").exists():
15
+ return path
16
+ return current
17
+
18
+
19
+ @dataclass
20
+ class RunConfig:
21
+ cwd: Path = field(default_factory=Path.cwd)
22
+ model: str = "gpt-4o-mini"
23
+ evaluator_model: str | None = None
24
+ api_key: str | None = None
25
+ base_url: str | None = None
26
+ max_turns: int = 20
27
+ allow_bash: bool = False
28
+ dry_run: bool = False
29
+ stream: bool = True
30
+ max_retries: int = 3
31
+ tool_timeout: float = 120.0
32
+ agentic_loop_dir: Path | None = None
33
+
34
+ @property
35
+ def state_dir(self) -> Path:
36
+ base = self.agentic_loop_dir or (self.cwd / ".agentic-loop")
37
+ return base
38
+
39
+ @property
40
+ def runs_dir(self) -> Path:
41
+ return self.state_dir / "runs"
42
+
43
+ @property
44
+ def effective_evaluator_model(self) -> str:
45
+ return self.evaluator_model or self.model
46
+
47
+ @classmethod
48
+ def from_env(cls, *, cwd: Path | None = None, overrides: dict[str, Any] | None = None) -> RunConfig:
49
+ root = _find_project_root(cwd)
50
+ load_dotenv(root / ".env")
51
+ load_dotenv()
52
+
53
+ cfg = cls(
54
+ cwd=(cwd or Path.cwd()).resolve(),
55
+ model=os.getenv("OPENAI_MODEL") or os.getenv("MODEL") or "gpt-4o-mini",
56
+ evaluator_model=os.getenv("EVALUATOR_MODEL"),
57
+ api_key=os.getenv("OPENAI_API_KEY"),
58
+ base_url=os.getenv("OPENAI_BASE_URL"),
59
+ )
60
+ if overrides:
61
+ for key, value in overrides.items():
62
+ if value is not None and hasattr(cfg, key):
63
+ setattr(cfg, key, value)
64
+ return cfg
65
+
66
+ def require_api_key(self) -> str:
67
+ if not self.api_key:
68
+ raise ValueError(
69
+ "OPENAI_API_KEY is not set.\n"
70
+ "Copy .env.example to .env and set your key.\n"
71
+ "Example: agentic-loop run \"hello\" --dry-run # no API key needed"
72
+ )
73
+ return self.api_key
File without changes
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Awaitable, Callable, Protocol
5
+
6
+
7
+ @dataclass
8
+ class ConnectorResult:
9
+ ok: bool
10
+ message: str
11
+ data: dict[str, Any] | None = None
12
+
13
+
14
+ class Connector(Protocol):
15
+ name: str
16
+
17
+ async def invoke(self, action: str, **params: Any) -> ConnectorResult: ...
18
+
19
+
20
+ ConnectorHandler = Callable[..., Awaitable[ConnectorResult] | ConnectorResult]
21
+
22
+
23
+ class ConnectorRegistry:
24
+ def __init__(self) -> None:
25
+ self._handlers: dict[str, ConnectorHandler] = {}
26
+
27
+ def register(self, name: str, handler: ConnectorHandler) -> None:
28
+ self._handlers[name] = handler
29
+
30
+ async def invoke(self, name: str, action: str, **params: Any) -> ConnectorResult:
31
+ if name not in self._handlers:
32
+ return ConnectorResult(ok=False, message=f"Unknown connector '{name}'")
33
+ result = self._handlers[name](action=action, **params)
34
+ if hasattr(result, "__await__"):
35
+ return await result
36
+ return result
37
+
38
+ def list_names(self) -> list[str]:
39
+ return sorted(self._handlers.keys())
@@ -0,0 +1,99 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ from agentic_loop.connectors.base import ConnectorResult
8
+
9
+
10
+ @dataclass
11
+ class MCPServerConfig:
12
+ """Stdio MCP server launch configuration."""
13
+
14
+ command: str
15
+ args: list[str]
16
+ env: dict[str, str] | None = None
17
+
18
+
19
+ class MCPConnector:
20
+ """
21
+ Minimal MCP client wrapper (stdio transport).
22
+
23
+ Requires optional dependency: pip install agentic-loop[mcp]
24
+ """
25
+
26
+ def __init__(self, config: MCPServerConfig) -> None:
27
+ self.config = config
28
+ self._session = None
29
+ self._context = None
30
+
31
+ async def __aenter__(self) -> MCPConnector:
32
+ try:
33
+ from mcp import ClientSession, StdioServerParameters
34
+ from mcp.client.stdio import stdio_client
35
+ except ImportError as exc:
36
+ raise ImportError(
37
+ "MCP support requires: pip install agentic-loop[mcp]"
38
+ ) from exc
39
+
40
+ params = StdioServerParameters(
41
+ command=self.config.command,
42
+ args=self.config.args,
43
+ env=self.config.env,
44
+ )
45
+ self._context = stdio_client(params)
46
+ read, write = await self._context.__aenter__()
47
+ self._session = ClientSession(read, write)
48
+ await self._session.__aenter__()
49
+ await self._session.initialize()
50
+ return self
51
+
52
+ async def __aexit__(self, *args: Any) -> None:
53
+ if self._session is not None:
54
+ await self._session.__aexit__(*args)
55
+ if self._context is not None:
56
+ await self._context.__aexit__(*args)
57
+
58
+ async def list_tools(self) -> list[dict[str, Any]]:
59
+ assert self._session is not None
60
+ result = await self._session.list_tools()
61
+ return [
62
+ {
63
+ "name": tool.name,
64
+ "description": tool.description or "",
65
+ "inputSchema": tool.inputSchema,
66
+ }
67
+ for tool in result.tools
68
+ ]
69
+
70
+ async def call_tool(self, name: str, arguments: dict[str, Any]) -> ConnectorResult:
71
+ assert self._session is not None
72
+ try:
73
+ result = await self._session.call_tool(name, arguments)
74
+ text_parts = [
75
+ item.text
76
+ for item in result.content
77
+ if hasattr(item, "text") and item.text
78
+ ]
79
+ message = "\n".join(text_parts) if text_parts else json.dumps(
80
+ [item.model_dump() if hasattr(item, "model_dump") else str(item) for item in result.content],
81
+ ensure_ascii=False,
82
+ )
83
+ return ConnectorResult(ok=not result.isError, message=message)
84
+ except Exception as exc: # noqa: BLE001
85
+ return ConnectorResult(ok=False, message=str(exc))
86
+
87
+ async def invoke(self, action: str, **params: Any) -> ConnectorResult:
88
+ if action == "list_tools":
89
+ tools = await self.list_tools()
90
+ return ConnectorResult(ok=True, message=f"{len(tools)} tools", data={"tools": tools})
91
+ if action == "call_tool":
92
+ name = str(params.get("name", ""))
93
+ arguments = params.get("arguments") or {}
94
+ if not name:
95
+ return ConnectorResult(ok=False, message="Missing tool name")
96
+ if not isinstance(arguments, dict):
97
+ return ConnectorResult(ok=False, message="arguments must be an object")
98
+ return await self.call_tool(name, arguments)
99
+ return ConnectorResult(ok=False, message=f"Unknown MCP action '{action}'")
File without changes