mcp-console 0.0.5__py3-none-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,15 @@
1
+ """Composable Python integrations for the MCP Console stdio server."""
2
+
3
+ from . import anthropic, chatlas, codex, openai
4
+ from ._client import AsyncMCPConsole, Requirements
5
+ from ._sync import MCPConsole
6
+
7
+ __all__ = [
8
+ "AsyncMCPConsole",
9
+ "MCPConsole",
10
+ "Requirements",
11
+ "anthropic",
12
+ "chatlas",
13
+ "codex",
14
+ "openai",
15
+ ]
mcp_console/_client.py ADDED
@@ -0,0 +1,252 @@
1
+ from collections.abc import Mapping, Sequence
2
+ from contextlib import AsyncExitStack
3
+ from typing import TYPE_CHECKING, Any, Literal, Self, TypedDict
4
+
5
+ from ._common import Command, result_text, stdio_command
6
+
7
+ if TYPE_CHECKING:
8
+ from mcp import Client
9
+ from mcp.types import CallToolResult, Tool
10
+
11
+
12
+ class Requirements(TypedDict, total=False):
13
+ """Dependency declarations accepted by the clients' `send(requirements=...)`.
14
+
15
+ `add` merges packages into the session declaration. `get` inspects it, `set`
16
+ replaces it (emptying omitted fields), and `reset` restores startup defaults.
17
+ Changed replacements of a live worker require `control="restart"`, which
18
+ also discards its variables. Python packages run in Console's worker
19
+ environment; installing a framework extra prepares the calling application.
20
+
21
+ Attributes:
22
+ action: `get`, `add` (the default), `set`, or `reset`.
23
+ python_version: Python version constraints, such as `[">=3.11"]`.
24
+ exclude_newer: Optional cutoff date for dependency resolution.
25
+ r: R package requirements.
26
+ python: Python package requirements.
27
+ duckdb: DuckDB extensions.
28
+
29
+ Examples:
30
+ Install with `pip install 'mcp-console[client]'`. Add NumPy to Console's
31
+ managed Python environment and use it in the same call:
32
+
33
+ ```python
34
+ from mcp_console import MCPConsole, Requirements
35
+
36
+ requirements: Requirements = {"python": ["numpy"]}
37
+ with MCPConsole() as console:
38
+ print(
39
+ console.send(
40
+ requirements=requirements,
41
+ python="import numpy as np; np.mean([12, 15, 18, 20, 25])",
42
+ )
43
+ )
44
+ ```
45
+ """
46
+
47
+ action: Literal["get", "add", "set", "reset"]
48
+ python_version: list[str]
49
+ exclude_newer: str | None
50
+ r: list[str]
51
+ python: list[str]
52
+ duckdb: list[str]
53
+
54
+
55
+ class AsyncMCPConsole:
56
+ """A persistent, callable connection to ``mcp-console serve``.
57
+
58
+ Enter and close the connection in the same async task. The application owns
59
+ its model clients, agents, and tool loops. The context starts the server and
60
+ discovers its tools; leaving the context closes its connection and process.
61
+ Variables persist across `send()` calls on this connection. A new connection
62
+ starts a new session.
63
+
64
+ Args:
65
+ command: Console executable. Defaults to the installed `mcp-console`.
66
+ args: Server arguments. Defaults to `["serve"]`.
67
+ server_parameters: MCP stdio settings such as `cwd` and `env`.
68
+
69
+ Examples:
70
+ Install with `pip install 'mcp-console[client]'`. This script defines
71
+ values in one cell and uses them in a second cell:
72
+
73
+ ```python
74
+ import asyncio
75
+ from mcp_console import AsyncMCPConsole
76
+
77
+
78
+ async def main():
79
+ async with AsyncMCPConsole() as console:
80
+ print(
81
+ await console.send(
82
+ python="values = [12, 15, 18, 20, 25]; values"
83
+ )
84
+ )
85
+ print(
86
+ await console.send(
87
+ python="import statistics; statistics.mean(values)"
88
+ )
89
+ )
90
+
91
+
92
+ asyncio.run(main())
93
+ ```
94
+
95
+ Framework adapters are created inside this context, after discovery.
96
+ See [Anthropic](anthropic.tool.html), [chatlas](chatlas.tool.html),
97
+ [Responses](openai.responses_tool.html), or [Agents](openai.agents_tool.html).
98
+ """
99
+
100
+ def __init__(
101
+ self,
102
+ *,
103
+ command: Command | None = None,
104
+ args: Sequence[Command] | None = None,
105
+ server_parameters: Mapping[str, Any] | None = None,
106
+ ) -> None:
107
+ self._command = command
108
+ self._args = args
109
+ self._server_parameters = dict(server_parameters or {})
110
+ self._stack: AsyncExitStack | None = None
111
+ self._client: Client | None = None
112
+ self._send_tool: Tool | None = None
113
+
114
+ async def connect(self) -> "Self":
115
+ """Start MCP Console and initialize its MCP client session."""
116
+ if self._client is not None:
117
+ return self
118
+
119
+ from mcp import Client, StdioServerParameters
120
+
121
+ command, args = stdio_command(self._command, self._args)
122
+ parameters = self._server_parameters | {"command": command, "args": args}
123
+ async with AsyncExitStack() as stack:
124
+ # Automatic discovery can time out while remote startup is pending
125
+ # and switch lifecycles on the same connection. Initialize once.
126
+ client = await stack.enter_async_context(
127
+ Client(StdioServerParameters(**parameters), mode="legacy")
128
+ )
129
+ tools = await client.list_tools()
130
+ send_tool = next(
131
+ (tool for tool in tools.tools if tool.name == "send"), None
132
+ )
133
+ if send_tool is None:
134
+ raise RuntimeError("MCP Console did not expose its send tool")
135
+ self._stack = stack.pop_all()
136
+ self._client = client
137
+ self._send_tool = send_tool
138
+ return self
139
+
140
+ async def close(self) -> None:
141
+ """Close the MCP client session and terminate its subprocess."""
142
+ stack = self._stack
143
+ self._stack = self._client = self._send_tool = None
144
+ if stack is not None:
145
+ await stack.aclose()
146
+
147
+ async def __aenter__(self) -> "Self":
148
+ return await self.connect()
149
+
150
+ async def __aexit__(self, exc_type: object, exc: object, traceback: object) -> None:
151
+ await self.close()
152
+
153
+ async def _call_send(self, arguments: Mapping[str, Any]) -> "CallToolResult":
154
+ if self._client is None:
155
+ raise RuntimeError(
156
+ "AsyncMCPConsole is not connected; use `async with AsyncMCPConsole() as console` "
157
+ "or call `await console.connect()` first"
158
+ )
159
+ return await self._client.call_tool("send", dict(arguments))
160
+
161
+ async def send(
162
+ self,
163
+ *,
164
+ r: str | None = None,
165
+ python: str | None = None,
166
+ sql: str | None = None,
167
+ control: Literal["interrupt", "restart"] | None = None,
168
+ requirements: Requirements | None = None,
169
+ stdin: str | None = None,
170
+ timeout_ms: int | None = None,
171
+ ) -> str:
172
+ """Run or control the persistent R, Python, and SQL console.
173
+
174
+ Send at most one of r, python, or sql per call.
175
+ If output ends in ``[running; poll with an empty send]``, call again
176
+ without code or stdin until completion before submitting another cell.
177
+
178
+ Args:
179
+ r: One complete R cell.
180
+ python: One complete Python cell.
181
+ sql: One complete SQL cell on the selected connection.
182
+ control: Interrupt the active operation or restart the runtime.
183
+ requirements: Declaration keyed by r, python, or duckdb, with optional
184
+ python_version and exclude_newer constraints. action defaults to
185
+ add; get returns complete inspection JSON; set replaces omitted
186
+ fields with empty values; reset restores startup defaults.
187
+ Changed replacements of a live worker require control="restart".
188
+ stdin: Exact input for an active prompt or debugger.
189
+ timeout_ms: Maximum wait after dispatch; timeout does not cancel work.
190
+ Omission or None uses the server default of 60,000 ms.
191
+
192
+ Returns:
193
+ Collected text output and lifecycle notices. Images appear as MIME
194
+ placeholders. MCP tool errors raise `RuntimeError`.
195
+
196
+ Examples:
197
+ A short observation window may return before the cell finishes.
198
+ Poll with an empty call, collecting each chunk without rerunning
199
+ the cell:
200
+
201
+ ```python
202
+ import asyncio
203
+ from mcp_console import AsyncMCPConsole
204
+
205
+
206
+ async def main():
207
+ async with AsyncMCPConsole() as console:
208
+ output = await console.send(
209
+ python="sum(range(1_000_000))",
210
+ timeout_ms=0,
211
+ )
212
+ print(output, end="")
213
+ while output.endswith("[running; poll with an empty send]"):
214
+ output = await console.send()
215
+ print(output, end="")
216
+
217
+
218
+ asyncio.run(main())
219
+ ```
220
+ """
221
+ arguments = {
222
+ "r": r,
223
+ "python": python,
224
+ "sql": sql,
225
+ "control": control,
226
+ "requirements": requirements,
227
+ "stdin": stdin,
228
+ "timeout_ms": timeout_ms,
229
+ }
230
+ return result_text(
231
+ await self._call_send(
232
+ {name: value for name, value in arguments.items() if value is not None}
233
+ )
234
+ )
235
+
236
+ # Ordinary Python shorthand; SDK registration uses the product adapters.
237
+ __call__ = send
238
+
239
+ @property
240
+ def send_tool(self) -> "Tool":
241
+ """The connected server's MCP tool definition, used by SDK adapters.
242
+
243
+ Available after connecting. Its input schema reflects the server's
244
+ configured capabilities. Use a framework adapter to register this
245
+ schema; automatic inference from `send()` annotations loses that detail.
246
+ Recreate adapters after reconnecting.
247
+ """
248
+ if self._send_tool is None:
249
+ raise RuntimeError(
250
+ "Connect the console before creating tools; enter its context or call connect()"
251
+ )
252
+ return self._send_tool
mcp_console/_common.py ADDED
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import shutil
6
+ import sysconfig
7
+ from collections.abc import Sequence
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING:
12
+ from mcp.types import CallToolResult, ContentBlock
13
+
14
+ Command = str | os.PathLike[str]
15
+
16
+
17
+ def stdio_command(
18
+ command: Command | None, args: Sequence[Command] | None
19
+ ) -> tuple[str, list[str]]:
20
+ if command is None:
21
+ candidate = Path(sysconfig.get_path("scripts")) / "mcp-console"
22
+ command = (
23
+ candidate if os.access(candidate, os.X_OK) else shutil.which("mcp-console")
24
+ )
25
+ if command is None:
26
+ raise FileNotFoundError(
27
+ "Install mcp-console in this Python environment or pass command= explicitly"
28
+ )
29
+ return os.fspath(command), ["serve"] if args is None else [
30
+ os.fspath(arg) for arg in args
31
+ ]
32
+
33
+
34
+ def content_text(content: ContentBlock) -> str:
35
+ if content.type == "text":
36
+ return content.text
37
+ if content.type == "image":
38
+ return f"[{content.mime_type} output]"
39
+ return content.model_dump_json(by_alias=True, exclude_none=True)
40
+
41
+
42
+ def result_text(result: CallToolResult) -> str:
43
+ text = ""
44
+ for item in result.content:
45
+ part = content_text(item)
46
+ if text and part and not text.endswith("\n") and not part.startswith("\n"):
47
+ text += "\n"
48
+ text += part
49
+ if result.is_error:
50
+ raise RuntimeError(text or "MCP Console returned an error")
51
+ if (
52
+ isinstance(result.structured_content, dict)
53
+ and "requirements" in result.structured_content
54
+ ):
55
+ return json.dumps(result.structured_content, indent=2)
56
+ return text
@@ -0,0 +1,32 @@
1
+ """Keep the Windows command bound to its installed native Console bundle."""
2
+
3
+ from importlib.metadata import distribution
4
+ import subprocess
5
+ import sys
6
+
7
+
8
+ def main() -> int:
9
+ native = next(
10
+ (
11
+ path.locate()
12
+ for path in distribution("mcp-console").files or ()
13
+ if path.parts[-2:] == ("libexec", "mcp-console.exe")
14
+ ),
15
+ None,
16
+ )
17
+ if native is None:
18
+ print("The installed native MCP Console executable is missing", file=sys.stderr)
19
+ return 1
20
+ # Private worker invocations carry explicitly inheritable native events
21
+ # and pipes. Keep those handles intact while forwarding the command.
22
+ with subprocess.Popen([str(native), *sys.argv[1:]], close_fds=False) as process:
23
+ while True:
24
+ try:
25
+ status = process.wait()
26
+ # Python's exit status accepts a signed Windows C int, while
27
+ # native exception/exit statuses retain all 32 bits.
28
+ return status if status < 2**31 else status - 2**32
29
+ except KeyboardInterrupt:
30
+ # Console control events already reach the native process.
31
+ # Retain ownership until its shutdown has finished.
32
+ continue
mcp_console/_sync.py ADDED
@@ -0,0 +1,290 @@
1
+ import asyncio
2
+ from collections.abc import Mapping, Sequence
3
+ from concurrent.futures import Future
4
+ from contextlib import ExitStack
5
+ from threading import Condition, Event, Lock
6
+ from typing import TYPE_CHECKING, Any, Literal, Self
7
+
8
+ from ._client import AsyncMCPConsole, Requirements
9
+ from ._common import Command, result_text
10
+
11
+ _CANCELLATION_ERROR = (
12
+ "Request cancellation cleanup did not finish; "
13
+ "close and reconnect before reusing this connection"
14
+ )
15
+ _CANCELLATION_PENDING = (
16
+ "Request cancellation cleanup is in progress; wait before reusing this connection"
17
+ )
18
+
19
+ if TYPE_CHECKING:
20
+ from anyio.from_thread import BlockingPortal
21
+ from mcp.types import CallToolResult, Tool
22
+
23
+
24
+ class _Request:
25
+ """Own one SDK call from queued admission through confirmed task retirement.
26
+
27
+ The host owns the response Future, so cancelling it never waits for the
28
+ event loop. SDK cancellation runs on the loop; the task's done callback
29
+ confirms retirement even if cancellation prevents its coroutine starting.
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ loop: asyncio.AbstractEventLoop,
35
+ console: AsyncMCPConsole,
36
+ arguments: Mapping[str, Any],
37
+ *,
38
+ admission: Condition,
39
+ ) -> None:
40
+ self.future: Future["CallToolResult"] = Future()
41
+ self.finished = Event()
42
+ self._loop = loop
43
+ self._console = console
44
+ self._arguments = arguments
45
+ self._admission = admission
46
+ self._task: asyncio.Task["CallToolResult"] | None = None
47
+
48
+ def start(self) -> None:
49
+ self._loop.call_soon_threadsafe(self._start)
50
+
51
+ def _start(self) -> None:
52
+ with self._admission:
53
+ if self.future.cancelled():
54
+ self.finished.set()
55
+ return
56
+ self._task = self._loop.create_task(self._run())
57
+ self._task.add_done_callback(self._finish)
58
+
59
+ async def _run(self) -> "CallToolResult":
60
+ if self.future.cancelled():
61
+ raise asyncio.CancelledError
62
+ return await self._console._call_send(self._arguments)
63
+
64
+ def _finish(self, task: asyncio.Task["CallToolResult"]) -> None:
65
+ with self._admission:
66
+ try:
67
+ result = task.result()
68
+ except asyncio.CancelledError:
69
+ self.future.cancel()
70
+ except BaseException as error:
71
+ if not self.future.cancelled():
72
+ self.future.set_exception(error)
73
+ else:
74
+ if not self.future.cancelled():
75
+ self.future.set_result(result)
76
+ finally:
77
+ self.finished.set()
78
+ self._admission.notify_all()
79
+
80
+ def cancel(self) -> None:
81
+ with self._admission:
82
+ self.future.cancel()
83
+ if self._task is None:
84
+ # The queued start must check the same cancelled Future under
85
+ # admission, so it cannot create a task after this receipt.
86
+ self.finished.set()
87
+ return
88
+ self._loop.call_soon_threadsafe(self._cancel)
89
+
90
+ def _cancel(self) -> None:
91
+ if self._task is not None:
92
+ self._task.cancel()
93
+
94
+ def abort(self) -> None:
95
+ # Queue the abort behind the task step awakened by the first cancel,
96
+ # even when both host callbacks waited for a busy loop. Cancelling
97
+ # twice before that step coalesces instead of escaping SDK shielding.
98
+ self._loop.call_soon_threadsafe(self._loop.call_soon, self._cancel)
99
+
100
+
101
+ class MCPConsole:
102
+ """A synchronous, persistent connection to ``mcp-console serve``.
103
+
104
+ Use as a context manager or explicitly call ``connect()`` and ``close()``.
105
+ The context starts Console and discovers its tools; leaving it closes the
106
+ connection and subprocess. Variables persist across calls on this connection.
107
+ The application owns its model clients, agents, and tool loops.
108
+
109
+ Calls block while the official MCP client runs on a background event loop.
110
+ Catching `KeyboardInterrupt` while waiting cancels that request's wait,
111
+ leaving an admitted cell running.
112
+ Poll to collect its output or send `control="interrupt"` to stop execution.
113
+ Connect and close only when no other call is in progress.
114
+
115
+ Args:
116
+ command: Console executable. Defaults to the installed `mcp-console`.
117
+ args: Server arguments. Defaults to `["serve"]`.
118
+ server_parameters: MCP stdio settings such as `cwd` and `env`.
119
+
120
+ Examples:
121
+ Install with `pip install 'mcp-console[client]'`. Define values in one
122
+ cell and use them in the next:
123
+
124
+ ```python
125
+ from mcp_console import MCPConsole
126
+
127
+ with MCPConsole() as console:
128
+ print(console.send(python="values = [12, 15, 18, 20, 25]; values"))
129
+ print(
130
+ console.send(
131
+ python="import statistics; statistics.mean(values)"
132
+ )
133
+ )
134
+ ```
135
+
136
+ Create framework adapters inside this context, after discovery. See
137
+ [chatlas](chatlas.tool.html), [Anthropic](anthropic.tool.html),
138
+ [Responses](openai.responses_tool.html), or [Agents](openai.agents_tool.html).
139
+ """
140
+
141
+ def __init__(
142
+ self,
143
+ *,
144
+ command: Command | None = None,
145
+ args: Sequence[Command] | None = None,
146
+ server_parameters: Mapping[str, Any] | None = None,
147
+ ) -> None:
148
+ self._async = AsyncMCPConsole(
149
+ command=command, args=args, server_parameters=server_parameters
150
+ )
151
+ self._stack: ExitStack | None = None
152
+ self._portal: BlockingPortal | None = None
153
+ self._loop: asyncio.AbstractEventLoop | None = None
154
+ self._admission = Condition(Lock())
155
+ self._cancellation: _Request | None = None
156
+ self._cancellation_failed = False
157
+
158
+ def connect(self) -> "Self":
159
+ """Start MCP Console and initialize its MCP client session."""
160
+ if self._cancellation_failed:
161
+ raise RuntimeError(_CANCELLATION_ERROR)
162
+ if self._cancellation is not None:
163
+ raise RuntimeError(_CANCELLATION_PENDING)
164
+ if self._stack is None:
165
+ from anyio.from_thread import start_blocking_portal
166
+
167
+ with ExitStack() as stack:
168
+ portal = stack.enter_context(start_blocking_portal())
169
+ # The MCP transport must enter and exit in the same async task.
170
+ stack.enter_context(portal.wrap_async_context_manager(self._async))
171
+ self._portal = portal
172
+ self._loop = portal.call(asyncio.get_running_loop)
173
+ self._stack = stack.pop_all()
174
+ return self
175
+
176
+ def close(self) -> None:
177
+ """Close the MCP session, subprocess, and portal thread."""
178
+ stack = self._stack
179
+ if stack is not None:
180
+ if self._cancellation is not None and not self._cancellation.finished.wait(
181
+ timeout=5
182
+ ):
183
+ # Retain ownership rather than joining a request that still
184
+ # suppresses native cancellation or permitting a replacement.
185
+ raise RuntimeError(
186
+ "Cancelled request did not stop; connection remains invalid"
187
+ )
188
+ try:
189
+ stack.close()
190
+ finally:
191
+ # ExitStack consumes all its callbacks even when teardown
192
+ # raises; the old portal must never look connected afterward.
193
+ self._stack = self._portal = self._loop = None
194
+ self._cancellation = None
195
+ self._cancellation_failed = False
196
+
197
+ def __enter__(self) -> "Self":
198
+ return self.connect()
199
+
200
+ def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
201
+ self.close()
202
+
203
+ def _call_send(self, arguments: Mapping[str, Any]) -> "CallToolResult":
204
+ request: _Request | None = None
205
+ try:
206
+ with self._admission:
207
+ try:
208
+ if self._cancellation_failed:
209
+ raise RuntimeError(_CANCELLATION_ERROR)
210
+ if self._cancellation is not None:
211
+ raise RuntimeError(_CANCELLATION_PENDING)
212
+ if self._loop is None:
213
+ raise RuntimeError(
214
+ "MCPConsole is not connected; use `with MCPConsole() as console` "
215
+ "or call `console.connect()` first"
216
+ )
217
+ request = _Request(
218
+ self._loop, self._async, arguments, admission=self._admission
219
+ )
220
+ request.start()
221
+ # Waiting releases admission for ordinary concurrent calls.
222
+ # On interruption it reacquires admission before propagating,
223
+ # so cancellation is published before another send can pass.
224
+ self._admission.wait_for(request.future.done)
225
+ return request.future.result()
226
+ except KeyboardInterrupt:
227
+ if request is not None:
228
+ self._cancellation = request
229
+ raise
230
+ except KeyboardInterrupt:
231
+ if request is None:
232
+ raise
233
+ completed = False
234
+ try:
235
+ request.cancel()
236
+ completed = request.finished.wait(timeout=5)
237
+ finally:
238
+ with self._admission:
239
+ if completed:
240
+ self._cancellation = None
241
+ else:
242
+ self._cancellation_failed = True
243
+ request.abort()
244
+ raise
245
+
246
+ def send(
247
+ self,
248
+ *,
249
+ r: str | None = None,
250
+ python: str | None = None,
251
+ sql: str | None = None,
252
+ control: Literal["interrupt", "restart"] | None = None,
253
+ requirements: Requirements | None = None,
254
+ stdin: str | None = None,
255
+ timeout_ms: int | None = None,
256
+ ) -> str:
257
+ """Run or control a cell on this connection and return its text output.
258
+
259
+ The arguments and polling behavior match
260
+ [AsyncMCPConsole.send()](AsyncMCPConsole.html#send). Send one language
261
+ per call. If output ends in `[running; poll with an empty send]`, collect
262
+ the remaining output with `console.send()` before submitting another cell.
263
+ Images appear as MIME placeholders; MCP tool errors raise `RuntimeError`.
264
+ """
265
+ arguments = {
266
+ "r": r,
267
+ "python": python,
268
+ "sql": sql,
269
+ "control": control,
270
+ "requirements": requirements,
271
+ "stdin": stdin,
272
+ "timeout_ms": timeout_ms,
273
+ }
274
+ return result_text(
275
+ self._call_send(
276
+ {name: value for name, value in arguments.items() if value is not None}
277
+ )
278
+ )
279
+
280
+ __call__ = send
281
+
282
+ @property
283
+ def send_tool(self) -> "Tool":
284
+ """The connected server's MCP tool definition, used by SDK adapters.
285
+
286
+ Available after connecting. Its input schema reflects the server's
287
+ configured capabilities. Use a framework adapter to register this
288
+ schema; recreate adapters after reconnecting.
289
+ """
290
+ return self._async.send_tool