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.
- mcp_console/__init__.py +15 -0
- mcp_console/_client.py +252 -0
- mcp_console/_common.py +56 -0
- mcp_console/_launcher.py +32 -0
- mcp_console/_sync.py +290 -0
- mcp_console/anthropic.py +175 -0
- mcp_console/chatlas.py +133 -0
- mcp_console/codex.py +56 -0
- mcp_console/openai.py +394 -0
- mcp_console/py.typed +0 -0
- mcp_console-0.0.5.data/data/libexec/mcp-console-sandbox.exe +0 -0
- mcp_console-0.0.5.data/data/libexec/mcp-console.exe +0 -0
- mcp_console-0.0.5.data/data/share/licenses/mcp-console/LICENSE +201 -0
- mcp_console-0.0.5.data/data/share/licenses/mcp-console/NOTICE +6 -0
- mcp_console-0.0.5.dist-info/METADATA +116 -0
- mcp_console-0.0.5.dist-info/RECORD +20 -0
- mcp_console-0.0.5.dist-info/WHEEL +4 -0
- mcp_console-0.0.5.dist-info/entry_points.txt +3 -0
- mcp_console-0.0.5.dist-info/licenses/LICENSE +21 -0
- mcp_console-0.0.5.dist-info/sboms/mcp-console.cyclonedx.json +8332 -0
mcp_console/__init__.py
ADDED
|
@@ -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
|
mcp_console/_launcher.py
ADDED
|
@@ -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
|