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.
- py_codemode-0.1.1/PKG-INFO +169 -0
- py_codemode-0.1.1/README.md +143 -0
- py_codemode-0.1.1/pyproject.toml +57 -0
- py_codemode-0.1.1/src/codemode/__init__.py +25 -0
- py_codemode-0.1.1/src/codemode/capabilities/__init__.py +5 -0
- py_codemode-0.1.1/src/codemode/capabilities/env.py +20 -0
- py_codemode-0.1.1/src/codemode/capabilities/fs.py +35 -0
- py_codemode-0.1.1/src/codemode/capabilities/http.py +49 -0
- py_codemode-0.1.1/src/codemode/executor.py +265 -0
- py_codemode-0.1.1/src/codemode/mcp/__init__.py +17 -0
- py_codemode-0.1.1/src/codemode/mcp/registry.py +141 -0
- py_codemode-0.1.1/src/codemode/mcp/server.py +139 -0
- py_codemode-0.1.1/src/codemode/py.typed +0 -0
- py_codemode-0.1.1/src/codemode/stubs.py +127 -0
|
@@ -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,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)
|