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.
Files changed (203) hide show
  1. rlmagent/__init__.py +1 -0
  2. rlmagent/__main__.py +8 -0
  3. rlmagent/acp/__init__.py +1 -0
  4. rlmagent/acp/runtime.py +71 -0
  5. rlmagent/acp/server.py +598 -0
  6. rlmagent/acp/updates.py +128 -0
  7. rlmagent/agentnet.py +237 -0
  8. rlmagent/agents/__init__.py +1 -0
  9. rlmagent/agents/approval.py +90 -0
  10. rlmagent/agents/bridge.py +109 -0
  11. rlmagent/agents/budget.py +212 -0
  12. rlmagent/agents/child_handles.py +46 -0
  13. rlmagent/agents/child_mailbox.py +30 -0
  14. rlmagent/agents/kernel_api.py +156 -0
  15. rlmagent/agents/output.py +34 -0
  16. rlmagent/agents/patch.py +107 -0
  17. rlmagent/agents/prompts.py +156 -0
  18. rlmagent/agents/trace.py +290 -0
  19. rlmagent/agents/tree.py +682 -0
  20. rlmagent/agents/wasm_api.py +105 -0
  21. rlmagent/agents/workspace.py +357 -0
  22. rlmagent/autopilot.py +447 -0
  23. rlmagent/backend.py +946 -0
  24. rlmagent/backend_context.py +74 -0
  25. rlmagent/cli/__init__.py +5 -0
  26. rlmagent/cli/config_cmd.py +177 -0
  27. rlmagent/cli/health.py +156 -0
  28. rlmagent/cli/main.py +1194 -0
  29. rlmagent/cli/update.py +188 -0
  30. rlmagent/clipboard.py +63 -0
  31. rlmagent/config/__init__.py +37 -0
  32. rlmagent/config/config.py +297 -0
  33. rlmagent/config/loader.py +119 -0
  34. rlmagent/config/prompts.py +124 -0
  35. rlmagent/config/secure_input.py +213 -0
  36. rlmagent/config/store.py +275 -0
  37. rlmagent/context/__init__.py +47 -0
  38. rlmagent/context/budget.py +557 -0
  39. rlmagent/contract.py +311 -0
  40. rlmagent/contract_run.py +232 -0
  41. rlmagent/conversation.py +2547 -0
  42. rlmagent/cron.py +77 -0
  43. rlmagent/directives.py +893 -0
  44. rlmagent/discovery.py +356 -0
  45. rlmagent/execution_log.py +66 -0
  46. rlmagent/factory.py +189 -0
  47. rlmagent/factory_graph.py +413 -0
  48. rlmagent/harness/__init__.py +63 -0
  49. rlmagent/harness/contracts/__init__.py +1 -0
  50. rlmagent/harness/contracts/stream.py +129 -0
  51. rlmagent/harness/contracts/tooling.py +127 -0
  52. rlmagent/harness/contracts/transcript/__init__.py +68 -0
  53. rlmagent/harness/contracts/transcript/_base.py +29 -0
  54. rlmagent/harness/contracts/transcript/diagnostics.py +52 -0
  55. rlmagent/harness/contracts/transcript/entries.py +180 -0
  56. rlmagent/harness/contracts/transcript/segments.py +64 -0
  57. rlmagent/harness/contracts/values.py +6 -0
  58. rlmagent/harness/driver.py +397 -0
  59. rlmagent/harness/engine.py +489 -0
  60. rlmagent/harness/mending.py +153 -0
  61. rlmagent/harness/provider/__init__.py +1 -0
  62. rlmagent/harness/provider/base.py +27 -0
  63. rlmagent/harness/provider/wire.py +189 -0
  64. rlmagent/harness/py.typed +0 -0
  65. rlmagent/harness/session/__init__.py +5 -0
  66. rlmagent/harness/session/index.py +218 -0
  67. rlmagent/harness/session/lease.py +66 -0
  68. rlmagent/harness/session/records.py +118 -0
  69. rlmagent/harness/session/replay.py +165 -0
  70. rlmagent/harness/session/store.py +155 -0
  71. rlmagent/harness/session/summary.py +46 -0
  72. rlmagent/hints.py +47 -0
  73. rlmagent/history.py +92 -0
  74. rlmagent/hooks/__init__.py +1 -0
  75. rlmagent/hooks/hooks.py +1 -0
  76. rlmagent/instructions.py +157 -0
  77. rlmagent/job_runner.py +117 -0
  78. rlmagent/jobs.py +365 -0
  79. rlmagent/kept_agents.py +397 -0
  80. rlmagent/kernel/__init__.py +5 -0
  81. rlmagent/kernel/session.py +205 -0
  82. rlmagent/kernel/wasm.py +169 -0
  83. rlmagent/logs.py +208 -0
  84. rlmagent/managed_release.py +368 -0
  85. rlmagent/mcp.py +411 -0
  86. rlmagent/mcp_oauth.py +213 -0
  87. rlmagent/model/__init__.py +29 -0
  88. rlmagent/model/_claude/__init__.py +1 -0
  89. rlmagent/model/_claude/courier.py +117 -0
  90. rlmagent/model/_claude/emitter.py +378 -0
  91. rlmagent/model/_oai/__init__.py +1 -0
  92. rlmagent/model/_oai/helpers.py +256 -0
  93. rlmagent/model/_oai/normalize.py +263 -0
  94. rlmagent/model/_oai/parsers.py +317 -0
  95. rlmagent/model/_oai/payloads.py +226 -0
  96. rlmagent/model/_oai/transport.py +127 -0
  97. rlmagent/model/claude.py +552 -0
  98. rlmagent/model/copilot.py +142 -0
  99. rlmagent/model/correlation.py +21 -0
  100. rlmagent/model/deadline.py +19 -0
  101. rlmagent/model/discovery.py +181 -0
  102. rlmagent/model/external/__init__.py +11 -0
  103. rlmagent/model/external/acp.py +202 -0
  104. rlmagent/model/external/acp_install.py +109 -0
  105. rlmagent/model/external/cli.py +248 -0
  106. rlmagent/model/external/textcalls.py +57 -0
  107. rlmagent/model/failover.py +129 -0
  108. rlmagent/model/google.py +199 -0
  109. rlmagent/model/limits.py +46 -0
  110. rlmagent/model/oai_compatible.py +414 -0
  111. rlmagent/model/preferences.py +56 -0
  112. rlmagent/model/py.typed +0 -0
  113. rlmagent/model/registered_oauth.py +195 -0
  114. rlmagent/model/scripted.py +55 -0
  115. rlmagent/model/settings.py +271 -0
  116. rlmagent/model/subscription.py +398 -0
  117. rlmagent/model/token_bounds.py +15 -0
  118. rlmagent/model/transport/__init__.py +1 -0
  119. rlmagent/model/transport/backoff.py +87 -0
  120. rlmagent/model/transport/client.py +87 -0
  121. rlmagent/model/transport/faults.py +65 -0
  122. rlmagent/model/transport/responses_socket.py +223 -0
  123. rlmagent/model/vertex.py +93 -0
  124. rlmagent/network.py +123 -0
  125. rlmagent/packages.py +246 -0
  126. rlmagent/planning.py +84 -0
  127. rlmagent/playbook/__init__.py +1 -0
  128. rlmagent/playbook/apply.py +106 -0
  129. rlmagent/playbook/digest.py +63 -0
  130. rlmagent/playbook/improver.py +272 -0
  131. rlmagent/playbook/plan.py +99 -0
  132. rlmagent/playbook/store.py +67 -0
  133. rlmagent/plugins.py +327 -0
  134. rlmagent/policy.py +105 -0
  135. rlmagent/prompt_refs.py +57 -0
  136. rlmagent/prompts.py +331 -0
  137. rlmagent/prune.py +60 -0
  138. rlmagent/py.typed +0 -0
  139. rlmagent/reasoning.py +135 -0
  140. rlmagent/refinement/__init__.py +1 -0
  141. rlmagent/refinement/controller.py +448 -0
  142. rlmagent/refinement/engine.py +220 -0
  143. rlmagent/refinement/evidence.py +131 -0
  144. rlmagent/refinement/scoped.py +90 -0
  145. rlmagent/refinement/store.py +193 -0
  146. rlmagent/refresh.py +202 -0
  147. rlmagent/routing.py +52 -0
  148. rlmagent/runtime.py +575 -0
  149. rlmagent/safety.py +267 -0
  150. rlmagent/session_html.py +57 -0
  151. rlmagent/session_import.py +52 -0
  152. rlmagent/session_share.py +55 -0
  153. rlmagent/sessions.py +249 -0
  154. rlmagent/shell_sandbox.py +88 -0
  155. rlmagent/skillset.py +320 -0
  156. rlmagent/steering.py +148 -0
  157. rlmagent/stops.py +62 -0
  158. rlmagent/subscriptions.py +219 -0
  159. rlmagent/tasks.py +98 -0
  160. rlmagent/toolhost.py +310 -0
  161. rlmagent/tools/__init__.py +56 -0
  162. rlmagent/tools/_shared.py +210 -0
  163. rlmagent/tools/files.py +462 -0
  164. rlmagent/tools/kernel.py +93 -0
  165. rlmagent/tools/patch_tool.py +68 -0
  166. rlmagent/tools/search.py +188 -0
  167. rlmagent/trust.py +383 -0
  168. rlmagent/tui/__init__.py +1 -0
  169. rlmagent/tui/adapter.py +506 -0
  170. rlmagent/tui/app.py +711 -0
  171. rlmagent/tui/approval_screen.py +57 -0
  172. rlmagent/tui/choice_screen.py +47 -0
  173. rlmagent/tui/completions.py +116 -0
  174. rlmagent/tui/components.py +25 -0
  175. rlmagent/tui/composer.py +40 -0
  176. rlmagent/tui/factory_view.py +86 -0
  177. rlmagent/tui/inspect.py +193 -0
  178. rlmagent/tui/messages.py +215 -0
  179. rlmagent/tui/model_screen.py +307 -0
  180. rlmagent/tui/prompt.py +98 -0
  181. rlmagent/tui/question_screen.py +52 -0
  182. rlmagent/tui/replay.py +141 -0
  183. rlmagent/tui/rlm_agent.tcss +405 -0
  184. rlmagent/tui/search_box.py +91 -0
  185. rlmagent/tui/sidebar.py +182 -0
  186. rlmagent/tui/status.py +49 -0
  187. rlmagent/tui/steps.py +283 -0
  188. rlmagent/tui/suggest.py +778 -0
  189. rlmagent/tui/transcript.py +172 -0
  190. rlmagent/tui/trust_dialog.py +58 -0
  191. rlmagent/tui/worker_attach.py +141 -0
  192. rlmagent/ui/__init__.py +1 -0
  193. rlmagent/ui/render.py +211 -0
  194. rlmagent/undo.py +109 -0
  195. rlmagent/utils/__init__.py +1 -0
  196. rlmagent/websearch.py +75 -0
  197. rlmagent/worker.py +1275 -0
  198. rlmagent/worker_service.py +203 -0
  199. rlmness_cli-0.2.0.dist-info/METADATA +882 -0
  200. rlmness_cli-0.2.0.dist-info/RECORD +203 -0
  201. rlmness_cli-0.2.0.dist-info/WHEEL +4 -0
  202. rlmness_cli-0.2.0.dist-info/entry_points.txt +2 -0
  203. 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."""