rlmness-cli 0.2.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.
- rlmagent/__init__.py +1 -0
- rlmagent/__main__.py +8 -0
- rlmagent/acp/__init__.py +1 -0
- rlmagent/acp/runtime.py +71 -0
- rlmagent/acp/server.py +598 -0
- rlmagent/acp/updates.py +128 -0
- rlmagent/agentnet.py +237 -0
- rlmagent/agents/__init__.py +1 -0
- rlmagent/agents/approval.py +90 -0
- rlmagent/agents/bridge.py +109 -0
- rlmagent/agents/budget.py +212 -0
- rlmagent/agents/child_handles.py +46 -0
- rlmagent/agents/child_mailbox.py +30 -0
- rlmagent/agents/kernel_api.py +156 -0
- rlmagent/agents/output.py +34 -0
- rlmagent/agents/patch.py +107 -0
- rlmagent/agents/prompts.py +156 -0
- rlmagent/agents/trace.py +290 -0
- rlmagent/agents/tree.py +682 -0
- rlmagent/agents/wasm_api.py +105 -0
- rlmagent/agents/workspace.py +357 -0
- rlmagent/autopilot.py +447 -0
- rlmagent/backend.py +946 -0
- rlmagent/backend_context.py +74 -0
- rlmagent/cli/__init__.py +5 -0
- rlmagent/cli/config_cmd.py +177 -0
- rlmagent/cli/health.py +156 -0
- rlmagent/cli/main.py +1194 -0
- rlmagent/cli/update.py +188 -0
- rlmagent/clipboard.py +63 -0
- rlmagent/config/__init__.py +37 -0
- rlmagent/config/config.py +297 -0
- rlmagent/config/loader.py +119 -0
- rlmagent/config/prompts.py +124 -0
- rlmagent/config/secure_input.py +213 -0
- rlmagent/config/store.py +275 -0
- rlmagent/context/__init__.py +47 -0
- rlmagent/context/budget.py +557 -0
- rlmagent/contract.py +311 -0
- rlmagent/contract_run.py +232 -0
- rlmagent/conversation.py +2547 -0
- rlmagent/cron.py +77 -0
- rlmagent/directives.py +893 -0
- rlmagent/discovery.py +356 -0
- rlmagent/execution_log.py +66 -0
- rlmagent/factory.py +189 -0
- rlmagent/factory_graph.py +413 -0
- rlmagent/harness/__init__.py +63 -0
- rlmagent/harness/contracts/__init__.py +1 -0
- rlmagent/harness/contracts/stream.py +129 -0
- rlmagent/harness/contracts/tooling.py +127 -0
- rlmagent/harness/contracts/transcript/__init__.py +68 -0
- rlmagent/harness/contracts/transcript/_base.py +29 -0
- rlmagent/harness/contracts/transcript/diagnostics.py +52 -0
- rlmagent/harness/contracts/transcript/entries.py +180 -0
- rlmagent/harness/contracts/transcript/segments.py +64 -0
- rlmagent/harness/contracts/values.py +6 -0
- rlmagent/harness/driver.py +397 -0
- rlmagent/harness/engine.py +489 -0
- rlmagent/harness/mending.py +153 -0
- rlmagent/harness/provider/__init__.py +1 -0
- rlmagent/harness/provider/base.py +27 -0
- rlmagent/harness/provider/wire.py +189 -0
- rlmagent/harness/py.typed +0 -0
- rlmagent/harness/session/__init__.py +5 -0
- rlmagent/harness/session/index.py +218 -0
- rlmagent/harness/session/lease.py +66 -0
- rlmagent/harness/session/records.py +118 -0
- rlmagent/harness/session/replay.py +165 -0
- rlmagent/harness/session/store.py +155 -0
- rlmagent/harness/session/summary.py +46 -0
- rlmagent/hints.py +47 -0
- rlmagent/history.py +92 -0
- rlmagent/hooks/__init__.py +1 -0
- rlmagent/hooks/hooks.py +1 -0
- rlmagent/instructions.py +157 -0
- rlmagent/job_runner.py +117 -0
- rlmagent/jobs.py +365 -0
- rlmagent/kept_agents.py +397 -0
- rlmagent/kernel/__init__.py +5 -0
- rlmagent/kernel/session.py +205 -0
- rlmagent/kernel/wasm.py +169 -0
- rlmagent/logs.py +208 -0
- rlmagent/managed_release.py +368 -0
- rlmagent/mcp.py +411 -0
- rlmagent/mcp_oauth.py +213 -0
- rlmagent/model/__init__.py +29 -0
- rlmagent/model/_claude/__init__.py +1 -0
- rlmagent/model/_claude/courier.py +117 -0
- rlmagent/model/_claude/emitter.py +378 -0
- rlmagent/model/_oai/__init__.py +1 -0
- rlmagent/model/_oai/helpers.py +256 -0
- rlmagent/model/_oai/normalize.py +263 -0
- rlmagent/model/_oai/parsers.py +317 -0
- rlmagent/model/_oai/payloads.py +226 -0
- rlmagent/model/_oai/transport.py +127 -0
- rlmagent/model/claude.py +552 -0
- rlmagent/model/copilot.py +142 -0
- rlmagent/model/correlation.py +21 -0
- rlmagent/model/deadline.py +19 -0
- rlmagent/model/discovery.py +181 -0
- rlmagent/model/external/__init__.py +11 -0
- rlmagent/model/external/acp.py +202 -0
- rlmagent/model/external/acp_install.py +109 -0
- rlmagent/model/external/cli.py +248 -0
- rlmagent/model/external/textcalls.py +57 -0
- rlmagent/model/failover.py +129 -0
- rlmagent/model/google.py +199 -0
- rlmagent/model/limits.py +46 -0
- rlmagent/model/oai_compatible.py +414 -0
- rlmagent/model/preferences.py +56 -0
- rlmagent/model/py.typed +0 -0
- rlmagent/model/registered_oauth.py +195 -0
- rlmagent/model/scripted.py +55 -0
- rlmagent/model/settings.py +271 -0
- rlmagent/model/subscription.py +398 -0
- rlmagent/model/token_bounds.py +15 -0
- rlmagent/model/transport/__init__.py +1 -0
- rlmagent/model/transport/backoff.py +87 -0
- rlmagent/model/transport/client.py +87 -0
- rlmagent/model/transport/faults.py +65 -0
- rlmagent/model/transport/responses_socket.py +223 -0
- rlmagent/model/vertex.py +93 -0
- rlmagent/network.py +123 -0
- rlmagent/packages.py +246 -0
- rlmagent/planning.py +84 -0
- rlmagent/playbook/__init__.py +1 -0
- rlmagent/playbook/apply.py +106 -0
- rlmagent/playbook/digest.py +63 -0
- rlmagent/playbook/improver.py +272 -0
- rlmagent/playbook/plan.py +99 -0
- rlmagent/playbook/store.py +67 -0
- rlmagent/plugins.py +327 -0
- rlmagent/policy.py +105 -0
- rlmagent/prompt_refs.py +57 -0
- rlmagent/prompts.py +331 -0
- rlmagent/prune.py +60 -0
- rlmagent/py.typed +0 -0
- rlmagent/reasoning.py +135 -0
- rlmagent/refinement/__init__.py +1 -0
- rlmagent/refinement/controller.py +448 -0
- rlmagent/refinement/engine.py +220 -0
- rlmagent/refinement/evidence.py +131 -0
- rlmagent/refinement/scoped.py +90 -0
- rlmagent/refinement/store.py +193 -0
- rlmagent/refresh.py +202 -0
- rlmagent/routing.py +52 -0
- rlmagent/runtime.py +575 -0
- rlmagent/safety.py +267 -0
- rlmagent/session_html.py +57 -0
- rlmagent/session_import.py +52 -0
- rlmagent/session_share.py +55 -0
- rlmagent/sessions.py +249 -0
- rlmagent/shell_sandbox.py +88 -0
- rlmagent/skillset.py +320 -0
- rlmagent/steering.py +148 -0
- rlmagent/stops.py +62 -0
- rlmagent/subscriptions.py +219 -0
- rlmagent/tasks.py +98 -0
- rlmagent/toolhost.py +310 -0
- rlmagent/tools/__init__.py +56 -0
- rlmagent/tools/_shared.py +210 -0
- rlmagent/tools/files.py +462 -0
- rlmagent/tools/kernel.py +93 -0
- rlmagent/tools/patch_tool.py +68 -0
- rlmagent/tools/search.py +188 -0
- rlmagent/trust.py +383 -0
- rlmagent/tui/__init__.py +1 -0
- rlmagent/tui/adapter.py +506 -0
- rlmagent/tui/app.py +711 -0
- rlmagent/tui/approval_screen.py +57 -0
- rlmagent/tui/choice_screen.py +47 -0
- rlmagent/tui/completions.py +116 -0
- rlmagent/tui/components.py +25 -0
- rlmagent/tui/composer.py +40 -0
- rlmagent/tui/factory_view.py +86 -0
- rlmagent/tui/inspect.py +193 -0
- rlmagent/tui/messages.py +215 -0
- rlmagent/tui/model_screen.py +307 -0
- rlmagent/tui/prompt.py +98 -0
- rlmagent/tui/question_screen.py +52 -0
- rlmagent/tui/replay.py +141 -0
- rlmagent/tui/rlm_agent.tcss +405 -0
- rlmagent/tui/search_box.py +91 -0
- rlmagent/tui/sidebar.py +182 -0
- rlmagent/tui/status.py +49 -0
- rlmagent/tui/steps.py +283 -0
- rlmagent/tui/suggest.py +778 -0
- rlmagent/tui/transcript.py +172 -0
- rlmagent/tui/trust_dialog.py +58 -0
- rlmagent/tui/worker_attach.py +141 -0
- rlmagent/ui/__init__.py +1 -0
- rlmagent/ui/render.py +211 -0
- rlmagent/undo.py +109 -0
- rlmagent/utils/__init__.py +1 -0
- rlmagent/websearch.py +75 -0
- rlmagent/worker.py +1275 -0
- rlmagent/worker_service.py +203 -0
- rlmness_cli-0.2.0.dist-info/METADATA +882 -0
- rlmness_cli-0.2.0.dist-info/RECORD +203 -0
- rlmness_cli-0.2.0.dist-info/WHEEL +4 -0
- rlmness_cli-0.2.0.dist-info/entry_points.txt +2 -0
- rlmness_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
rlmagent/mcp.py
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
"""Model Context Protocol servers: their tools, reachable from cells and the session engine.
|
|
2
|
+
|
|
3
|
+
Servers are listed in mcp.json under the rlm-agent home, and in a trusted project's
|
|
4
|
+
.rlm-agent/mcp.json:
|
|
5
|
+
|
|
6
|
+
{"servers": {
|
|
7
|
+
"files": {"command": "npx", "args": ["-y", "some-mcp-server"], "env": {"TOKEN": "${MY_TOKEN}"}},
|
|
8
|
+
"docs": {"url": "https://example.com/mcp", "headers": {"Authorization": "Bearer ${DOCS_KEY}"}}
|
|
9
|
+
}}
|
|
10
|
+
|
|
11
|
+
``${NAME}`` is filled from the environment when the server starts, so keys stay out of
|
|
12
|
+
the file. Servers start on first use. Every tool call goes through approval unless the
|
|
13
|
+
server marks the tool read-only.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import time
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
PROTOCOL = "2025-06-18"
|
|
27
|
+
START_SECONDS = 30.0
|
|
28
|
+
CALL_SECONDS = 300.0
|
|
29
|
+
MAX_RESULT_CHARS = 100_000
|
|
30
|
+
_VAR = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
|
31
|
+
_CONNECTORS = {
|
|
32
|
+
"slack": ("https://mcp.slack.com/mcp", "https://mcp.slack.com"),
|
|
33
|
+
"notion": ("https://mcp.notion.com/mcp", "https://mcp.notion.com"),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class McpError(RuntimeError):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _fill(value):
|
|
42
|
+
if isinstance(value, str):
|
|
43
|
+
return _VAR.sub(lambda m: os.environ.get(m.group(1), ""), value)
|
|
44
|
+
if isinstance(value, list):
|
|
45
|
+
return [_fill(v) for v in value]
|
|
46
|
+
if isinstance(value, dict):
|
|
47
|
+
return {k: _fill(v) for k, v in value.items()}
|
|
48
|
+
return value
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class ServerConfig:
|
|
53
|
+
name: str
|
|
54
|
+
command: str | None = None
|
|
55
|
+
args: list[str] = field(default_factory=list)
|
|
56
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
57
|
+
url: str | None = None
|
|
58
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
59
|
+
source: str = ""
|
|
60
|
+
oauth: dict | None = None
|
|
61
|
+
expand_env: bool = True
|
|
62
|
+
|
|
63
|
+
def expand(self, value):
|
|
64
|
+
return _fill(value) if self.expand_env else value
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def parse(cls, name: str, raw: dict, source: str) -> ServerConfig:
|
|
68
|
+
if isinstance(raw, dict) and "connector" in raw:
|
|
69
|
+
connector = raw["connector"]
|
|
70
|
+
if not isinstance(connector, str) or connector not in _CONNECTORS:
|
|
71
|
+
raise McpError(f"{source}: unknown MCP connector {connector!r}")
|
|
72
|
+
oauth = raw.get("oauth")
|
|
73
|
+
if not isinstance(oauth, dict):
|
|
74
|
+
raise McpError(f"{source}: connector {name!r} needs OAuth client configuration")
|
|
75
|
+
url, issuer = _CONNECTORS[connector]
|
|
76
|
+
raw = {**raw, "url": url, "oauth": {**oauth, "issuer": issuer}}
|
|
77
|
+
if not isinstance(raw, dict) or bool(raw.get("command")) == bool(raw.get("url")):
|
|
78
|
+
raise McpError(f"{source}: server {name!r} needs either command or url")
|
|
79
|
+
oauth = raw.get("oauth")
|
|
80
|
+
if oauth is not None and (
|
|
81
|
+
not raw.get("url") or not isinstance(oauth, dict)
|
|
82
|
+
or not all(isinstance(oauth.get(k), str) and oauth[k] for k in ("issuer", "client_id"))
|
|
83
|
+
or type(oauth.get("redirect_port")) is not int
|
|
84
|
+
or not 1 <= oauth["redirect_port"] <= 65535
|
|
85
|
+
or not isinstance(oauth.get("scope", ""), str)
|
|
86
|
+
or type(oauth.get("persist", False)) is not bool
|
|
87
|
+
or not isinstance(oauth.get("client_secret_env", ""), str)
|
|
88
|
+
):
|
|
89
|
+
raise McpError(f"{source}: server {name!r} needs OAuth issuer, client_id and redirect_port")
|
|
90
|
+
return cls(name, raw.get("command"), [str(a) for a in raw.get("args", [])],
|
|
91
|
+
{str(k): str(v) for k, v in raw.get("env", {}).items()},
|
|
92
|
+
raw.get("url"), {str(k): str(v) for k, v in raw.get("headers", {}).items()},
|
|
93
|
+
source, oauth)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def load_configs(home: Path, project: Path | None) -> tuple[dict[str, ServerConfig], list[str]]:
|
|
97
|
+
"""Servers from the home file, then the project's (which wins on a name), and problems."""
|
|
98
|
+
servers: dict[str, ServerConfig] = {}
|
|
99
|
+
problems: list[str] = []
|
|
100
|
+
files = [home / "mcp.json"]
|
|
101
|
+
if project is not None:
|
|
102
|
+
files.append(project / ".rlm-agent" / "mcp.json")
|
|
103
|
+
for path in files:
|
|
104
|
+
if not path.exists():
|
|
105
|
+
continue
|
|
106
|
+
try:
|
|
107
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
108
|
+
for name, raw in (data.get("servers") or {}).items():
|
|
109
|
+
servers[name] = ServerConfig.parse(name, raw, str(path))
|
|
110
|
+
except (OSError, json.JSONDecodeError, McpError, AttributeError) as exc:
|
|
111
|
+
problems.append(f"{path}: {exc}")
|
|
112
|
+
return servers, problems
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class _Stdio:
|
|
116
|
+
"""Newline-delimited JSON-RPC over a child process's stdin and stdout."""
|
|
117
|
+
|
|
118
|
+
def __init__(self, config: ServerConfig, cwd: str) -> None:
|
|
119
|
+
self.config, self.cwd = config, cwd
|
|
120
|
+
self.proc: asyncio.subprocess.Process | None = None
|
|
121
|
+
self._waiting: dict[int, asyncio.Future] = {}
|
|
122
|
+
self._reader: asyncio.Task | None = None
|
|
123
|
+
|
|
124
|
+
async def open(self) -> None:
|
|
125
|
+
import shutil
|
|
126
|
+
|
|
127
|
+
from rlmagent.model.external.cli import own_group
|
|
128
|
+
|
|
129
|
+
command = shutil.which(self.config.command) or self.config.command
|
|
130
|
+
self.proc = await asyncio.create_subprocess_exec(
|
|
131
|
+
command, *self.config.expand(self.config.args), cwd=self.cwd,
|
|
132
|
+
env={**os.environ, **self.config.expand(self.config.env)},
|
|
133
|
+
stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
|
|
134
|
+
stderr=asyncio.subprocess.DEVNULL, limit=16 * 1024 * 1024, **own_group())
|
|
135
|
+
self._reader = asyncio.create_task(self._read())
|
|
136
|
+
|
|
137
|
+
async def _read(self) -> None:
|
|
138
|
+
assert self.proc is not None and self.proc.stdout is not None
|
|
139
|
+
while line := await self.proc.stdout.readline():
|
|
140
|
+
try:
|
|
141
|
+
message = json.loads(line)
|
|
142
|
+
except json.JSONDecodeError:
|
|
143
|
+
continue
|
|
144
|
+
waiter = self._waiting.pop(message.get("id"), None) if isinstance(message, dict) else None
|
|
145
|
+
if waiter is not None and not waiter.done():
|
|
146
|
+
waiter.set_result(message)
|
|
147
|
+
for waiter in self._waiting.values():
|
|
148
|
+
if not waiter.done():
|
|
149
|
+
waiter.set_exception(McpError(f"{self.config.name} stopped"))
|
|
150
|
+
self._waiting.clear()
|
|
151
|
+
|
|
152
|
+
async def send(self, message: dict) -> dict | None:
|
|
153
|
+
assert self.proc is not None and self.proc.stdin is not None
|
|
154
|
+
waiter = None
|
|
155
|
+
if "id" in message:
|
|
156
|
+
waiter = asyncio.get_running_loop().create_future()
|
|
157
|
+
self._waiting[message["id"]] = waiter
|
|
158
|
+
self.proc.stdin.write((json.dumps(message) + "\n").encode())
|
|
159
|
+
await self.proc.stdin.drain()
|
|
160
|
+
return await waiter if waiter is not None else None
|
|
161
|
+
|
|
162
|
+
async def close(self) -> None:
|
|
163
|
+
if self.proc is not None and self.proc.returncode is None:
|
|
164
|
+
from rlmagent.model.external.cli import kill_tree
|
|
165
|
+
|
|
166
|
+
kill_tree(self.proc)
|
|
167
|
+
await self.proc.wait()
|
|
168
|
+
if self._reader is not None:
|
|
169
|
+
self._reader.cancel()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class _Http:
|
|
173
|
+
"""JSON-RPC over HTTP POST; the reply is JSON or a stream of server-sent events."""
|
|
174
|
+
|
|
175
|
+
def __init__(self, config: ServerConfig) -> None:
|
|
176
|
+
self.config = config
|
|
177
|
+
self.session_id: str | None = None
|
|
178
|
+
self.client = None
|
|
179
|
+
self.credential = None
|
|
180
|
+
self.credential_key = None
|
|
181
|
+
self._auth_lock = asyncio.Lock()
|
|
182
|
+
|
|
183
|
+
async def open(self) -> None:
|
|
184
|
+
import httpx
|
|
185
|
+
|
|
186
|
+
self.client = httpx.AsyncClient(timeout=CALL_SECONDS)
|
|
187
|
+
|
|
188
|
+
async def send(self, message: dict) -> dict | None:
|
|
189
|
+
headers = {"Content-Type": "application/json",
|
|
190
|
+
"Accept": "application/json, text/event-stream",
|
|
191
|
+
"MCP-Protocol-Version": PROTOCOL, **self.config.expand(self.config.headers)}
|
|
192
|
+
if self.session_id:
|
|
193
|
+
headers["Mcp-Session-Id"] = self.session_id
|
|
194
|
+
url = self.config.expand(self.config.url)
|
|
195
|
+
token = await self._token(url) if self.config.oauth else None
|
|
196
|
+
if token:
|
|
197
|
+
headers = {k: v for k, v in headers.items() if k.lower() != "authorization"}
|
|
198
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
199
|
+
response = await self.client.post(url, json=message, headers=headers)
|
|
200
|
+
if response.status_code == 401 and self.config.oauth:
|
|
201
|
+
raise McpError(f"{self.config.name} requires login. Run /mcp login {self.config.name}.")
|
|
202
|
+
if response.status_code >= 400:
|
|
203
|
+
raise McpError(f"{self.config.name} answered HTTP {response.status_code}")
|
|
204
|
+
self.session_id = response.headers.get("mcp-session-id", self.session_id)
|
|
205
|
+
if "id" not in message:
|
|
206
|
+
return None
|
|
207
|
+
if response.headers.get("content-type", "").startswith("text/event-stream"):
|
|
208
|
+
for line in response.text.splitlines():
|
|
209
|
+
if line.startswith("data:"):
|
|
210
|
+
try:
|
|
211
|
+
data = json.loads(line[5:].strip())
|
|
212
|
+
except json.JSONDecodeError:
|
|
213
|
+
continue
|
|
214
|
+
if isinstance(data, dict) and data.get("id") == message["id"]:
|
|
215
|
+
return data
|
|
216
|
+
raise McpError(f"{self.config.name} sent no answer")
|
|
217
|
+
return response.json()
|
|
218
|
+
|
|
219
|
+
async def _token(self, url: str) -> str | None:
|
|
220
|
+
from rlmagent.mcp_oauth import keyring_io, refresh
|
|
221
|
+
|
|
222
|
+
config = _fill(self.config.oauth)
|
|
223
|
+
key = (url, config)
|
|
224
|
+
async with self._auth_lock:
|
|
225
|
+
if key != self.credential_key:
|
|
226
|
+
self.credential = await asyncio.to_thread(keyring_io, url, config, "load")
|
|
227
|
+
self.credential_key = key
|
|
228
|
+
value = self.credential
|
|
229
|
+
if value is not None and value.expires is not None and time.time() >= value.expires:
|
|
230
|
+
self.credential = value = await refresh(url, config, value)
|
|
231
|
+
await asyncio.to_thread(keyring_io, url, config, "save", value)
|
|
232
|
+
return value.access if value is not None else None
|
|
233
|
+
|
|
234
|
+
async def close(self) -> None:
|
|
235
|
+
if self.client is not None:
|
|
236
|
+
await self.client.aclose()
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class Server:
|
|
240
|
+
def __init__(self, config: ServerConfig, cwd: str) -> None:
|
|
241
|
+
self.config = config
|
|
242
|
+
self.transport = _Http(config) if config.url else _Stdio(config, cwd)
|
|
243
|
+
self.tools: list[dict] = []
|
|
244
|
+
self.state = "not started"
|
|
245
|
+
self.error = ""
|
|
246
|
+
self._next = 0
|
|
247
|
+
self._lock = asyncio.Lock()
|
|
248
|
+
|
|
249
|
+
async def _request(self, method: str, params: dict | None = None,
|
|
250
|
+
seconds: float = CALL_SECONDS) -> dict:
|
|
251
|
+
self._next += 1
|
|
252
|
+
message = {"jsonrpc": "2.0", "id": self._next, "method": method, "params": params or {}}
|
|
253
|
+
reply = await asyncio.wait_for(self.transport.send(message), seconds)
|
|
254
|
+
if reply is None:
|
|
255
|
+
raise McpError(f"{self.config.name} gave no reply to {method}")
|
|
256
|
+
if reply.get("error"):
|
|
257
|
+
raise McpError(f"{self.config.name}: {reply['error'].get('message', reply['error'])}")
|
|
258
|
+
return reply.get("result") or {}
|
|
259
|
+
|
|
260
|
+
async def start(self) -> None:
|
|
261
|
+
async with self._lock:
|
|
262
|
+
if self.state == "ready":
|
|
263
|
+
return
|
|
264
|
+
try:
|
|
265
|
+
await self.transport.open()
|
|
266
|
+
await self._request("initialize", {
|
|
267
|
+
"protocolVersion": PROTOCOL, "capabilities": {},
|
|
268
|
+
"clientInfo": {"name": "rlm-agent", "version": "1"}}, START_SECONDS)
|
|
269
|
+
await self.transport.send({"jsonrpc": "2.0", "method": "notifications/initialized"})
|
|
270
|
+
self.tools = (await self._request("tools/list", seconds=START_SECONDS)).get("tools", [])
|
|
271
|
+
self.state, self.error = "ready", ""
|
|
272
|
+
except Exception as exc:
|
|
273
|
+
self.state, self.error = "failed", str(exc) or type(exc).__name__
|
|
274
|
+
await self.transport.close()
|
|
275
|
+
raise McpError(f"{self.config.name} did not start: {self.error}") from exc
|
|
276
|
+
|
|
277
|
+
def tool(self, name: str) -> dict:
|
|
278
|
+
for tool in self.tools:
|
|
279
|
+
if tool.get("name") == name:
|
|
280
|
+
return tool
|
|
281
|
+
raise McpError(f"{self.config.name} has no tool {name!r}; "
|
|
282
|
+
f"it has {', '.join(t.get('name', '?') for t in self.tools) or 'none'}")
|
|
283
|
+
|
|
284
|
+
async def call(self, name: str, arguments: dict) -> str:
|
|
285
|
+
result = await self._request("tools/call", {"name": name, "arguments": arguments})
|
|
286
|
+
parts = []
|
|
287
|
+
for item in result.get("content", []):
|
|
288
|
+
if item.get("type") == "text":
|
|
289
|
+
parts.append(item.get("text", ""))
|
|
290
|
+
else:
|
|
291
|
+
parts.append(f"[{item.get('type', 'content')} not shown]")
|
|
292
|
+
if not parts and "structuredContent" in result:
|
|
293
|
+
parts.append(json.dumps(result["structuredContent"]))
|
|
294
|
+
text = "\n".join(parts)[:MAX_RESULT_CHARS]
|
|
295
|
+
if result.get("isError"):
|
|
296
|
+
raise McpError(text or f"{name} failed")
|
|
297
|
+
return text
|
|
298
|
+
|
|
299
|
+
async def close(self) -> None:
|
|
300
|
+
await self.transport.close()
|
|
301
|
+
self.state = "stopped"
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class McpHub:
|
|
305
|
+
"""Every configured server, started when first used."""
|
|
306
|
+
|
|
307
|
+
def __init__(self, configs: dict[str, ServerConfig], cwd: str, approver=None,
|
|
308
|
+
problems: list[str] | None = None) -> None:
|
|
309
|
+
self.servers = {name: Server(config, cwd) for name, config in configs.items()}
|
|
310
|
+
self.approver = approver
|
|
311
|
+
self.problems = problems or []
|
|
312
|
+
|
|
313
|
+
def _server(self, name: str) -> Server:
|
|
314
|
+
server = self.servers.get(name)
|
|
315
|
+
if server is None:
|
|
316
|
+
known = ", ".join(self.servers) or "none are configured"
|
|
317
|
+
raise McpError(f"no MCP server named {name!r} ({known})")
|
|
318
|
+
return server
|
|
319
|
+
|
|
320
|
+
async def mcp_tools(self, server: str | None = None) -> list[dict]:
|
|
321
|
+
"""The tools MCP servers offer: [{"server", "tool", "description", "input"}].
|
|
322
|
+
Pass a server name to list only that one."""
|
|
323
|
+
rows = []
|
|
324
|
+
for name in [server] if server else list(self.servers):
|
|
325
|
+
target = self._server(name)
|
|
326
|
+
try:
|
|
327
|
+
await target.start()
|
|
328
|
+
except McpError as exc:
|
|
329
|
+
rows.append({"server": name, "error": str(exc)})
|
|
330
|
+
continue
|
|
331
|
+
rows += [{"server": name, "tool": t.get("name"),
|
|
332
|
+
"description": (t.get("description") or "")[:300],
|
|
333
|
+
"input": t.get("inputSchema", {})} for t in target.tools]
|
|
334
|
+
return rows
|
|
335
|
+
|
|
336
|
+
async def login(self, name: str) -> str:
|
|
337
|
+
from rlmagent.mcp_oauth import keyring_io, login
|
|
338
|
+
|
|
339
|
+
target = self._server(name)
|
|
340
|
+
transport = target.transport
|
|
341
|
+
if not isinstance(transport, _Http) or target.config.oauth is None:
|
|
342
|
+
raise McpError(f"{name} has no HTTP OAuth configuration")
|
|
343
|
+
async with target._lock:
|
|
344
|
+
url = _fill(target.config.url)
|
|
345
|
+
config = _fill(target.config.oauth)
|
|
346
|
+
if config.get("persist"):
|
|
347
|
+
await asyncio.to_thread(keyring_io, url, config, "load")
|
|
348
|
+
credential = await login(url, config)
|
|
349
|
+
await asyncio.to_thread(keyring_io, url, config, "save", credential)
|
|
350
|
+
await target.close()
|
|
351
|
+
transport.session_id = None
|
|
352
|
+
transport.credential = credential
|
|
353
|
+
transport.credential_key = (url, config)
|
|
354
|
+
await target.start()
|
|
355
|
+
return f"Signed in to {name}."
|
|
356
|
+
|
|
357
|
+
async def logout(self, name: str) -> str:
|
|
358
|
+
from rlmagent.mcp_oauth import keyring_io
|
|
359
|
+
|
|
360
|
+
target = self._server(name)
|
|
361
|
+
transport = target.transport
|
|
362
|
+
if not isinstance(transport, _Http) or target.config.oauth is None:
|
|
363
|
+
raise McpError(f"{name} has no HTTP OAuth configuration")
|
|
364
|
+
async with target._lock:
|
|
365
|
+
url, config = _fill(target.config.url), _fill(target.config.oauth)
|
|
366
|
+
await asyncio.to_thread(keyring_io, url, config, "delete")
|
|
367
|
+
await target.close()
|
|
368
|
+
transport.session_id = None
|
|
369
|
+
transport.credential = None
|
|
370
|
+
transport.credential_key = (url, config)
|
|
371
|
+
target.tools.clear()
|
|
372
|
+
return f"Signed out of {name}."
|
|
373
|
+
|
|
374
|
+
async def mcp(self, server: str, tool: str, arguments: dict | None = None) -> str:
|
|
375
|
+
"""Call one tool on an MCP server; see mcp_tools() for what exists."""
|
|
376
|
+
from rlmagent.agents.approval import Request, check
|
|
377
|
+
|
|
378
|
+
target = self._server(server)
|
|
379
|
+
await target.start()
|
|
380
|
+
spec = target.tool(tool)
|
|
381
|
+
arguments = dict(arguments or {})
|
|
382
|
+
read_only = bool((spec.get("annotations") or {}).get("readOnlyHint"))
|
|
383
|
+
if self.approver is not None and not read_only:
|
|
384
|
+
request = Request("mcp", f"{server}.{tool}", (json.dumps(arguments, sort_keys=True),))
|
|
385
|
+
check(await self.approver.approve(request), request)
|
|
386
|
+
return await target.call(tool, arguments)
|
|
387
|
+
|
|
388
|
+
def report(self) -> str:
|
|
389
|
+
if not self.servers and not self.problems:
|
|
390
|
+
return ("No MCP servers. Add them to mcp.json in the rlm-agent home, or to "
|
|
391
|
+
".rlm-agent/mcp.json in a trusted project.")
|
|
392
|
+
lines = []
|
|
393
|
+
for name, server in self.servers.items():
|
|
394
|
+
where = server.config.url or server.config.command
|
|
395
|
+
line = f"{name} ({where}): {server.state}"
|
|
396
|
+
if server.state == "ready":
|
|
397
|
+
line += f", {len(server.tools)} tools: " + ", ".join(
|
|
398
|
+
t.get("name", "?") for t in server.tools)
|
|
399
|
+
if server.error:
|
|
400
|
+
line += f" — {server.error}"
|
|
401
|
+
lines.append(line)
|
|
402
|
+
lines += [f"problem: {p}" for p in self.problems]
|
|
403
|
+
return "\n".join(lines)
|
|
404
|
+
|
|
405
|
+
async def close(self) -> None:
|
|
406
|
+
for server in self.servers.values():
|
|
407
|
+
if server.state == "ready":
|
|
408
|
+
await server.close()
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
__all__ = ["McpError", "McpHub", "ServerConfig", "load_configs"]
|
rlmagent/mcp_oauth.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Browser login for explicitly configured public MCP clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import base64
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import secrets
|
|
13
|
+
import time
|
|
14
|
+
import webbrowser
|
|
15
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
16
|
+
from dataclasses import asdict, dataclass, field
|
|
17
|
+
from urllib.parse import parse_qs, urlsplit
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from rlmagent.mcp import McpError
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Credential:
|
|
26
|
+
access: str = field(repr=False)
|
|
27
|
+
refresh: str | None = field(repr=False)
|
|
28
|
+
expires: float | None
|
|
29
|
+
endpoint: str
|
|
30
|
+
audience: str | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _credential(reply, endpoint: str, previous: str | None = None,
|
|
34
|
+
audience: str | None = None) -> Credential:
|
|
35
|
+
access = reply["access_token"]
|
|
36
|
+
if (reply.get("token_type", "").lower() != "bearer" or not isinstance(access, str)
|
|
37
|
+
or not access or any(ch.isspace() for ch in access)):
|
|
38
|
+
raise McpError("MCP authorization server returned an invalid bearer token")
|
|
39
|
+
refresh = reply.get("refresh_token", previous)
|
|
40
|
+
if refresh is not None and (not isinstance(refresh, str) or not refresh):
|
|
41
|
+
raise McpError("MCP authorization server returned an invalid refresh token")
|
|
42
|
+
seconds = reply.get("expires_in")
|
|
43
|
+
lifetime = float(seconds) if seconds is not None else None
|
|
44
|
+
if lifetime is not None and (not math.isfinite(lifetime) or lifetime < 0):
|
|
45
|
+
raise McpError("MCP authorization server returned an invalid token lifetime")
|
|
46
|
+
return Credential(access, refresh, time.time() + lifetime if lifetime is not None else None,
|
|
47
|
+
endpoint, audience)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _account(resource: str, config: dict) -> str:
|
|
51
|
+
return hashlib.sha256(json.dumps([
|
|
52
|
+
resource, config["issuer"], config["client_id"], config.get("scope", ""),
|
|
53
|
+
]).encode()).hexdigest()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def keyring_io(resource: str, config: dict, action: str, credential=None):
|
|
57
|
+
"""Use the platform credential store when persistence is explicitly enabled."""
|
|
58
|
+
if not config.get("persist"):
|
|
59
|
+
return None
|
|
60
|
+
try:
|
|
61
|
+
import keyring
|
|
62
|
+
from keyring.errors import KeyringError
|
|
63
|
+
except ImportError:
|
|
64
|
+
raise McpError("Persistent MCP login requires the mcp-keyring extra") from None
|
|
65
|
+
service, account = "rlmagent-mcp", _account(resource, config)
|
|
66
|
+
try:
|
|
67
|
+
if action == "load":
|
|
68
|
+
stored = keyring.get_password(service, account)
|
|
69
|
+
return Credential(**json.loads(stored)) if stored else None
|
|
70
|
+
if action == "save":
|
|
71
|
+
keyring.set_password(service, account, json.dumps(asdict(credential)))
|
|
72
|
+
elif keyring.get_password(service, account) is not None:
|
|
73
|
+
keyring.delete_password(service, account)
|
|
74
|
+
except (KeyringError, ValueError, TypeError, OSError):
|
|
75
|
+
raise McpError("The platform credential store could not access the MCP login") from None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def refresh(resource: str, config: dict, current: Credential) -> Credential:
|
|
79
|
+
if not current.refresh:
|
|
80
|
+
raise McpError("MCP login expired. Sign in again.")
|
|
81
|
+
try:
|
|
82
|
+
async with httpx.AsyncClient(timeout=15, follow_redirects=False) as client:
|
|
83
|
+
response = await client.post(https_url(current.endpoint), data={
|
|
84
|
+
"grant_type": "refresh_token", "refresh_token": current.refresh,
|
|
85
|
+
**_client_fields(config), "resource": current.audience or str(https_url(resource)),
|
|
86
|
+
})
|
|
87
|
+
return _credential(response.raise_for_status().json(), current.endpoint,
|
|
88
|
+
current.refresh, current.audience)
|
|
89
|
+
except (httpx.HTTPError, ValueError, TypeError, KeyError, AttributeError):
|
|
90
|
+
raise McpError("MCP login could not be refreshed. Sign in again.") from None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def https_url(value: str) -> httpx.URL:
|
|
94
|
+
url = httpx.URL(value)
|
|
95
|
+
if url.scheme != "https" or not url.host or url.userinfo or url.fragment:
|
|
96
|
+
raise McpError("MCP OAuth endpoints must use HTTPS without embedded credentials")
|
|
97
|
+
return url.copy_with(path="/") if url.path == "/" and not url.query else url
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _client_fields(config: dict) -> dict:
|
|
101
|
+
client_id = config["client_id"]
|
|
102
|
+
if not client_id:
|
|
103
|
+
raise McpError("MCP OAuth client ID is unset")
|
|
104
|
+
fields = {"client_id": client_id}
|
|
105
|
+
if name := config.get("client_secret_env"):
|
|
106
|
+
secret = os.environ.get(name)
|
|
107
|
+
if not secret:
|
|
108
|
+
raise McpError(f"MCP OAuth client secret environment variable {name} is unset")
|
|
109
|
+
fields["client_secret"] = secret
|
|
110
|
+
return fields
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def receive_code(endpoint, params: dict, port: int) -> tuple[str, str]:
|
|
114
|
+
state = params["state"]
|
|
115
|
+
result = {}
|
|
116
|
+
|
|
117
|
+
class Callback(BaseHTTPRequestHandler):
|
|
118
|
+
def setup(self):
|
|
119
|
+
self.request.settimeout(5)
|
|
120
|
+
super().setup()
|
|
121
|
+
|
|
122
|
+
def log_message(self, *_args):
|
|
123
|
+
pass
|
|
124
|
+
|
|
125
|
+
def do_GET(self):
|
|
126
|
+
request = urlsplit(self.path)
|
|
127
|
+
query = parse_qs(request.query)
|
|
128
|
+
supplied = query.get("state", [""])[0]
|
|
129
|
+
valid = request.path == "/oauth/callback" and secrets.compare_digest(
|
|
130
|
+
supplied.encode(), state.encode()
|
|
131
|
+
)
|
|
132
|
+
if valid:
|
|
133
|
+
result["code"] = query.get("code", [""])[0]
|
|
134
|
+
self.send_response(200 if valid else 400)
|
|
135
|
+
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
|
136
|
+
self.end_headers()
|
|
137
|
+
self.wfile.write(b"Return to rlm-agent." if valid else b"Invalid login callback.")
|
|
138
|
+
|
|
139
|
+
with HTTPServer(("127.0.0.1", port), Callback) as server:
|
|
140
|
+
redirect = f"http://127.0.0.1:{server.server_port}/oauth/callback"
|
|
141
|
+
target = endpoint.copy_merge_params({**params, "redirect_uri": redirect})
|
|
142
|
+
if not webbrowser.open(str(target)):
|
|
143
|
+
raise McpError("Could not open a browser for MCP login")
|
|
144
|
+
deadline = time.monotonic() + 120
|
|
145
|
+
while not result and time.monotonic() < deadline:
|
|
146
|
+
server.timeout = max(0, deadline - time.monotonic())
|
|
147
|
+
server.handle_request()
|
|
148
|
+
if not result.get("code"):
|
|
149
|
+
raise McpError("MCP login was denied or timed out")
|
|
150
|
+
return result["code"], redirect
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
async def login(resource: str, config: dict) -> Credential:
|
|
154
|
+
resource_url = https_url(resource)
|
|
155
|
+
issuer = https_url(config["issuer"])
|
|
156
|
+
if issuer.query:
|
|
157
|
+
raise McpError("MCP OAuth issuer must be a URL without a query")
|
|
158
|
+
# Confirm the server advertises the configured issuer before requesting credentials.
|
|
159
|
+
async with httpx.AsyncClient(timeout=15, follow_redirects=False) as client:
|
|
160
|
+
try:
|
|
161
|
+
response = await client.post(resource_url, json={
|
|
162
|
+
"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
|
163
|
+
"params": {"protocolVersion": "2025-06-18", "capabilities": {},
|
|
164
|
+
"clientInfo": {"name": "rlm-agent", "version": "1"}},
|
|
165
|
+
}, headers={"Accept": "application/json, text/event-stream"})
|
|
166
|
+
challenge = response.headers.get("www-authenticate", "")
|
|
167
|
+
match = re.search(r'\bresource_metadata="([^"]+)"', challenge, re.I)
|
|
168
|
+
metadata_url = (https_url(match[1]) if match else resource_url.copy_with(
|
|
169
|
+
path="/.well-known/oauth-protected-resource" + resource_url.path.rstrip("/"),
|
|
170
|
+
query=None,
|
|
171
|
+
))
|
|
172
|
+
if (metadata_url.host, metadata_url.port) != (resource_url.host, resource_url.port):
|
|
173
|
+
raise McpError("MCP resource metadata must be on the configured server origin")
|
|
174
|
+
metadata_reply = await client.get(metadata_url)
|
|
175
|
+
if metadata_reply.status_code == 404 and not match:
|
|
176
|
+
metadata_reply = await client.get(resource_url.copy_with(
|
|
177
|
+
path="/.well-known/oauth-protected-resource", query=None,
|
|
178
|
+
))
|
|
179
|
+
metadata = metadata_reply.raise_for_status().json()
|
|
180
|
+
audience = https_url(metadata["resource"])
|
|
181
|
+
origin = resource_url.copy_with(path="/", query=None)
|
|
182
|
+
issuers = [https_url(value) for value in metadata.get("authorization_servers", [])]
|
|
183
|
+
if audience not in (resource_url, origin) or issuer not in issuers:
|
|
184
|
+
raise McpError("MCP server metadata did not confirm the configured issuer and resource")
|
|
185
|
+
auth_metadata = issuer.copy_with(
|
|
186
|
+
path="/.well-known/oauth-authorization-server" + issuer.path.rstrip("/")
|
|
187
|
+
)
|
|
188
|
+
provider = (await client.get(auth_metadata)).raise_for_status().json()
|
|
189
|
+
if https_url(provider["issuer"]) != issuer:
|
|
190
|
+
raise McpError("MCP authorization metadata returned a different issuer")
|
|
191
|
+
if "S256" not in provider.get("code_challenge_methods_supported", []):
|
|
192
|
+
raise McpError("MCP authorization server did not advertise S256 PKCE")
|
|
193
|
+
authorize = https_url(provider["authorization_endpoint"])
|
|
194
|
+
token = https_url(provider["token_endpoint"])
|
|
195
|
+
verifier = secrets.token_urlsafe(48)
|
|
196
|
+
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest())
|
|
197
|
+
fields = {"client_id": config["client_id"], "resource": str(audience)}
|
|
198
|
+
client_fields = _client_fields(config)
|
|
199
|
+
params = {**fields, "response_type": "code", "state": secrets.token_urlsafe(32),
|
|
200
|
+
"code_challenge_method": "S256",
|
|
201
|
+
"code_challenge": challenge.decode().rstrip("=")}
|
|
202
|
+
if config.get("scope"):
|
|
203
|
+
params["scope"] = config["scope"]
|
|
204
|
+
code, redirect = await asyncio.to_thread(
|
|
205
|
+
receive_code, authorize, params, config["redirect_port"]
|
|
206
|
+
)
|
|
207
|
+
reply = (await client.post(token, data={
|
|
208
|
+
**fields, **client_fields, "grant_type": "authorization_code", "code": code,
|
|
209
|
+
"redirect_uri": redirect, "code_verifier": verifier,
|
|
210
|
+
})).raise_for_status().json()
|
|
211
|
+
return _credential(reply, str(token), audience=str(audience))
|
|
212
|
+
except (httpx.HTTPError, ValueError, TypeError, KeyError, AttributeError, OSError):
|
|
213
|
+
raise McpError("MCP OAuth discovery or token exchange failed") from None
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""rlm-agent client: LLM providers — turn any model into a typed wire-event stream."""
|
|
2
|
+
|
|
3
|
+
from rlmagent.model.claude import AnthropicProvider
|
|
4
|
+
from rlmagent.model.oai_compatible import OpenAIProvider
|
|
5
|
+
from rlmagent.model.settings import (
|
|
6
|
+
AnthropicProfile,
|
|
7
|
+
ConfigError,
|
|
8
|
+
Credential,
|
|
9
|
+
CredentialResolver,
|
|
10
|
+
OpenAIProfile,
|
|
11
|
+
ReasoningPolicy,
|
|
12
|
+
RetryPolicy,
|
|
13
|
+
load_anthropic_profile,
|
|
14
|
+
load_openai_profile,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"AnthropicProfile",
|
|
19
|
+
"AnthropicProvider",
|
|
20
|
+
"ConfigError",
|
|
21
|
+
"Credential",
|
|
22
|
+
"CredentialResolver",
|
|
23
|
+
"OpenAIProfile",
|
|
24
|
+
"OpenAIProvider",
|
|
25
|
+
"ReasoningPolicy",
|
|
26
|
+
"RetryPolicy",
|
|
27
|
+
"load_anthropic_profile",
|
|
28
|
+
"load_openai_profile",
|
|
29
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Internal implementation package for the Anthropic Messages API adapter."""
|