py-codemode 0.1.1__tar.gz

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,169 @@
1
+ Metadata-Version: 2.4
2
+ Name: py-codemode
3
+ Version: 0.1.1
4
+ Summary: Code Mode: use LLMs to generate executable code that performs tool calls.
5
+ Keywords: llm,code-generation,tool-calling,mcp
6
+ Author: Xin
7
+ Author-email: Xin <xin@imfing.com>
8
+ License-Expression: MIT
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Typing :: Typed
17
+ Requires-Dist: httpx>=0.28.1
18
+ Requires-Dist: jsrun>=0.1.0
19
+ Requires-Dist: mcp>=1.0,<2 ; extra == 'mcp'
20
+ Requires-Python: >=3.10
21
+ Project-URL: Homepage, https://github.com/imfing/codemode
22
+ Project-URL: Repository, https://github.com/imfing/codemode
23
+ Project-URL: Issues, https://github.com/imfing/codemode/issues
24
+ Provides-Extra: mcp
25
+ Description-Content-Type: text/markdown
26
+
27
+ # codemode
28
+
29
+ Instead of making tool calls one at a time, let the LLM write code that orchestrates your Python tools in a single, more token-efficient pass.
30
+
31
+ Codemode runs that code in an in-process V8 isolate via [jsrun](https://github.com/imfing/jsrun). Isolates spin up in under 5ms with the same runtime performance as Node.js, have no network or filesystem access by default, and cannot reach the host except through functions you explicitly provide.
32
+
33
+ > **Experimental**: this project is in early development
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install py-codemode[mcp]
39
+ # or
40
+ uv add py-codemode[mcp]
41
+ ```
42
+
43
+ For the core executor without MCP: `pip install py-codemode`
44
+
45
+ ## Quick start
46
+
47
+ Create an MCP server with built-in capabilities and custom tools:
48
+
49
+ ```python
50
+ from codemode import EnvCapability, FsCapability, HttpCapability
51
+ from codemode.mcp import CodeModeServer
52
+
53
+ server = CodeModeServer(
54
+ name="example",
55
+ capabilities=[
56
+ EnvCapability(allowed_keys=["USER"]),
57
+ FsCapability(allowed_paths=["./examples"]),
58
+ HttpCapability(allowed_hosts=["httpbin.org"]),
59
+ ],
60
+ enable_search=True,
61
+ )
62
+
63
+
64
+ @server.tool()
65
+ def add(a: int, b: int) -> int:
66
+ """Add two numbers"""
67
+ return a + b
68
+
69
+
70
+ if __name__ == "__main__":
71
+ server.mcp.run(transport="streamable-http")
72
+ ```
73
+
74
+ Run with `uv run python examples/mcp_server.py` and connect any MCP client.
75
+
76
+ The `codemode[mcp]` extra wraps the executor as an MCP server, exposing:
77
+
78
+ - **`execute`** -- runs JavaScript that calls your Python functions. TypeScript declarations are auto-generated from your type hints and embedded in the tool description so the LLM knows what is callable.
79
+ - **`search`** (optional, via `enable_search=True`) -- searches registered capabilities and tools by name or description.
80
+
81
+ ## How it works
82
+
83
+ ```
84
+ ┌─────────────────────┐ ┌──────────────────────┐
85
+ │ V8 isolate (jsrun) │ │ Python host │
86
+ │ │ │ │
87
+ │ LLM-generated JS │ │ Executor │
88
+ │ runs in async IIFE │ │ │
89
+ │ │ bridge │ │
90
+ │ codemode.fn({...}) ├────────►│ _dispatch() │
91
+ │ │◄────────┤ -> your_fn(**kwargs)│
92
+ │ │ result │ │
93
+ └─────────────────────┘ └──────────────────────┘
94
+ ```
95
+
96
+ The executor wraps your code in a harness that captures console output and sets up a `codemode` proxy object. The proxy intercepts every `codemode.*()` call, serializes it to JSON, and bridges back to Python where `_dispatch()` routes it to the matching host function. The MCP server uses `Executor` under the hood.
97
+
98
+ ## Host functions
99
+
100
+ Host functions use keyword-only parameters with type hints:
101
+
102
+ ```python
103
+ async def read(*, path: str) -> str:
104
+ return open(path).read()
105
+ ```
106
+
107
+ Called from JavaScript as `await codemode.read({ path: "/tmp/f" })`. The type hints are used to generate TypeScript stubs (`str` becomes `string`, `int/float` becomes `number`, `list[T]` becomes `T[]`, etc.).
108
+
109
+ ## Built-in capabilities
110
+
111
+ Capabilities are namespaced host functions with allowlist-based security. The sandbox has no filesystem, network, or environment access by default.
112
+
113
+ | Capability | Namespace | Description |
114
+ |---|---|---|
115
+ | `FsCapability(allowed_paths=[...])` | `codemode.fs` | Sandboxed read/write within allowed paths |
116
+ | `EnvCapability(allowed_keys=[...])` | `codemode.env` | Read environment variables from an allowlist |
117
+ | `HttpCapability(allowed_hosts=[...])` | `codemode.http` | HTTP requests to allowed hosts only |
118
+
119
+ ## Limitations
120
+
121
+ - V8 runtime, not Node, so no Node built-in modules. JavaScript only.
122
+ - No top-level `import` or `require` in sandbox code
123
+
124
+ ## Advanced
125
+
126
+ For step-by-step control over host calls, use the session API directly:
127
+
128
+ ```python
129
+ import asyncio
130
+ from codemode import Executor, HostCallRequest, RunCompleted, RunFailed
131
+
132
+ async def main():
133
+ executor = Executor(timeout_s=5)
134
+ session = await executor.start("""
135
+ async () => {
136
+ const a = await codemode.double({ n: 3 });
137
+ const b = await codemode.double({ n: a });
138
+ return b;
139
+ }
140
+ """)
141
+
142
+ while True:
143
+ event = await session.next_event()
144
+ if isinstance(event, HostCallRequest):
145
+ result = event.input["n"] * 2
146
+ await session.submit_result(event.call_id, result)
147
+ elif isinstance(event, (RunCompleted, RunFailed)):
148
+ break
149
+
150
+ await session.cancel()
151
+ executor.close()
152
+
153
+ asyncio.run(main())
154
+ ```
155
+
156
+ This gives you full control over each host call, useful for logging, approval flows, or routing to external services.
157
+
158
+ jsrun also supports loading additional JavaScript libraries into the isolate via `Runtime.add_static_module()` or custom module loaders, V8 heap snapshots for faster cold starts, heap size limits, and per-call execution timeouts. See the [jsrun documentation](https://github.com/imfing/jsrun) for details.
159
+
160
+ ## Further reading
161
+
162
+ - [jsrun](https://github.com/imfing/jsrun) -- the V8 isolate runtime that powers codemode
163
+ - [src/codemode](https://github.com/imfing/codemode/tree/main/src/codemode) -- executor, capabilities, and stub generation source
164
+ - [src/codemode/mcp](https://github.com/imfing/codemode/tree/main/src/codemode/mcp) -- MCP server and registry source
165
+ - [examples/](https://github.com/imfing/codemode/tree/main/examples) -- runnable examples
166
+
167
+ ## License
168
+
169
+ MIT
@@ -0,0 +1,143 @@
1
+ # codemode
2
+
3
+ Instead of making tool calls one at a time, let the LLM write code that orchestrates your Python tools in a single, more token-efficient pass.
4
+
5
+ Codemode runs that code in an in-process V8 isolate via [jsrun](https://github.com/imfing/jsrun). Isolates spin up in under 5ms with the same runtime performance as Node.js, have no network or filesystem access by default, and cannot reach the host except through functions you explicitly provide.
6
+
7
+ > **Experimental**: this project is in early development
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pip install py-codemode[mcp]
13
+ # or
14
+ uv add py-codemode[mcp]
15
+ ```
16
+
17
+ For the core executor without MCP: `pip install py-codemode`
18
+
19
+ ## Quick start
20
+
21
+ Create an MCP server with built-in capabilities and custom tools:
22
+
23
+ ```python
24
+ from codemode import EnvCapability, FsCapability, HttpCapability
25
+ from codemode.mcp import CodeModeServer
26
+
27
+ server = CodeModeServer(
28
+ name="example",
29
+ capabilities=[
30
+ EnvCapability(allowed_keys=["USER"]),
31
+ FsCapability(allowed_paths=["./examples"]),
32
+ HttpCapability(allowed_hosts=["httpbin.org"]),
33
+ ],
34
+ enable_search=True,
35
+ )
36
+
37
+
38
+ @server.tool()
39
+ def add(a: int, b: int) -> int:
40
+ """Add two numbers"""
41
+ return a + b
42
+
43
+
44
+ if __name__ == "__main__":
45
+ server.mcp.run(transport="streamable-http")
46
+ ```
47
+
48
+ Run with `uv run python examples/mcp_server.py` and connect any MCP client.
49
+
50
+ The `codemode[mcp]` extra wraps the executor as an MCP server, exposing:
51
+
52
+ - **`execute`** -- runs JavaScript that calls your Python functions. TypeScript declarations are auto-generated from your type hints and embedded in the tool description so the LLM knows what is callable.
53
+ - **`search`** (optional, via `enable_search=True`) -- searches registered capabilities and tools by name or description.
54
+
55
+ ## How it works
56
+
57
+ ```
58
+ ┌─────────────────────┐ ┌──────────────────────┐
59
+ │ V8 isolate (jsrun) │ │ Python host │
60
+ │ │ │ │
61
+ │ LLM-generated JS │ │ Executor │
62
+ │ runs in async IIFE │ │ │
63
+ │ │ bridge │ │
64
+ │ codemode.fn({...}) ├────────►│ _dispatch() │
65
+ │ │◄────────┤ -> your_fn(**kwargs)│
66
+ │ │ result │ │
67
+ └─────────────────────┘ └──────────────────────┘
68
+ ```
69
+
70
+ The executor wraps your code in a harness that captures console output and sets up a `codemode` proxy object. The proxy intercepts every `codemode.*()` call, serializes it to JSON, and bridges back to Python where `_dispatch()` routes it to the matching host function. The MCP server uses `Executor` under the hood.
71
+
72
+ ## Host functions
73
+
74
+ Host functions use keyword-only parameters with type hints:
75
+
76
+ ```python
77
+ async def read(*, path: str) -> str:
78
+ return open(path).read()
79
+ ```
80
+
81
+ Called from JavaScript as `await codemode.read({ path: "/tmp/f" })`. The type hints are used to generate TypeScript stubs (`str` becomes `string`, `int/float` becomes `number`, `list[T]` becomes `T[]`, etc.).
82
+
83
+ ## Built-in capabilities
84
+
85
+ Capabilities are namespaced host functions with allowlist-based security. The sandbox has no filesystem, network, or environment access by default.
86
+
87
+ | Capability | Namespace | Description |
88
+ |---|---|---|
89
+ | `FsCapability(allowed_paths=[...])` | `codemode.fs` | Sandboxed read/write within allowed paths |
90
+ | `EnvCapability(allowed_keys=[...])` | `codemode.env` | Read environment variables from an allowlist |
91
+ | `HttpCapability(allowed_hosts=[...])` | `codemode.http` | HTTP requests to allowed hosts only |
92
+
93
+ ## Limitations
94
+
95
+ - V8 runtime, not Node, so no Node built-in modules. JavaScript only.
96
+ - No top-level `import` or `require` in sandbox code
97
+
98
+ ## Advanced
99
+
100
+ For step-by-step control over host calls, use the session API directly:
101
+
102
+ ```python
103
+ import asyncio
104
+ from codemode import Executor, HostCallRequest, RunCompleted, RunFailed
105
+
106
+ async def main():
107
+ executor = Executor(timeout_s=5)
108
+ session = await executor.start("""
109
+ async () => {
110
+ const a = await codemode.double({ n: 3 });
111
+ const b = await codemode.double({ n: a });
112
+ return b;
113
+ }
114
+ """)
115
+
116
+ while True:
117
+ event = await session.next_event()
118
+ if isinstance(event, HostCallRequest):
119
+ result = event.input["n"] * 2
120
+ await session.submit_result(event.call_id, result)
121
+ elif isinstance(event, (RunCompleted, RunFailed)):
122
+ break
123
+
124
+ await session.cancel()
125
+ executor.close()
126
+
127
+ asyncio.run(main())
128
+ ```
129
+
130
+ This gives you full control over each host call, useful for logging, approval flows, or routing to external services.
131
+
132
+ jsrun also supports loading additional JavaScript libraries into the isolate via `Runtime.add_static_module()` or custom module loaders, V8 heap snapshots for faster cold starts, heap size limits, and per-call execution timeouts. See the [jsrun documentation](https://github.com/imfing/jsrun) for details.
133
+
134
+ ## Further reading
135
+
136
+ - [jsrun](https://github.com/imfing/jsrun) -- the V8 isolate runtime that powers codemode
137
+ - [src/codemode](https://github.com/imfing/codemode/tree/main/src/codemode) -- executor, capabilities, and stub generation source
138
+ - [src/codemode/mcp](https://github.com/imfing/codemode/tree/main/src/codemode/mcp) -- MCP server and registry source
139
+ - [examples/](https://github.com/imfing/codemode/tree/main/examples) -- runnable examples
140
+
141
+ ## License
142
+
143
+ MIT
@@ -0,0 +1,57 @@
1
+ [project]
2
+ name = "py-codemode"
3
+ version = "0.1.1"
4
+ description = "Code Mode: use LLMs to generate executable code that performs tool calls."
5
+ readme = "README.md"
6
+ authors = [{ name = "Xin", email = "xin@imfing.com" }]
7
+ license = "MIT"
8
+ keywords = ["llm", "code-generation", "tool-calling", "mcp"]
9
+ classifiers = [
10
+ "Development Status :: 3 - Alpha",
11
+ "Intended Audience :: Developers",
12
+ "Programming Language :: Python :: 3",
13
+ "Programming Language :: Python :: 3.10",
14
+ "Programming Language :: Python :: 3.11",
15
+ "Programming Language :: Python :: 3.12",
16
+ "Programming Language :: Python :: 3.13",
17
+ "Typing :: Typed",
18
+ ]
19
+ requires-python = ">=3.10"
20
+ dependencies = [
21
+ "httpx>=0.28.1",
22
+ "jsrun>=0.1.0",
23
+ ]
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/imfing/codemode"
27
+ Repository = "https://github.com/imfing/codemode"
28
+ Issues = "https://github.com/imfing/codemode/issues"
29
+
30
+ [project.optional-dependencies]
31
+ mcp = [
32
+ "mcp>=1.0,<2",
33
+ ]
34
+
35
+ [build-system]
36
+ requires = ["uv_build>=0.10.4,<0.11.0"]
37
+ build-backend = "uv_build"
38
+
39
+ [tool.uv.build-backend]
40
+ module-name = "codemode"
41
+
42
+ [dependency-groups]
43
+ dev = [
44
+ "pytest>=9.0.2",
45
+ "pytest-asyncio>=1.3.0",
46
+ "ruff>=0.13.0",
47
+ ]
48
+
49
+ [tool.pytest.ini_options]
50
+ asyncio_mode = "auto"
51
+
52
+ [tool.ruff]
53
+ target-version = "py310"
54
+ line-length = 88
55
+
56
+ [tool.ruff.lint]
57
+ select = ["E", "F", "I"]
@@ -0,0 +1,25 @@
1
+ from .capabilities import EnvCapability, FsCapability, HttpCapability
2
+ from .executor import (
3
+ Capability,
4
+ Executor,
5
+ HostCallRequest,
6
+ RunCompleted,
7
+ RunEvent,
8
+ RunFailed,
9
+ RunSession,
10
+ )
11
+ from .stubs import generate_stubs
12
+
13
+ __all__ = [
14
+ "Capability",
15
+ "Executor",
16
+ "RunSession",
17
+ "HostCallRequest",
18
+ "RunCompleted",
19
+ "RunFailed",
20
+ "RunEvent",
21
+ "EnvCapability",
22
+ "FsCapability",
23
+ "HttpCapability",
24
+ "generate_stubs",
25
+ ]
@@ -0,0 +1,5 @@
1
+ from .env import EnvCapability
2
+ from .fs import FsCapability
3
+ from .http import HttpCapability
4
+
5
+ __all__ = ["EnvCapability", "FsCapability", "HttpCapability"]
@@ -0,0 +1,20 @@
1
+ import os
2
+
3
+ from codemode.executor import HostFn
4
+
5
+
6
+ class EnvCapability:
7
+ namespace = "env"
8
+ description = "Read environment variables from an allowlist"
9
+
10
+ def __init__(self, *, allowed_keys: list[str]) -> None:
11
+ self._allowed: set[str] = set(allowed_keys)
12
+
13
+ def exports(self) -> dict[str, HostFn]:
14
+ return {"get": self._get}
15
+
16
+ async def _get(self, *, key: str) -> str | None:
17
+ """Return the value of an environment variable, or null if unset."""
18
+ if key not in self._allowed:
19
+ raise ValueError(f"Key {key!r} is not allowed")
20
+ return os.environ.get(key)
@@ -0,0 +1,35 @@
1
+ import asyncio
2
+ from pathlib import Path
3
+
4
+ from codemode.executor import HostFn
5
+
6
+
7
+ class FsCapability:
8
+ namespace = "fs"
9
+ description = "Sandboxed file-system access within allowed paths"
10
+
11
+ def __init__(self, *, allowed_paths: list[str]) -> None:
12
+ self._allowed = [Path(p).resolve() for p in allowed_paths]
13
+
14
+ def exports(self) -> dict[str, HostFn]:
15
+ return {"read": self._read, "write": self._write}
16
+
17
+ def _check_path(self, raw: str) -> Path:
18
+ resolved = Path(raw).resolve()
19
+ for allowed in self._allowed:
20
+ try:
21
+ resolved.relative_to(allowed)
22
+ return resolved
23
+ except ValueError:
24
+ continue
25
+ raise ValueError(f"Path {raw!r} is not within allowed paths")
26
+
27
+ async def _read(self, *, path: str) -> str:
28
+ """Read a file and return its contents as UTF-8 text."""
29
+ checked = self._check_path(path)
30
+ return await asyncio.to_thread(checked.read_text)
31
+
32
+ async def _write(self, *, path: str, content: str) -> None:
33
+ """Write text content to a file, creating it if it does not exist."""
34
+ checked = self._check_path(path)
35
+ await asyncio.to_thread(checked.write_text, content)
@@ -0,0 +1,49 @@
1
+ from typing import Any
2
+ from urllib.parse import urlparse
3
+
4
+ import httpx
5
+
6
+ from codemode.executor import HostFn
7
+
8
+
9
+ class HttpCapability:
10
+ namespace = "http"
11
+ description = "Make outbound HTTP requests to allowed hosts"
12
+
13
+ def __init__(self, *, allowed_hosts: list[str] | None = None) -> None:
14
+ self._allowed_hosts: set[str] | None = (
15
+ set(allowed_hosts) if allowed_hosts is not None else None
16
+ )
17
+
18
+ def exports(self) -> dict[str, HostFn]:
19
+ return {"fetch": self._fetch}
20
+
21
+ def _check_host(self, url: str) -> None:
22
+ if self._allowed_hosts is None:
23
+ return
24
+ host = urlparse(url).hostname or ""
25
+ if host not in self._allowed_hosts:
26
+ raise ValueError(f"Host {host!r} is not allowed")
27
+
28
+ async def _fetch(
29
+ self,
30
+ *,
31
+ url: str,
32
+ method: str = "GET",
33
+ headers: dict[str, str] | None = None,
34
+ body: str | None = None,
35
+ ) -> dict[str, Any]:
36
+ """Make an HTTP request and return status, headers, and body."""
37
+ self._check_host(url)
38
+ async with httpx.AsyncClient() as client:
39
+ resp = await client.request(
40
+ method,
41
+ url,
42
+ headers=dict(headers or {}),
43
+ content=body,
44
+ )
45
+ return {
46
+ "status": resp.status_code,
47
+ "headers": dict(resp.headers),
48
+ "body": resp.text,
49
+ }
@@ -0,0 +1,265 @@
1
+ import asyncio
2
+ import contextlib
3
+ import inspect
4
+ import json
5
+ from dataclasses import dataclass
6
+ from typing import Any, Awaitable, Callable, Mapping, Protocol
7
+
8
+ from jsrun import JavaScriptError, Runtime, RuntimeConfig
9
+
10
+ HostFn = Callable[..., Awaitable[Any] | Any]
11
+
12
+
13
+ class Capability(Protocol):
14
+ """Capability contract for executor registration."""
15
+
16
+ namespace: str
17
+ description: str
18
+
19
+ def exports(self) -> dict[str, HostFn]: ...
20
+
21
+
22
+ @dataclass
23
+ class HostCallRequest:
24
+ call_id: str
25
+ target: str
26
+ input: dict[str, Any]
27
+
28
+
29
+ @dataclass
30
+ class RunCompleted:
31
+ result: Any = None
32
+ logs: list[str] | None = None
33
+
34
+
35
+ @dataclass
36
+ class RunFailed:
37
+ error: str
38
+ logs: list[str] | None = None
39
+
40
+
41
+ RunEvent = HostCallRequest | RunCompleted | RunFailed
42
+
43
+
44
+ class RunSession:
45
+ def __init__(
46
+ self,
47
+ runtime: Runtime,
48
+ script: str,
49
+ all_fns: dict[str, HostFn],
50
+ timeout_s: float,
51
+ ) -> None:
52
+ self._runtime = runtime
53
+ self._timeout_s = timeout_s
54
+ self.events: asyncio.Queue[RunEvent] = asyncio.Queue()
55
+ self._pending: dict[str, asyncio.Future[Any]] = {}
56
+ self._counter = 0
57
+ self._task = asyncio.create_task(self._run(script, all_fns))
58
+
59
+ async def __aenter__(self) -> "RunSession":
60
+ return self
61
+
62
+ async def __aexit__(self, *_: object) -> None:
63
+ await self.cancel()
64
+
65
+ async def _run(self, script: str, all_fns: dict[str, HostFn]) -> None:
66
+ loop = asyncio.get_running_loop()
67
+
68
+ async def _dispatch(tool_name: str, args_json: str) -> str:
69
+ args = json.loads(args_json) if args_json else {}
70
+ if not isinstance(args, dict):
71
+ return json.dumps({"error": "Tool args must be an object"})
72
+
73
+ fn = all_fns.get(tool_name)
74
+ if fn is not None:
75
+ try:
76
+ value = fn(**args)
77
+ if inspect.isawaitable(value):
78
+ value = await value
79
+ return json.dumps({"result": value})
80
+ except Exception as exc:
81
+ return json.dumps({"error": str(exc)})
82
+
83
+ call_id = str(self._counter)
84
+ self._counter += 1
85
+ fut: asyncio.Future[Any] = loop.create_future()
86
+ self._pending[call_id] = fut
87
+ await self.events.put(
88
+ HostCallRequest(call_id=call_id, target=tool_name, input=args)
89
+ )
90
+ try:
91
+ value = await fut
92
+ return json.dumps({"result": value})
93
+ except Exception as exc:
94
+ return json.dumps({"error": str(exc)})
95
+ finally:
96
+ self._pending.pop(call_id, None)
97
+
98
+ try:
99
+ self._runtime.bind_function("__codemode_call", _dispatch)
100
+ out = await self._runtime.eval_async(script, timeout=self._timeout_s)
101
+ if not isinstance(out, dict):
102
+ await self.events.put(RunFailed(error="Invalid response shape"))
103
+ return
104
+ error = out.get("error")
105
+ if error:
106
+ await self.events.put(RunFailed(error=str(error), logs=out.get("logs")))
107
+ else:
108
+ await self.events.put(
109
+ RunCompleted(result=out.get("result"), logs=out.get("logs"))
110
+ )
111
+ except TimeoutError:
112
+ await self.events.put(RunFailed(error="Execution timed out"))
113
+ except JavaScriptError as exc:
114
+ await self.events.put(RunFailed(error=f"JavaScript runtime error: {exc}"))
115
+ except Exception as exc:
116
+ await self.events.put(RunFailed(error=f"Executor failure: {exc}"))
117
+ finally:
118
+ self._runtime.close()
119
+
120
+ async def next_event(self) -> RunEvent:
121
+ return await self.events.get()
122
+
123
+ def _get_pending(self, call_id: str) -> "asyncio.Future[Any]":
124
+ fut = self._pending.get(call_id)
125
+ if fut is None or fut.done():
126
+ raise KeyError(f"Unknown or already-settled call_id: {call_id!r}")
127
+ return fut
128
+
129
+ async def submit_result(self, call_id: str, value: Any) -> None:
130
+ self._get_pending(call_id).set_result(value)
131
+
132
+ async def submit_error(self, call_id: str, error: str) -> None:
133
+ self._get_pending(call_id).set_exception(RuntimeError(error))
134
+
135
+ async def cancel(self) -> None:
136
+ for fut in self._pending.values():
137
+ if not fut.done():
138
+ fut.set_exception(RuntimeError("session cancelled"))
139
+ self._pending.clear()
140
+ self._task.cancel()
141
+ with contextlib.suppress(asyncio.CancelledError):
142
+ await self._task
143
+
144
+
145
+ class Executor:
146
+ def __init__(
147
+ self,
148
+ *,
149
+ timeout_s: float = 60.0,
150
+ max_heap_bytes: int = 16 * 1024 * 1024,
151
+ capabilities: list[Capability] | None = None,
152
+ ) -> None:
153
+ self._timeout_s = timeout_s
154
+ self._max_heap_bytes = max_heap_bytes
155
+ self._closed = False
156
+ self._capabilities: list[Capability] = capabilities or []
157
+
158
+ async def start(
159
+ self, code: str, fns: Mapping[str, HostFn] | None = None
160
+ ) -> RunSession:
161
+ if self._closed:
162
+ raise RuntimeError("Executor is closed")
163
+
164
+ all_fns: dict[str, HostFn] = dict(fns or {})
165
+ for cap in self._capabilities:
166
+ for fn_name, fn in cap.exports().items():
167
+ all_fns[f"{cap.namespace}.{fn_name}"] = fn
168
+
169
+ namespaces = [cap.namespace for cap in self._capabilities]
170
+ runtime = Runtime(RuntimeConfig(max_heap_size=self._max_heap_bytes))
171
+ script = self._build_script(code, namespaces)
172
+ return RunSession(runtime, script, all_fns, self._timeout_s)
173
+
174
+ async def execute(
175
+ self, code: str, fns: Mapping[str, HostFn] | None = None
176
+ ) -> RunCompleted | RunFailed:
177
+ session = await self.start(code, fns=fns)
178
+ try:
179
+ event = await session.next_event()
180
+ while isinstance(event, HostCallRequest):
181
+ await session.submit_error(
182
+ event.call_id, f"Unknown tool: {event.target!r}"
183
+ )
184
+ event = await session.next_event()
185
+ return event
186
+ finally:
187
+ await session.cancel()
188
+
189
+ def close(self) -> None:
190
+ self._closed = True
191
+
192
+ async def __aenter__(self) -> "Executor":
193
+ return self
194
+
195
+ async def __aexit__(self, *_: object) -> None:
196
+ self.close()
197
+
198
+ @staticmethod
199
+ def _build_script(code: str, namespaces: list[str]) -> str:
200
+ source = json.dumps(code)
201
+ ns_json = json.dumps(namespaces)
202
+ return f"""
203
+ (async () => {{
204
+ const __logs = [];
205
+ console.log = (...a) => __logs.push(a.map(String).join(" "));
206
+ console.warn = (...a) => __logs.push("[warn] " + a.map(String).join(" "));
207
+ console.error = (...a) => __logs.push("[error] " + a.map(String).join(" "));
208
+
209
+ const __ns = new Set({ns_json});
210
+ const __call = (key) => async (args) => {{
211
+ const resJson = await __codemode_call(key, JSON.stringify(args ?? {{}}));
212
+ const data = JSON.parse(resJson);
213
+ if (data.error) throw new Error(data.error);
214
+ return data.result;
215
+ }};
216
+
217
+ const codemode = new Proxy({{}}, {{
218
+ get: (_, key) => __ns.has(key)
219
+ ? new Proxy({{}}, {{ get: (_, fn) => __call(`${{key}}.${{fn}}`) }})
220
+ : __call(key)
221
+ }});
222
+
223
+ let fn;
224
+ try {{
225
+ fn = eval({source});
226
+ }} catch (err) {{
227
+ if (err instanceof SyntaxError) {{
228
+ // Fallback: wrap bare statements in an async function.
229
+ try {{
230
+ fn = eval(`(async () => {{\\n${{ {source} }}\\n}})`);
231
+ }} catch (_) {{
232
+ return {{
233
+ result: undefined,
234
+ error: `Code parse error: ${{err.message}}`,
235
+ logs: __logs
236
+ }};
237
+ }}
238
+ }} else {{
239
+ return {{
240
+ result: undefined,
241
+ error: `Code parse error: ${{err.message}}`,
242
+ logs: __logs
243
+ }};
244
+ }}
245
+ }}
246
+
247
+ if (typeof fn !== "function") {{
248
+ // The code evaluated to a non-function value; wrap it as a
249
+ // return expression inside an async function.
250
+ const __val = fn;
251
+ fn = async () => __val;
252
+ }}
253
+
254
+ try {{
255
+ const result = await fn();
256
+ return {{ result: result === undefined ? null : result, logs: __logs }};
257
+ }} catch (err) {{
258
+ return {{
259
+ result: undefined,
260
+ error: err?.message ?? String(err),
261
+ logs: __logs
262
+ }};
263
+ }}
264
+ }})()
265
+ """
@@ -0,0 +1,17 @@
1
+ """MCP integration package for CodeMode."""
2
+
3
+ try:
4
+ import mcp as _mcp_sdk # type: ignore[import-not-found]
5
+ except ModuleNotFoundError as exc:
6
+ if exc.name not in (None, "mcp"):
7
+ raise
8
+ raise ModuleNotFoundError(
9
+ "codemode.mcp requires the optional MCP SDK dependency. "
10
+ "Install with `pip install codemode[mcp]`."
11
+ ) from exc
12
+ else:
13
+ del _mcp_sdk
14
+ from .server import CodeModeServer
15
+
16
+
17
+ __all__ = ["CodeModeServer"]
@@ -0,0 +1,141 @@
1
+ """MCP registry and metadata assembly helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Iterable
6
+
7
+ from codemode.executor import Capability, HostFn
8
+ from codemode.stubs import first_doc_line, generate_stubs
9
+
10
+
11
+ class MCPRegistry:
12
+ """Stores capabilities and tools, assembles MCP-facing metadata."""
13
+
14
+ def __init__(self, capabilities: Iterable[Capability] | None = None) -> None:
15
+ self._capabilities: dict[str, Capability] = {}
16
+ self.tools: dict[str, HostFn] = {}
17
+ for capability in capabilities or ():
18
+ self.add_capability(capability)
19
+
20
+ def add_capability(self, capability: Capability) -> Capability:
21
+ namespace = capability.namespace
22
+ if namespace in self._capabilities:
23
+ raise ValueError(f"duplicate capability namespace: {namespace!r}")
24
+ if namespace in self.tools:
25
+ raise ValueError(
26
+ f"capability namespace conflicts with tool name: {namespace!r}"
27
+ )
28
+ self._capabilities[namespace] = capability
29
+ return capability
30
+
31
+ def register_tool(self, name: str, fn: HostFn) -> HostFn:
32
+ if name in self.tools:
33
+ raise ValueError(f"duplicate tool: {name!r}")
34
+ if name in self._capabilities:
35
+ raise ValueError(f"tool name conflicts with capability namespace: {name!r}")
36
+ self.tools[name] = fn
37
+ return fn
38
+
39
+ def _sorted_capabilities(self) -> list[Capability]:
40
+ return [self._capabilities[ns] for ns in sorted(self._capabilities)]
41
+
42
+ def build_execute_tool_description(self) -> str:
43
+ declarations = generate_stubs(self._sorted_capabilities(), flat_fns=self.tools)
44
+ return (
45
+ "Execute JavaScript with CodeMode host tools.\n\n"
46
+ "The `code` parameter accepts either:\n"
47
+ "1. A function expression (e.g. `async () => { ... }`). The "
48
+ "function is called automatically and its return value becomes "
49
+ "the `result`.\n"
50
+ "2. Bare statements (e.g. `const v = await codemode.env.get("
51
+ '{ key: "HOME" }); return v;`). These are auto-wrapped in an '
52
+ "async function. Use `return` to produce a `result`.\n\n"
53
+ "Rules:\n"
54
+ "- `return` a JSON-serializable value (string, number, boolean, "
55
+ "array, or plain object). Avoid returning `undefined`.\n"
56
+ "- `console.log()` output is captured in the `logs` field, not "
57
+ "in `result`.\n"
58
+ "- `await` works in both forms.\n\n"
59
+ "Examples:\n"
60
+ "```js\n"
61
+ "// Function expression\n"
62
+ "async () => {\n"
63
+ ' const val = await codemode.env.get({ key: "HOME" });\n'
64
+ " return val;\n"
65
+ "}\n"
66
+ "```\n"
67
+ "```js\n"
68
+ "// Bare statements (auto-wrapped)\n"
69
+ 'const val = await codemode.env.get({ key: "HOME" });\n'
70
+ "return val;\n"
71
+ "```\n\n"
72
+ "The `codemode` global provides the following TypeScript "
73
+ "declarations:\n\n"
74
+ "```ts\n"
75
+ f"{declarations}\n"
76
+ "```"
77
+ )
78
+
79
+ # ------------------------------------------------------------------
80
+ # Search
81
+ # ------------------------------------------------------------------
82
+
83
+ def search(self, query: str, *, limit: int = 20) -> list[dict[str, str | None]]:
84
+ normalized = query.strip().lower()
85
+ if not normalized or limit <= 0:
86
+ return []
87
+
88
+ entries = self._collect_entries()
89
+ scored: list[tuple[int, str, dict[str, str | None]]] = []
90
+
91
+ for entry in entries:
92
+ score = self._score(entry, normalized)
93
+ if score > 0:
94
+ scored.append((score, entry["name"] or "", entry))
95
+
96
+ scored.sort(key=lambda item: (-item[0], item[1]))
97
+ return [entry for _, _, entry in scored[:limit]]
98
+
99
+ def _collect_entries(self) -> list[dict[str, str | None]]:
100
+ entries: list[dict[str, str | None]] = []
101
+ for cap in self._sorted_capabilities():
102
+ namespace = cap.namespace
103
+ entries.append(
104
+ {
105
+ "name": namespace,
106
+ "kind": "capability",
107
+ "description": cap.description or None,
108
+ }
109
+ )
110
+ for fn_name, fn in sorted(cap.exports().items()):
111
+ entries.append(
112
+ {
113
+ "name": f"{namespace}.{fn_name}",
114
+ "kind": "method",
115
+ "description": first_doc_line(fn),
116
+ }
117
+ )
118
+ for name, fn in sorted(self.tools.items()):
119
+ entries.append(
120
+ {
121
+ "name": name,
122
+ "kind": "tool",
123
+ "description": first_doc_line(fn),
124
+ }
125
+ )
126
+ return entries
127
+
128
+ @staticmethod
129
+ def _score(entry: dict[str, str | None], query: str) -> int:
130
+ name = (entry.get("name") or "").lower()
131
+ description = (entry.get("description") or "").lower()
132
+ searchable = f"{name} {description}"
133
+
134
+ if query not in searchable:
135
+ return 0
136
+
137
+ if name == query:
138
+ return 3
139
+ if query in name:
140
+ return 2
141
+ return 1
@@ -0,0 +1,139 @@
1
+ """CodeMode MCP server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib import import_module
6
+ from typing import Any, Callable, Iterable
7
+
8
+ from codemode.executor import Capability, Executor, HostFn, RunCompleted, RunFailed
9
+
10
+ from .registry import MCPRegistry
11
+
12
+
13
+ class CodeModeServer:
14
+ """MCP server wrapper around CodeMode's executor and registry."""
15
+
16
+ def __init__(
17
+ self,
18
+ *,
19
+ name: str,
20
+ capabilities: Iterable[Capability] | None = None,
21
+ timeout_s: float = 60.0,
22
+ max_heap_bytes: int = 16 * 1024 * 1024,
23
+ enable_search: bool = False,
24
+ ) -> None:
25
+ self.name = name
26
+ self.capabilities = list(capabilities or [])
27
+ self.timeout_s = timeout_s
28
+ self.max_heap_bytes = max_heap_bytes
29
+ self.enable_search = enable_search
30
+ self.registry = MCPRegistry(self.capabilities)
31
+ self._mcp_server: Any | None = None
32
+
33
+ def tool(self) -> Callable[[HostFn], HostFn]:
34
+ """Decorator to register a tool.
35
+
36
+ The tool name is taken from the function's ``__name__`` and the
37
+ description from its docstring.
38
+
39
+ Example::
40
+
41
+ @server.tool()
42
+ async def echo(text: str) -> str:
43
+ \"\"\"Echo the input text.\"\"\"
44
+ return text
45
+ """
46
+
47
+ def decorator(fn: HostFn) -> HostFn:
48
+ name = getattr(fn, "__name__", None)
49
+ if not name:
50
+ raise ValueError("tool function must have a __name__")
51
+ registered = self.registry.register_tool(name, fn)
52
+ self._mcp_server = None # invalidate cached MCP server
53
+ return registered
54
+
55
+ return decorator
56
+
57
+ def build_executor(self) -> Executor:
58
+ return Executor(
59
+ timeout_s=self.timeout_s,
60
+ max_heap_bytes=self.max_heap_bytes,
61
+ capabilities=self.capabilities,
62
+ )
63
+
64
+ def search_tools(self, query: str, *, limit: int = 20) -> dict[str, Any]:
65
+ """Search registered tools/capabilities by name or description."""
66
+ return {
67
+ "query": query,
68
+ "results": self.registry.search(query, limit=limit),
69
+ }
70
+
71
+ async def execute_code(self, code: str) -> dict[str, Any]:
72
+ """Run JavaScript and return a transport-agnostic JSON-friendly payload."""
73
+ async with self.build_executor() as executor:
74
+ event = await executor.execute(code, fns=self.registry.tools)
75
+ return self._normalize_execute_result(event)
76
+
77
+ def _load_mcp_server_class(self) -> type[Any]:
78
+ try:
79
+ module = import_module("mcp.server.fastmcp")
80
+ return module.FastMCP
81
+ except Exception as exc: # pragma: no cover
82
+ raise ImportError(
83
+ "MCP SDK is required for transport adapters. "
84
+ "Install with `codemode[mcp]`."
85
+ ) from exc
86
+
87
+ @property
88
+ def mcp(self) -> Any:
89
+ """Return the underlying ``FastMCP`` server instance.
90
+
91
+ The instance is lazily built and cached. Registering new tools via
92
+ :meth:`tool` invalidates the cache so subsequent access picks up the
93
+ new tool.
94
+
95
+ Use the returned object's own transport methods, e.g.
96
+ ``server.mcp.run(transport="stdio")``.
97
+ """
98
+ if self._mcp_server is not None:
99
+ return self._mcp_server
100
+
101
+ mcp_server_cls = self._load_mcp_server_class()
102
+ mcp_server = mcp_server_cls(self.name)
103
+
104
+ @mcp_server.tool(
105
+ name="execute",
106
+ description=self.registry.build_execute_tool_description(),
107
+ )
108
+ async def execute(code: str) -> dict[str, Any]:
109
+ return await self.execute_code(code)
110
+
111
+ if self.enable_search:
112
+
113
+ @mcp_server.tool(
114
+ name="search",
115
+ description="Search registered CodeMode capabilities and tools.",
116
+ )
117
+ async def search(query: str, limit: int = 20) -> dict[str, Any]:
118
+ return self.search_tools(query, limit=limit)
119
+
120
+ self._mcp_server = mcp_server
121
+ return mcp_server
122
+
123
+ @staticmethod
124
+ def _normalize_execute_result(
125
+ event: RunCompleted | RunFailed,
126
+ ) -> dict[str, Any]:
127
+ if isinstance(event, RunCompleted):
128
+ return {
129
+ "success": True,
130
+ "result": event.result,
131
+ "error": None,
132
+ "logs": list(event.logs or []),
133
+ }
134
+ return {
135
+ "success": False,
136
+ "result": None,
137
+ "error": event.error,
138
+ "logs": list(event.logs or []),
139
+ }
File without changes
@@ -0,0 +1,127 @@
1
+ """TypeScript declaration generation for codemode host functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import types
7
+ from typing import Any, Callable, Union, get_args, get_origin
8
+
9
+ from .executor import Capability, HostFn
10
+
11
+
12
+ def _py_to_ts(annotation: Any) -> str:
13
+ if (
14
+ annotation
15
+ in (
16
+ inspect.Signature.empty,
17
+ inspect.Parameter.empty,
18
+ )
19
+ or annotation is Any
20
+ ):
21
+ return "any"
22
+ if annotation is None or annotation is type(None):
23
+ return "null"
24
+ if annotation is str:
25
+ return "string"
26
+ if annotation in (int, float):
27
+ return "number"
28
+ if annotation is bool:
29
+ return "boolean"
30
+
31
+ origin = get_origin(annotation)
32
+ args = get_args(annotation)
33
+
34
+ if origin in (Union, types.UnionType):
35
+ non_none = [arg for arg in args if arg is not type(None)]
36
+ if len(non_none) == 1 and len(non_none) != len(args):
37
+ return f"{_py_to_ts(non_none[0])} | null"
38
+ return " | ".join(_py_to_ts(arg) for arg in args) if args else "any"
39
+
40
+ if origin is list:
41
+ item = _py_to_ts(args[0]) if args else "any"
42
+ return f"{item}[]"
43
+
44
+ if origin is dict:
45
+ if len(args) == 2 and args[0] is str:
46
+ return f"Record<string, {_py_to_ts(args[1])}>"
47
+ return "Record<string, any>"
48
+
49
+ return "any"
50
+
51
+
52
+ def first_doc_line(value: object) -> str | None:
53
+ """Return the first non-empty docstring line, or *None*."""
54
+ doc = inspect.getdoc(value)
55
+ if not doc:
56
+ return None
57
+ line = doc.splitlines()[0].strip()
58
+ return line or None
59
+
60
+
61
+ def _fn_stub(name: str, fn: Callable[..., Any], indent: str) -> list[str]:
62
+ lines: list[str] = []
63
+
64
+ doc_line = first_doc_line(fn)
65
+ if doc_line:
66
+ # Ensure trailing period for JSDoc consistency.
67
+ if not doc_line.endswith("."):
68
+ doc_line += "."
69
+ escaped = doc_line.replace("*/", "* /")
70
+ lines.append(f"{indent}/** {escaped} */")
71
+
72
+ try:
73
+ sig = inspect.signature(fn, eval_str=True)
74
+ except (TypeError, ValueError):
75
+ lines.append(f"{indent}{name}(args: Record<string, any>): Promise<any>;")
76
+ return lines
77
+
78
+ ret = sig.return_annotation
79
+ # Top-level `-> None` maps to `void`, while `_py_to_ts` keeps `None` as
80
+ # `null` for union members (e.g. `str | None` -> `string | null`).
81
+ ret_ts = "void" if ret in (None, type(None)) else _py_to_ts(ret)
82
+
83
+ params = [
84
+ p
85
+ for p in sig.parameters.values()
86
+ if p.name != "self"
87
+ and p.kind
88
+ not in (
89
+ inspect.Parameter.VAR_POSITIONAL,
90
+ inspect.Parameter.VAR_KEYWORD,
91
+ )
92
+ ]
93
+ fields = ", ".join(
94
+ (
95
+ f"{param.name}"
96
+ f"{'?' if param.default is not inspect.Parameter.empty else ''}: "
97
+ f"{_py_to_ts(param.annotation)}"
98
+ )
99
+ for param in params
100
+ )
101
+ args_type = "{}" if not fields else f"{{ {fields} }}"
102
+
103
+ lines.append(f"{indent}{name}(args: {args_type}): Promise<{ret_ts}>;")
104
+ return lines
105
+
106
+
107
+ def generate_stubs(
108
+ capabilities: list[Capability],
109
+ flat_fns: dict[str, HostFn] | None = None,
110
+ ) -> str:
111
+ """Generate a TypeScript ``declare const codemode`` declaration block."""
112
+ lines = ["declare const codemode: {"]
113
+
114
+ for cap in capabilities:
115
+ if cap.description:
116
+ lines.append(f" /** {cap.description.replace('*/', '* /')} */")
117
+ lines.append(f" {cap.namespace}: {{")
118
+ exports = cap.exports()
119
+ for fn_name in sorted(exports):
120
+ lines.extend(_fn_stub(fn_name, exports[fn_name], indent=" "))
121
+ lines.append(" };")
122
+
123
+ for fn_name in sorted(flat_fns or {}):
124
+ lines.extend(_fn_stub(fn_name, (flat_fns or {})[fn_name], indent=" "))
125
+
126
+ lines.append("};")
127
+ return "\n".join(lines)