agentconnect-cli 0.1.0a1__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.
- agentconnect/__init__.py +3 -0
- agentconnect/a2a_adapter/__init__.py +6 -0
- agentconnect/a2a_adapter/adapter.py +565 -0
- agentconnect/a2a_adapter/cards.py +149 -0
- agentconnect/a2a_adapter/convert.py +157 -0
- agentconnect/a2a_adapter/facade.py +560 -0
- agentconnect/a2a_adapter/seen.py +49 -0
- agentconnect/a2a_adapter/serve.py +67 -0
- agentconnect/adapter.py +319 -0
- agentconnect/adapters.py +52 -0
- agentconnect/apiclient.py +51 -0
- agentconnect/backoff.py +42 -0
- agentconnect/callback.py +314 -0
- agentconnect/cli.py +2324 -0
- agentconnect/cli_json.py +470 -0
- agentconnect/cli_support.py +444 -0
- agentconnect/clock.py +161 -0
- agentconnect/config.py +1213 -0
- agentconnect/daemon/__init__.py +41 -0
- agentconnect/deliveries.py +295 -0
- agentconnect/directory_client.py +255 -0
- agentconnect/failpoints.py +99 -0
- agentconnect/identity.py +111 -0
- agentconnect/isolation.py +345 -0
- agentconnect/keystore.py +279 -0
- agentconnect/limits.py +457 -0
- agentconnect/log.py +143 -0
- agentconnect/mcp/__init__.py +30 -0
- agentconnect/mcp/backend.py +405 -0
- agentconnect/mcp/server.py +382 -0
- agentconnect/mcp/session.py +373 -0
- agentconnect/mcp/tools.py +583 -0
- agentconnect/ownerfile.py +238 -0
- agentconnect/payloads.py +109 -0
- agentconnect/policy.py +1066 -0
- agentconnect/provision.py +239 -0
- agentconnect/py.typed +0 -0
- agentconnect/relay.py +405 -0
- agentconnect/runtime/__init__.py +46 -0
- agentconnect/runtime/agent.py +225 -0
- agentconnect/runtime/api.py +2043 -0
- agentconnect/runtime/engine.py +5359 -0
- agentconnect/runtime/errors.py +47 -0
- agentconnect/runtime/floor.py +649 -0
- agentconnect/runtime/groups.py +1351 -0
- agentconnect/runtime/knocks.py +1422 -0
- agentconnect/runtime/ledger.py +94 -0
- agentconnect/runtime/listing.py +1307 -0
- agentconnect/runtime/registry.py +53 -0
- agentconnect/runtime/runtime.py +158 -0
- agentconnect/runtime/sas.py +1002 -0
- agentconnect/sas_emoji.py +83 -0
- agentconnect/schemas.py +381 -0
- agentconnect/shapes.py +56 -0
- agentconnect/sockets.py +86 -0
- agentconnect/sse.py +234 -0
- agentconnect/store.py +3282 -0
- agentconnect/tasks.py +35 -0
- agentconnect/tokens.py +63 -0
- agentconnect/webhook.py +199 -0
- agentconnect_cli-0.1.0a1.dist-info/METADATA +103 -0
- agentconnect_cli-0.1.0a1.dist-info/RECORD +64 -0
- agentconnect_cli-0.1.0a1.dist-info/WHEEL +4 -0
- agentconnect_cli-0.1.0a1.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
"""``agentconnect mcp``: the stdio MCP server an agent tool starts.
|
|
2
|
+
|
|
3
|
+
It hosts the runtime in this process or attaches to the one that holds the
|
|
4
|
+
data directory (:mod:`agentconnect.mcp.session`), answers ``tools/list``
|
|
5
|
+
and ``tools/call`` with :mod:`agentconnect.mcp.tools`, and stops on stdin
|
|
6
|
+
EOF (the agent tool ended the session) or SIGTERM/SIGINT. Stopping ends the
|
|
7
|
+
tool calls in flight, gives queued sends the runtime's bounded flush and
|
|
8
|
+
releases the lock (:meth:`Runtime.stop`).
|
|
9
|
+
|
|
10
|
+
stdout carries MCP only: the CLI moves the process's stdout to stderr before
|
|
11
|
+
anything runs (:func:`claim_stdio`), so stray output can't corrupt the
|
|
12
|
+
channel. Logs go to stderr (or a file), never a message body.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import contextlib
|
|
19
|
+
import io
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import signal
|
|
24
|
+
import tomllib
|
|
25
|
+
from collections.abc import AsyncIterator, Mapping
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, cast
|
|
29
|
+
|
|
30
|
+
import anyio
|
|
31
|
+
from mcp import types
|
|
32
|
+
from mcp.server import Server, ServerRequestContext
|
|
33
|
+
from mcp.server.stdio import stdio_server
|
|
34
|
+
|
|
35
|
+
from agentconnect import __version__, log
|
|
36
|
+
from agentconnect.mcp.backend import ToolError
|
|
37
|
+
from agentconnect.mcp.session import Session, current_request
|
|
38
|
+
from agentconnect.mcp.tools import BY_NAME, HUMAN_ONLY_NOTE, INSTRUCTIONS, TOOLS, Tool, call
|
|
39
|
+
from agentconnect.runtime import (
|
|
40
|
+
Clock,
|
|
41
|
+
Config,
|
|
42
|
+
ConfigError,
|
|
43
|
+
IsolationError,
|
|
44
|
+
Keystore,
|
|
45
|
+
Options,
|
|
46
|
+
SystemClock,
|
|
47
|
+
load_config,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
LOG = log.get("agentconnect.mcp")
|
|
51
|
+
|
|
52
|
+
SERVER_NAME = "agentconnect"
|
|
53
|
+
DEFAULT_AGENT = "mcp"
|
|
54
|
+
MAX_LINE = 4 << 20 # bytes of one JSON-RPC message from the client
|
|
55
|
+
SIGNALS = (signal.SIGINT, signal.SIGTERM)
|
|
56
|
+
_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,63}") # fullmatch: no newline
|
|
57
|
+
UNENCODABLE = "the result holds text that can't be written as UTF-8"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def default_data_dir(agent: str, environ: Mapping[str, str]) -> Path:
|
|
61
|
+
"""``$XDG_DATA_HOME/agentconnect-<agent>`` (``~/.local/share`` without it)."""
|
|
62
|
+
raw = environ.get("XDG_DATA_HOME", "").strip()
|
|
63
|
+
base = Path(raw) if raw else Path(environ.get("HOME", str(Path.home()))) / ".local/share"
|
|
64
|
+
return base / f"agentconnect-{agent}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _file_data_dir(config: Path | None) -> Path | None:
|
|
68
|
+
"""``daemon.data_dir`` of a config file, if it sets one."""
|
|
69
|
+
if config is None:
|
|
70
|
+
return None
|
|
71
|
+
try:
|
|
72
|
+
data = tomllib.loads(config.expanduser().read_text(encoding="utf-8"))
|
|
73
|
+
except (OSError, tomllib.TOMLDecodeError) as e:
|
|
74
|
+
msg = f"{config}: {e}"
|
|
75
|
+
raise ConfigError(msg) from None
|
|
76
|
+
daemon = data.get("daemon")
|
|
77
|
+
raw = daemon.get("data_dir") if isinstance(daemon, dict) else None
|
|
78
|
+
return Path(raw).expanduser() if isinstance(raw, str) and raw else None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def configure( # noqa: PLR0913
|
|
82
|
+
agent: str | None,
|
|
83
|
+
*,
|
|
84
|
+
data_dir: Path | None = None,
|
|
85
|
+
relay: str | None = None,
|
|
86
|
+
config: Path | None = None,
|
|
87
|
+
display_name: str | None = None,
|
|
88
|
+
environ: Mapping[str, str] = os.environ,
|
|
89
|
+
) -> Config:
|
|
90
|
+
"""The MCP host's :class:`Config` (ADR-005, Decision 2).
|
|
91
|
+
|
|
92
|
+
The data directory is ``data_dir``, else ``AC_DATA_DIR``, else the
|
|
93
|
+
``config`` file's ``daemon.data_dir`` when no ``agent`` is given (to
|
|
94
|
+
attach to the daemon that file configures), else
|
|
95
|
+
``$XDG_DATA_HOME/agentconnect-<agent>`` (``agent`` defaults to ``mcp``).
|
|
96
|
+
An ``AC_DATA_DIR`` that disagrees with an explicit ``agent`` is refused,
|
|
97
|
+
so a variable exported for one agent's CLI can't hand this one another
|
|
98
|
+
agent's identity. Only the TOML file ``config`` is read, never the
|
|
99
|
+
separate daemon's default file; ``AC_*`` variables apply on top."""
|
|
100
|
+
name = DEFAULT_AGENT if agent is None else agent
|
|
101
|
+
if not _NAME.fullmatch(name):
|
|
102
|
+
msg = "--agent: 1 to 64 of A-Z a-z 0-9 . _ -, starting with a letter or digit"
|
|
103
|
+
raise ConfigError(msg)
|
|
104
|
+
env_dir = environ.get("AC_DATA_DIR", "")
|
|
105
|
+
if data_dir is not None:
|
|
106
|
+
where = data_dir.expanduser()
|
|
107
|
+
elif env_dir:
|
|
108
|
+
where = Path(env_dir).expanduser()
|
|
109
|
+
if agent is not None and where != default_data_dir(agent, environ):
|
|
110
|
+
msg = (
|
|
111
|
+
f"AC_DATA_DIR is {env_dir}, not the data directory of --agent {agent} "
|
|
112
|
+
f"({default_data_dir(agent, environ)}); unset it, or pass --data-dir"
|
|
113
|
+
)
|
|
114
|
+
raise ConfigError(msg)
|
|
115
|
+
elif agent is None and (file_dir := _file_data_dir(config)) is not None:
|
|
116
|
+
where = file_dir
|
|
117
|
+
else:
|
|
118
|
+
where = default_data_dir(name, environ)
|
|
119
|
+
# Its one adapter, whatever the file lists for the separate daemon.
|
|
120
|
+
overrides: dict[str, Any] = {"data_dir": where, "default_adapter": "mcp", "adapters": ()}
|
|
121
|
+
if relay is not None:
|
|
122
|
+
overrides |= {"relay_url": relay, "relay_host": ""}
|
|
123
|
+
if display_name is not None or (agent is not None and config is None):
|
|
124
|
+
overrides["display_name"] = display_name or agent
|
|
125
|
+
cfg = load_config(config, environ, use_file=config is not None, **overrides)
|
|
126
|
+
if cfg.isolation:
|
|
127
|
+
msg = (
|
|
128
|
+
"isolation.enabled is for a separate daemon only: an MCP server runs the "
|
|
129
|
+
"runtime as the agent tool's own user (docs/isolation.md)"
|
|
130
|
+
)
|
|
131
|
+
raise IsolationError(msg)
|
|
132
|
+
return cfg
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _annotations(tool: Tool) -> types.ToolAnnotations:
|
|
136
|
+
return types.ToolAnnotations(
|
|
137
|
+
read_only_hint=tool.read_only,
|
|
138
|
+
destructive_hint=tool.destructive,
|
|
139
|
+
**tool.hints,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def mcp_tools(session: Session) -> list[types.Tool]:
|
|
144
|
+
"""The tools this session offers. A host session whose config keeps
|
|
145
|
+
knocks to the human (``knock.agent_may_knock = false``) doesn't list
|
|
146
|
+
``agentconnect_knock``; the runtime refuses it either way."""
|
|
147
|
+
hidden = set()
|
|
148
|
+
host = session.backend is not None and session.mode == "host"
|
|
149
|
+
if host and not session.config.knock_agent_may_knock:
|
|
150
|
+
hidden.add("agentconnect_knock")
|
|
151
|
+
return [
|
|
152
|
+
types.Tool(
|
|
153
|
+
name=t.name,
|
|
154
|
+
description=t.description,
|
|
155
|
+
input_schema=t.schema,
|
|
156
|
+
annotations=_annotations(t),
|
|
157
|
+
)
|
|
158
|
+
for t in TOOLS
|
|
159
|
+
if t.name not in hidden
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _result(data: dict[str, Any], *, error: bool = False) -> types.CallToolResult:
|
|
164
|
+
text = json.dumps(data, sort_keys=True, ensure_ascii=False)
|
|
165
|
+
try:
|
|
166
|
+
text.encode("utf-8")
|
|
167
|
+
except UnicodeEncodeError:
|
|
168
|
+
# A lone surrogate would end the stdio writer, and the server with
|
|
169
|
+
# it: this call fails, the server goes on. (A wait's messages are
|
|
170
|
+
# checked one by one, agentconnect.mcp.tools.writable_view.)
|
|
171
|
+
LOG.warning("mcp_result_unencodable")
|
|
172
|
+
data, error = {"error": {"code": "unencodable", "message": UNENCODABLE}}, True
|
|
173
|
+
text = json.dumps(data, sort_keys=True)
|
|
174
|
+
return types.CallToolResult(
|
|
175
|
+
content=[types.TextContent(type="text", text=text)],
|
|
176
|
+
structured_content=data,
|
|
177
|
+
is_error=error,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
async def call_tool(
|
|
182
|
+
session: Session, name: str, args: Mapping[str, Any], request_id: object | None = None
|
|
183
|
+
) -> types.CallToolResult:
|
|
184
|
+
"""One ``tools/call``: the tool's result, or an error result with the
|
|
185
|
+
runtime's code (``human_only`` with a pointer to the CLI). A refused
|
|
186
|
+
call is logged with its code only. ``request_id`` ties a wait's batch
|
|
187
|
+
to this request, to be acknowledged once its result is written."""
|
|
188
|
+
token = current_request.set(request_id)
|
|
189
|
+
finished = False
|
|
190
|
+
try:
|
|
191
|
+
result = _result(await call(session, name, args))
|
|
192
|
+
finished = True
|
|
193
|
+
return result
|
|
194
|
+
except ToolError as e:
|
|
195
|
+
LOG.info("mcp_tool_refused", tool=name if name in BY_NAME else "unknown", code=e.code)
|
|
196
|
+
error: dict[str, Any] = {"code": e.code, "message": str(e)}
|
|
197
|
+
if e.code == "human_only":
|
|
198
|
+
error["hint"] = HUMAN_ONLY_NOTE
|
|
199
|
+
finished = True
|
|
200
|
+
return _result({"error": error}, error=True)
|
|
201
|
+
finally:
|
|
202
|
+
current_request.reset(token)
|
|
203
|
+
if not finished and request_id is not None:
|
|
204
|
+
# Cancelled (or failed) after a take: no result will be written,
|
|
205
|
+
# so the batch goes back at once.
|
|
206
|
+
await session.abandon(request_id)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def build_server(session: Session) -> Server[Any]:
|
|
210
|
+
async def list_tools(
|
|
211
|
+
_ctx: ServerRequestContext[Any], _params: types.PaginatedRequestParams | None
|
|
212
|
+
) -> types.ListToolsResult:
|
|
213
|
+
return types.ListToolsResult(tools=mcp_tools(session))
|
|
214
|
+
|
|
215
|
+
async def on_call(
|
|
216
|
+
ctx: ServerRequestContext[Any], params: types.CallToolRequestParams
|
|
217
|
+
) -> types.CallToolResult:
|
|
218
|
+
return await call_tool(session, params.name, params.arguments or {}, ctx.request_id)
|
|
219
|
+
|
|
220
|
+
return Server(
|
|
221
|
+
SERVER_NAME,
|
|
222
|
+
version=__version__,
|
|
223
|
+
instructions=INSTRUCTIONS,
|
|
224
|
+
on_list_tools=list_tools,
|
|
225
|
+
on_call_tool=on_call,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _msg_ids(message: dict[str, Any]) -> list[str]:
|
|
230
|
+
"""The ``msg_id`` values a written tool result returned to the client."""
|
|
231
|
+
result = message.get("result")
|
|
232
|
+
content = result.get("structuredContent") if isinstance(result, dict) else None
|
|
233
|
+
messages = content.get("messages") if isinstance(content, dict) else None
|
|
234
|
+
if not isinstance(messages, list):
|
|
235
|
+
return []
|
|
236
|
+
return [str(m.get("msg_id")) for m in messages if isinstance(m, dict)]
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class Written:
|
|
240
|
+
"""The server's stdout: once a response line is written and flushed,
|
|
241
|
+
the session hears of it, and acknowledges the batch of a wait whose
|
|
242
|
+
result that was (:meth:`Session.result_written`)."""
|
|
243
|
+
|
|
244
|
+
def __init__(self, out: anyio.AsyncFile[str], session: Session) -> None:
|
|
245
|
+
self._out, self._session = out, session
|
|
246
|
+
self._lines: list[str] = []
|
|
247
|
+
|
|
248
|
+
async def write(self, text: str) -> None:
|
|
249
|
+
await self._out.write(text)
|
|
250
|
+
if self._session.awaiting_write():
|
|
251
|
+
self._lines.append(text)
|
|
252
|
+
|
|
253
|
+
async def flush(self) -> None:
|
|
254
|
+
await self._out.flush()
|
|
255
|
+
lines, self._lines = self._lines, []
|
|
256
|
+
for line in lines:
|
|
257
|
+
with contextlib.suppress(ValueError):
|
|
258
|
+
message = json.loads(line)
|
|
259
|
+
if isinstance(message, dict) and "result" in message:
|
|
260
|
+
self._session.result_written_soon(message.get("id"), _msg_ids(message))
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class LineReader:
|
|
264
|
+
"""The client's messages, one per line, from a pipe or terminal; closable
|
|
265
|
+
from another task (a signal), which ends the iteration as EOF would."""
|
|
266
|
+
|
|
267
|
+
def __init__(self, fd: int) -> None:
|
|
268
|
+
self._fd = fd
|
|
269
|
+
self._reader = asyncio.StreamReader(limit=MAX_LINE)
|
|
270
|
+
self._transport: asyncio.ReadTransport | None = None
|
|
271
|
+
|
|
272
|
+
async def open(self) -> None:
|
|
273
|
+
loop = asyncio.get_running_loop()
|
|
274
|
+
pipe = os.fdopen(self._fd, "rb", buffering=0, closefd=False)
|
|
275
|
+
protocol = asyncio.StreamReaderProtocol(self._reader)
|
|
276
|
+
self._transport, _ = await loop.connect_read_pipe(lambda: protocol, pipe)
|
|
277
|
+
|
|
278
|
+
def close(self) -> None:
|
|
279
|
+
"""End the input now: the server sees EOF and stops."""
|
|
280
|
+
if self._transport is not None:
|
|
281
|
+
self._transport.close()
|
|
282
|
+
self._reader.feed_eof()
|
|
283
|
+
|
|
284
|
+
def __aiter__(self) -> AsyncIterator[str]:
|
|
285
|
+
return self._lines()
|
|
286
|
+
|
|
287
|
+
async def _lines(self) -> AsyncIterator[str]:
|
|
288
|
+
while True:
|
|
289
|
+
try:
|
|
290
|
+
line = await self._reader.readline()
|
|
291
|
+
except (ValueError, asyncio.LimitOverrunError):
|
|
292
|
+
LOG.warning("mcp_line_too_long")
|
|
293
|
+
return
|
|
294
|
+
if not line:
|
|
295
|
+
return
|
|
296
|
+
if line.strip():
|
|
297
|
+
yield line.decode("utf-8", "replace")
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@dataclass
|
|
301
|
+
class StdioFds:
|
|
302
|
+
"""The MCP channel's file descriptors, after :func:`claim_stdio`."""
|
|
303
|
+
|
|
304
|
+
stdin: int
|
|
305
|
+
stdout: int
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def claim_stdio() -> StdioFds:
|
|
309
|
+
"""Keep private copies of fd 0 and 1 for MCP, then point fd 0 at the
|
|
310
|
+
null device and fd 1 at stderr, so nothing else in the process (or a
|
|
311
|
+
child) reads the client's input or writes into the channel."""
|
|
312
|
+
fds = StdioFds(os.dup(0), os.dup(1))
|
|
313
|
+
devnull = os.open(os.devnull, os.O_RDONLY)
|
|
314
|
+
try:
|
|
315
|
+
os.dup2(devnull, 0)
|
|
316
|
+
finally:
|
|
317
|
+
os.close(devnull)
|
|
318
|
+
os.dup2(2, 1)
|
|
319
|
+
return fds
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class McpServer:
|
|
323
|
+
"""One MCP session over two file descriptors (the CLI passes its
|
|
324
|
+
claimed stdin and stdout; tests pass pipes)."""
|
|
325
|
+
|
|
326
|
+
def __init__( # noqa: PLR0913
|
|
327
|
+
self,
|
|
328
|
+
config: Config,
|
|
329
|
+
*,
|
|
330
|
+
clock: Clock | None = None,
|
|
331
|
+
keystore: Keystore | None = None,
|
|
332
|
+
options: Options | None = None,
|
|
333
|
+
stdin: int = 0,
|
|
334
|
+
stdout: int = 1,
|
|
335
|
+
) -> None:
|
|
336
|
+
self.session = Session(config, clock or SystemClock(), keystore=keystore, options=options)
|
|
337
|
+
self._stdin, self._stdout = stdin, stdout
|
|
338
|
+
self._reader = LineReader(stdin)
|
|
339
|
+
|
|
340
|
+
def stop(self) -> None:
|
|
341
|
+
"""Stop as on stdin EOF (signal handlers call this)."""
|
|
342
|
+
self._reader.close()
|
|
343
|
+
|
|
344
|
+
async def run(self, ready: asyncio.Event | None = None) -> None:
|
|
345
|
+
"""Open the session (host or attach), serve MCP until stdin EOF or
|
|
346
|
+
:meth:`stop`, then close the session: tool calls in flight are
|
|
347
|
+
cancelled first, then the runtime stops and releases the lock.
|
|
348
|
+
Cancellation-safe: a cancelled run still closes the session."""
|
|
349
|
+
await self.session.open()
|
|
350
|
+
try:
|
|
351
|
+
await self._reader.open()
|
|
352
|
+
out = io.TextIOWrapper(
|
|
353
|
+
os.fdopen(self._stdout, "wb", closefd=False), encoding="utf-8", newline="\n"
|
|
354
|
+
)
|
|
355
|
+
stdout = cast("anyio.AsyncFile[str]", Written(anyio.wrap_file(out), self.session))
|
|
356
|
+
server = build_server(self.session)
|
|
357
|
+
# The SDK reads the client's lines by iterating `stdin` only.
|
|
358
|
+
stdin = cast("anyio.AsyncFile[str]", self._reader)
|
|
359
|
+
async with stdio_server(stdin, stdout) as (read, write):
|
|
360
|
+
if ready is not None:
|
|
361
|
+
ready.set()
|
|
362
|
+
await server.run(read, write, server.create_initialization_options())
|
|
363
|
+
with contextlib.suppress(ValueError):
|
|
364
|
+
out.detach() # the descriptor is the caller's
|
|
365
|
+
finally:
|
|
366
|
+
self._reader.close()
|
|
367
|
+
await self.session.close()
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
async def serve(config: Config, fds: StdioFds) -> None:
|
|
371
|
+
"""The CLI's entry point: run until stdin EOF or a signal."""
|
|
372
|
+
server = McpServer(config, stdin=fds.stdin, stdout=fds.stdout)
|
|
373
|
+
loop = asyncio.get_running_loop()
|
|
374
|
+
for sig in SIGNALS:
|
|
375
|
+
with contextlib.suppress(NotImplementedError):
|
|
376
|
+
loop.add_signal_handler(sig, server.stop)
|
|
377
|
+
try:
|
|
378
|
+
await server.run()
|
|
379
|
+
finally:
|
|
380
|
+
for sig in SIGNALS:
|
|
381
|
+
with contextlib.suppress(NotImplementedError):
|
|
382
|
+
loop.remove_signal_handler(sig)
|