agent-host-mcp 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_host_mcp-0.1.0.dist-info/METADATA +253 -0
- agent_host_mcp-0.1.0.dist-info/RECORD +35 -0
- agent_host_mcp-0.1.0.dist-info/WHEEL +4 -0
- agent_host_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- agent_mcp/__init__.py +1 -0
- agent_mcp/_build_info.json +1 -0
- agent_mcp/actions.py +194 -0
- agent_mcp/auth.py +84 -0
- agent_mcp/catalog.py +47 -0
- agent_mcp/cell_worker.py +107 -0
- agent_mcp/database.py +33 -0
- agent_mcp/execution.py +93 -0
- agent_mcp/knowledge.py +154 -0
- agent_mcp/knowledge_sdk.py +133 -0
- agent_mcp/knowledge_store.py +142 -0
- agent_mcp/landing.html +95 -0
- agent_mcp/models.py +53 -0
- agent_mcp/openwiki_export.mjs +5 -0
- agent_mcp/openwiki_retrieve.mjs +10 -0
- agent_mcp/paths.py +23 -0
- agent_mcp/runner.py +111 -0
- agent_mcp/scheduler.py +125 -0
- agent_mcp/server.py +496 -0
- agent_mcp/session_delete.py +76 -0
- agent_mcp/session_metadata.py +54 -0
- agent_mcp/sessions.py +158 -0
- agent_mcp/skills/upload-files/SKILL.md +82 -0
- agent_mcp/tokens.py +120 -0
- agent_mcp/upload_routes.py +71 -0
- agent_mcp/upload_skill.py +21 -0
- agent_mcp/uploads.py +98 -0
- agent_mcp/version.py +39 -0
- agent_mcp/wiki.py +144 -0
- agent_mcp/wiki_routes.py +101 -0
- agent_mcp/wiki_tools.py +165 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: agent-host-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Connect Codex sessions, remote execution, and project knowledge to MCP clients.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: mcp<2,>=1.12
|
|
7
|
+
Requires-Dist: openai-codex>=0.167.0
|
|
8
|
+
Requires-Dist: python-dotenv<2,>=1
|
|
9
|
+
Requires-Dist: python-multipart<1,>=0.0.33
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# Agent Host MCP
|
|
13
|
+
|
|
14
|
+
**Turn your agent on the host into an MCP server.**
|
|
15
|
+
|
|
16
|
+
Agent Host MCP lets your assistant work with the development environment on your
|
|
17
|
+
Linux machine. Read project files, execute Python or shell commands, ask a remote
|
|
18
|
+
Codex agent to carry out a task, and reuse knowledge from past sessions—all
|
|
19
|
+
through one MCP connection.
|
|
20
|
+
|
|
21
|
+
Use it when your code lives on a remote host and you want your assistant to
|
|
22
|
+
inspect that environment directly instead of copying files and terminal output
|
|
23
|
+
into a conversation.
|
|
24
|
+
|
|
25
|
+
## What you can do
|
|
26
|
+
|
|
27
|
+
- **Understand a project:** read contributor guidance, inspect files, and find
|
|
28
|
+
saved Codex sessions.
|
|
29
|
+
- **Get work done:** run notebook-style Python cells or shell scripts, or delegate
|
|
30
|
+
a task to Codex in a new or existing session.
|
|
31
|
+
- **Keep useful context:** build topic knowledge from sessions, then browse it in
|
|
32
|
+
OpenWiki or search it from your assistant.
|
|
33
|
+
- **Manage the host workflow:** transfer files, label sessions, and inspect
|
|
34
|
+
recorded token usage.
|
|
35
|
+
|
|
36
|
+
## Quick start
|
|
37
|
+
|
|
38
|
+
### 1. Start Agent Host MCP on your host
|
|
39
|
+
|
|
40
|
+
You need a **Linux host**, uv, make, Node.js **22.22.0 or newer**, and npm. Run the
|
|
41
|
+
server as the account whose Codex sessions you want to use, with that account’s
|
|
42
|
+
existing Codex login and configuration. Set `CODEX_HOME` if they live outside
|
|
43
|
+
`~/.codex`. A separate API key is not required by Agent Host MCP.
|
|
44
|
+
|
|
45
|
+
From a source checkout or an unpacked source release:
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
make build
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Create a `.env` file in the directory from which you will start the server:
|
|
52
|
+
|
|
53
|
+
```dotenv
|
|
54
|
+
MCP_TOKEN=replace-with-a-long-random-secret
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Replace the placeholder with a long, randomly generated token. Restrict access:
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
chmod 600 .env
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Keep an existing `.env` when upgrading. Keep the token private and provide the
|
|
64
|
+
same value to your client.
|
|
65
|
+
|
|
66
|
+
Start the server:
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
make run HOST=0.0.0.0 PORT=7081
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The CLI is **`agent-host-mcp`**. After building, you can also start it directly:
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
./build/agent-host-mcp --host 0.0.0.0 --port 7081
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Use the source-release setup above for all features, including OpenWiki.
|
|
79
|
+
Installing the Python wheel alone does not set up the knowledge viewer.
|
|
80
|
+
|
|
81
|
+
### 2. Add it to your Codex client
|
|
82
|
+
|
|
83
|
+
On the machine where you use Codex, set `MCP_TOKEN` to your server’s token. For
|
|
84
|
+
example, in Bash you can enter it without placing its value in shell history:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
read -rsp 'Agent Host MCP token: ' MCP_TOKEN
|
|
88
|
+
export MCP_TOKEN
|
|
89
|
+
printf '\n'
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Register the server, replacing `https://YOUR_HOST` with your server’s HTTPS URL:
|
|
93
|
+
|
|
94
|
+
```sh
|
|
95
|
+
codex mcp add remotecodex \
|
|
96
|
+
--url https://YOUR_HOST/mcp \
|
|
97
|
+
--bearer-token-env-var MCP_TOKEN
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Start your Codex client from an environment where `MCP_TOKEN` is available.
|
|
101
|
+
Other MCP clients can use **Streamable HTTP** at `/mcp` with
|
|
102
|
+
`Authorization: Bearer <MCP_TOKEN>`.
|
|
103
|
+
|
|
104
|
+
### 3. Ask your assistant to use it
|
|
105
|
+
|
|
106
|
+
Try requests such as:
|
|
107
|
+
|
|
108
|
+
> Use remotecodex to read AGENTS.md in my project directory and summarize its guidance.
|
|
109
|
+
>
|
|
110
|
+
> Run a Python cell that calculates 6 * 7 on the remote host.
|
|
111
|
+
>
|
|
112
|
+
> Start a new Codex session in my project and ask it to investigate the failing tests.
|
|
113
|
+
>
|
|
114
|
+
> Search my saved knowledge for deployment hints.
|
|
115
|
+
|
|
116
|
+
Replace “my project directory” with the actual host path. The client discovers
|
|
117
|
+
available tools and their parameters through `tools/list`.
|
|
118
|
+
|
|
119
|
+
## Choose the right execution tool
|
|
120
|
+
|
|
121
|
+
| Task | Tool | What to expect |
|
|
122
|
+
| --- | --- | --- |
|
|
123
|
+
| Run code directly | `execute(string, path="~")` | A persistent Python namespace per directory. Variables, imports and functions carry over between cells. |
|
|
124
|
+
| Run a shell script | `execute` with a cell beginning with `!` or `%` | The whole remaining cell runs through `/bin/sh` in the chosen directory. |
|
|
125
|
+
| Delegate work to Codex | `run(path, prompt, session_id=None)` | Omit the ID for a new saved session; supply one to resume that exact session. |
|
|
126
|
+
|
|
127
|
+
`execute` defaults to the server account’s home directory. `run` requires an
|
|
128
|
+
existing working directory. Use `create_path` to create an absolute directory
|
|
129
|
+
and its missing parents first.
|
|
130
|
+
|
|
131
|
+
`execute` uses the server’s installed Python packages and normal account
|
|
132
|
+
permissions; it is not a security sandbox. Python variables last until the runtime
|
|
133
|
+
or server restarts. Shell environment/directory changes do not carry over to later
|
|
134
|
+
cells. `run` uses your saved Codex login and configured defaults for new sessions.
|
|
135
|
+
A saved session must be closed in its current Codex client before resuming it here.
|
|
136
|
+
|
|
137
|
+
### Collect a long-running result
|
|
138
|
+
|
|
139
|
+
`run`, `execute`, `build_knowledge`, and `delete_session` return an **`action_id`**
|
|
140
|
+
immediately. Save it, then repeat the same tool and original arguments with that
|
|
141
|
+
ID until its status becomes `completed`, `failed`, or `interrupted`.
|
|
142
|
+
|
|
143
|
+
For example, start a cell:
|
|
144
|
+
|
|
145
|
+
```json
|
|
146
|
+
{"string":"6 * 7"}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Then call `execute` again with the returned ID:
|
|
150
|
+
|
|
151
|
+
```json
|
|
152
|
+
{"string":"6 * 7","action_id":"returned-action-id","wait_seconds":20}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Omitting the ID starts new work. Reconnecting never reruns the original task.
|
|
156
|
+
Keep an omitted `path` or `session_id` omitted when reconnecting. Inspect the
|
|
157
|
+
nested `result.status` too: a completed action may contain a failed cell or turn.
|
|
158
|
+
|
|
159
|
+
A gateway timeout or disconnected client leaves the job running. Each reconnect
|
|
160
|
+
waits at most 20 seconds. SSE carries progress and the final result, rather than
|
|
161
|
+
live stdout or model tokens. Server shutdown interrupts active jobs; they are
|
|
162
|
+
not automatically rerun. Completed results are retained for up to 24 hours,
|
|
163
|
+
subject to a 256-action capacity.
|
|
164
|
+
|
|
165
|
+
## Build and use your knowledge
|
|
166
|
+
|
|
167
|
+
Ask your assistant to call `list_models`, choose a supported model and depth,
|
|
168
|
+
then call `build_knowledge`. Supply `session_id` to target one session, or omit it
|
|
169
|
+
for an incremental batch. Unchanged, verified context is reused.
|
|
170
|
+
|
|
171
|
+
Browse the results at **`/knowledge`** and sign in with your MCP token. The
|
|
172
|
+
OpenWiki page and `wiki_list_pages`, `wiki_search`, and `wiki_read` tools use the
|
|
173
|
+
same saved knowledge. Viewing or searching it does not call a model. Browser
|
|
174
|
+
visualization needs access to OpenWiki’s pinned external library/font CDNs.
|
|
175
|
+
|
|
176
|
+
While the server runs, it also checks for new or changed session context every
|
|
177
|
+
30 minutes. When idle, it runs a daily maintenance pass that still skips unchanged
|
|
178
|
+
context. Builds use your Codex login and can make model calls.
|
|
179
|
+
|
|
180
|
+
**Session retirement:** knowledge builds may delete sessions whose working
|
|
181
|
+
directories have been removed, after saving and verifying their knowledge.
|
|
182
|
+
Sessions with existing working directories are preserved by automatic builds.
|
|
183
|
+
`delete_session` explicitly deletes a selected session, including one whose
|
|
184
|
+
working directory exists, only after its current knowledge is verified. Active or changed sessions, and sessions with remaining descendants, are kept.
|
|
185
|
+
|
|
186
|
+
## Available tools
|
|
187
|
+
|
|
188
|
+
The server exposes thirteen tools. See the [detailed reference](REFERENCE.md)
|
|
189
|
+
for schemas, limits, outputs and failure handling.
|
|
190
|
+
|
|
191
|
+
| Tool | Use it to |
|
|
192
|
+
| --- | --- |
|
|
193
|
+
| `list_sessions` | Find saved session IDs, models, depths, working directories and descriptions. |
|
|
194
|
+
| `update_session` | Set a client-managed description; blank text clears it. |
|
|
195
|
+
| `list_models` | Discover supported models and reasoning depths. |
|
|
196
|
+
| `create_path` | Create an absolute host directory and missing parents. |
|
|
197
|
+
| `execute` | Execute Python cells or prefixed shell scripts. |
|
|
198
|
+
| `run` | Start or resume a Codex session and submit a prompt. |
|
|
199
|
+
| `build_knowledge` | Extract reusable knowledge from changed or selected sessions; may retire missing-directory sessions. |
|
|
200
|
+
| `delete_session` | Delete a session only after its current knowledge is saved and verified. |
|
|
201
|
+
| `token_count` | Inspect recorded host/session usage and cache-hit ratios; retired usage appears in `others`. |
|
|
202
|
+
| `get_upload_skill` | Get instructions for authenticated HTTP file uploads. |
|
|
203
|
+
| `wiki_list_pages` | Browse knowledge topic pages with pagination. |
|
|
204
|
+
| `wiki_search` | Find relevant knowledge sections. |
|
|
205
|
+
| `wiki_read` | Read selected sections and their source provenance. |
|
|
206
|
+
|
|
207
|
+
To transfer a local file, read `get_upload_skill` first. Uploads use a separate
|
|
208
|
+
HTTP `POST /upload` with the same Bearer token, the actual file bytes, and a
|
|
209
|
+
client-selected absolute destination directory. Missing parents are created;
|
|
210
|
+
existing files are never overwritten. The file limit is 100 MiB. Native MCP auth
|
|
211
|
+
does not automatically expose the credential to an agent’s separate HTTP client.
|
|
212
|
+
|
|
213
|
+
Token usage is recorded session usage, not an exact billing report. Cached and
|
|
214
|
+
reasoning counters are subsets, and `cache_hit_ratio` is cached input divided by
|
|
215
|
+
input; unknown coverage is reported.
|
|
216
|
+
|
|
217
|
+
## Access and upgrades
|
|
218
|
+
|
|
219
|
+
The landing page at `/` is public. MCP calls, uploads and knowledge content
|
|
220
|
+
require authentication. Signing in to the knowledge browser does not authorize
|
|
221
|
+
MCP calls or uploads. Connect only clients you trust with the host account’s access.
|
|
222
|
+
|
|
223
|
+
The server reads `.env` from its working directory; an exported `MCP_TOKEN`
|
|
224
|
+
overrides the file. Restart the server after changing its token.
|
|
225
|
+
|
|
226
|
+
Keep your `.env`, Codex login and saved sessions when upgrading. Extracted
|
|
227
|
+
knowledge is saved under `~/.agent-mcp/knowledge`; preserve it and the other
|
|
228
|
+
Agent Host MCP data described in the [backup reference](REFERENCE.md#long-running-tool-actions-and-sse).
|
|
229
|
+
Stop the previous service before starting its replacement on the same port,
|
|
230
|
+
then refresh your client’s MCP connection to discover updated tools.
|
|
231
|
+
|
|
232
|
+
## Check your setup
|
|
233
|
+
|
|
234
|
+
With the server running and curl installed:
|
|
235
|
+
|
|
236
|
+
```sh
|
|
237
|
+
make test PORT=7081
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
This checks the advertised tools, session/model discovery, token accounting and
|
|
241
|
+
wiki retrieval. It does not submit model turns or delete real sessions. It prints
|
|
242
|
+
session and model results, so keep that output private.
|
|
243
|
+
|
|
244
|
+
| Symptom | What to check |
|
|
245
|
+
| --- | --- |
|
|
246
|
+
| Unauthorized or startup token error | Match the server’s token and the client’s `MCP_TOKEN` environment variable. |
|
|
247
|
+
| Port 7081 is already in use | Stop the intended previous process yourself, or choose another `PORT`. |
|
|
248
|
+
| Session has an active writer | Close its owning Codex session normally before resuming it, or omit the ID to create a separate session. |
|
|
249
|
+
| OpenWiki unavailable | Run `make build` from the source directory and check Node dependencies and browser CDN access. |
|
|
250
|
+
| Client still shows old tools | Refresh/reconnect the client after restarting the updated server; a new conversation alone may not refresh its cache. |
|
|
251
|
+
| Public gateway cannot be reached | Check gateway routing and any IP allowlist separately from the local server. |
|
|
252
|
+
|
|
253
|
+
For detailed usage, limits and troubleshooting, see [REFERENCE.md](REFERENCE.md).
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
agent_mcp/__init__.py,sha256=5zEC3WYJbthOmZ0gNVxhZCALiSoQ4w8gGsjwahMRBb0,37
|
|
2
|
+
agent_mcp/actions.py,sha256=R3Z94Q_9aV0ZU1WIW_wLKjDYRkjlFzIkQOjiYrdnetk,9581
|
|
3
|
+
agent_mcp/auth.py,sha256=DVmlguPHdcxj_XAG8E3AXx4atsrXnw0TxZ0nn88JLAg,3568
|
|
4
|
+
agent_mcp/catalog.py,sha256=EmoTDCWwYeLjZXBcgIuOkyOsx0PgXTDuYvp0wLuxVLY,1710
|
|
5
|
+
agent_mcp/cell_worker.py,sha256=tEs611aJc8Ue3dw1yzK4tbiRe11nFHUHM3X5oJ0g9OA,4642
|
|
6
|
+
agent_mcp/database.py,sha256=Vq2vU8T9zMGOHuEuBejZJ89WpFYhSJJwfYFK8jmbGeU,1143
|
|
7
|
+
agent_mcp/execution.py,sha256=4BWErvCPBEmYxitKzHfDhhnx1ltpR9c2uhuoQwgKjxs,3566
|
|
8
|
+
agent_mcp/knowledge.py,sha256=Ig-xHkP7Pp6jYy5338RMdj84Ib0KbN_jhYD5MshKjOc,6905
|
|
9
|
+
agent_mcp/knowledge_sdk.py,sha256=kFUydoC24yca2WxcIfLkbTQbmtLx66Zs5K4kfTvVX3Y,6755
|
|
10
|
+
agent_mcp/knowledge_store.py,sha256=zPSagOmhk5NULub5w2qlGYud8adaluAVCYottnF6agU,5809
|
|
11
|
+
agent_mcp/landing.html,sha256=VRvXag-_wdpXtA92dsGz8ucZJWHFvmGWQKlMNyUUPGk,6461
|
|
12
|
+
agent_mcp/models.py,sha256=F-L-8NcywC9IYfSd1JFtvyCn09I4c2hvLh8syJKvmW8,2197
|
|
13
|
+
agent_mcp/openwiki_export.mjs,sha256=bBqznHVYTu2xN7fz1fi7veR3RP9URqznSMwhWxHwnv8,326
|
|
14
|
+
agent_mcp/openwiki_retrieve.mjs,sha256=CzQSfp9fg3mWUGyrTbNG_p_YoDOy_cOavZlDvchE7NI,520
|
|
15
|
+
agent_mcp/paths.py,sha256=7MaPSTdBtZJX6mvrL1kPoMn4yk3e6KL3ucp-0cAl0PA,885
|
|
16
|
+
agent_mcp/runner.py,sha256=9aDXXbNjsePWUXCnsD-mxuEpTkMUnsMKYAj1QR0kP0A,6036
|
|
17
|
+
agent_mcp/scheduler.py,sha256=-x3NdFsEDuziEYE9LA10QPK30uCMI3vpgQLLJKr3Gvw,5542
|
|
18
|
+
agent_mcp/server.py,sha256=OqW2fdRv699CRXp8FZ4D1waRkN89an3N0MGr6Nr-PMw,33619
|
|
19
|
+
agent_mcp/session_delete.py,sha256=zWq0GamBH7_9JV9tOtmDDYUzmhO3IUJnKwPKn0UxUUA,3996
|
|
20
|
+
agent_mcp/session_metadata.py,sha256=mTl7FrLpoBGrXGGYs7VYECkeJJ0iRnlyKyrDXMt8q-o,2488
|
|
21
|
+
agent_mcp/sessions.py,sha256=OQs-5Ru11BzfUeM6bAe_w0T7FnVmeXTDrmWk36ER3CY,5697
|
|
22
|
+
agent_mcp/tokens.py,sha256=3hu2y6GxXqIFz9bpBpI9161TXaD8ZXe6tkqtTN2Hr2E,5046
|
|
23
|
+
agent_mcp/upload_routes.py,sha256=etOOLTttYhdQmSrQ5UWbt3wtS5o5vWnVWp-rK8Y4XOs,3730
|
|
24
|
+
agent_mcp/upload_skill.py,sha256=X0q9hBu-e4TL9LJ9p2BXYWxET7pSo8nsjonIPU2k8nk,626
|
|
25
|
+
agent_mcp/uploads.py,sha256=bT7Gy14HkoSM2MBKBxFBI_ybF0NTqM5uekKvLJqd3yg,4613
|
|
26
|
+
agent_mcp/version.py,sha256=O094-OhpGIG3kvVcIGeUvgAg43V1dbC2wVHqDe9EZ7I,1368
|
|
27
|
+
agent_mcp/wiki.py,sha256=aD1t6B4aNZeDHQptZADbN8-UC4rN9O-_brapNa81x1M,7219
|
|
28
|
+
agent_mcp/wiki_routes.py,sha256=YnQ6AsqS1fPU78Q-wUAs0nF2JjesQVt4zKKVdfApHgw,5281
|
|
29
|
+
agent_mcp/wiki_tools.py,sha256=tMCIradQTIitcH9YwH9cts3jyGUVh7JLi7CYl9sKlPg,8127
|
|
30
|
+
agent_mcp/skills/upload-files/SKILL.md,sha256=qgIdGLXu4De1TFrTPxeqSxpoFZwQdGRllOZ07Fskuvg,4126
|
|
31
|
+
agent_mcp/_build_info.json,sha256=hC-0V4vdrGPhn0Q9dB4ma8V7MCcFtXzd8Qx9wgIj61E,150
|
|
32
|
+
agent_host_mcp-0.1.0.dist-info/METADATA,sha256=Ac06AzPr9-05xnXHWEMpwoVOlejO0gDCIpIjYPUAWgM,10949
|
|
33
|
+
agent_host_mcp-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
34
|
+
agent_host_mcp-0.1.0.dist-info/entry_points.txt,sha256=xibB2opuL4fZqvVAZbXrldr0PkorHXdU5LG0ufzIzTs,57
|
|
35
|
+
agent_host_mcp-0.1.0.dist-info/RECORD,,
|
agent_mcp/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Local Codex session discovery."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"base_version": "0.1.0", "version": "0.1.0.20261111103023100757", "build_id": "20261111103023100757", "built_at": "2026-11-11T10:30:23.100757+00:00"}
|
agent_mcp/actions.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Durable action handles; jobs belong to the server, never an HTTP request."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import Awaitable, Callable
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any, Literal
|
|
12
|
+
from uuid import uuid4
|
|
13
|
+
|
|
14
|
+
from mcp.server.fastmcp.exceptions import ToolError
|
|
15
|
+
from pydantic import BaseModel
|
|
16
|
+
|
|
17
|
+
from agent_mcp.database import connect, database_path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ActionResult(BaseModel):
|
|
21
|
+
action_id: str
|
|
22
|
+
tool: str
|
|
23
|
+
status: Literal["running", "completed", "failed", "interrupted"]
|
|
24
|
+
result: dict[str, Any] | None = None
|
|
25
|
+
error: str | None = None
|
|
26
|
+
error_code: str | None = None
|
|
27
|
+
error_stage: str | None = None
|
|
28
|
+
retry_after_seconds: int | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _process_identity(pid: int) -> str | None:
|
|
32
|
+
try:
|
|
33
|
+
# Include Linux process start time to guard against PID reuse.
|
|
34
|
+
stat = Path(f"/proc/{pid}/stat").read_text().rsplit(")", 1)[1].split()
|
|
35
|
+
boot = Path("/proc/sys/kernel/random/boot_id").read_text().strip()
|
|
36
|
+
return f"{boot}:{pid}:{stat[19]}"
|
|
37
|
+
except (OSError, IndexError):
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _owner_alive(owner: str) -> bool:
|
|
42
|
+
try:
|
|
43
|
+
return _process_identity(int(owner.split(":")[1])) == owner
|
|
44
|
+
except (IndexError, ValueError):
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Actions:
|
|
49
|
+
def __init__(self, path: Path | None = None, *, capacity: int = 256, retention: float = 86400, heartbeat: float = 2):
|
|
50
|
+
self.path = path if path is not None else database_path()
|
|
51
|
+
self.capacity, self.retention, self.heartbeat = capacity, retention, heartbeat
|
|
52
|
+
self.owner = _process_identity(os.getpid())
|
|
53
|
+
self._jobs: dict[str, asyncio.Task] = {}
|
|
54
|
+
self._closed = False
|
|
55
|
+
self._pending_saves: dict[str, ActionResult] = {}
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def _fingerprint(tool: str, arguments: dict) -> str:
|
|
59
|
+
return hashlib.sha256(json.dumps([tool, arguments], sort_keys=True).encode()).hexdigest()
|
|
60
|
+
|
|
61
|
+
def _database(self):
|
|
62
|
+
return connect(self.path)
|
|
63
|
+
|
|
64
|
+
def _prepare(self, db):
|
|
65
|
+
db.execute("""CREATE TABLE IF NOT EXISTS actions (
|
|
66
|
+
id TEXT PRIMARY KEY, tool TEXT NOT NULL, fingerprint TEXT NOT NULL,
|
|
67
|
+
owner TEXT NOT NULL, response TEXT NOT NULL, finished REAL)""")
|
|
68
|
+
db.execute("DELETE FROM actions WHERE finished IS NOT NULL AND finished <= ?", (time.time() - self.retention,))
|
|
69
|
+
for row in db.execute("SELECT id, response, owner FROM actions WHERE finished IS NULL").fetchall():
|
|
70
|
+
if not _owner_alive(row["owner"]):
|
|
71
|
+
response = ActionResult.model_validate_json(row["response"])
|
|
72
|
+
response.status = "interrupted"
|
|
73
|
+
response.retry_after_seconds = None
|
|
74
|
+
response.error = "Server process stopped; inspect session state before submitting a new action. This action was not rerun."
|
|
75
|
+
db.execute("UPDATE actions SET response=?, finished=? WHERE id=?",
|
|
76
|
+
(response.model_dump_json(), time.time(), row["id"]))
|
|
77
|
+
|
|
78
|
+
def start(self, tool: str, arguments: dict, work: Callable[[], Awaitable[BaseModel]]) -> ActionResult:
|
|
79
|
+
if self._closed:
|
|
80
|
+
raise ToolError("The server is shutting down; no new action was started.")
|
|
81
|
+
self._flush_pending_saves()
|
|
82
|
+
response = ActionResult(action_id=uuid4().hex, tool=tool, status="running", retry_after_seconds=2)
|
|
83
|
+
with self._database() as db:
|
|
84
|
+
db.execute("BEGIN IMMEDIATE")
|
|
85
|
+
self._prepare(db)
|
|
86
|
+
if db.execute("SELECT count(*) FROM actions").fetchone()[0] >= self.capacity:
|
|
87
|
+
oldest = db.execute("SELECT id FROM actions WHERE finished IS NOT NULL ORDER BY finished LIMIT 1").fetchone()
|
|
88
|
+
if oldest is None:
|
|
89
|
+
raise ToolError("The server has too many active actions; retry later.")
|
|
90
|
+
db.execute("DELETE FROM actions WHERE id=?", (oldest["id"],))
|
|
91
|
+
db.execute("INSERT INTO actions VALUES (?,?,?,?,?,NULL)", (response.action_id, tool,
|
|
92
|
+
self._fingerprint(tool, arguments), self.owner, response.model_dump_json()))
|
|
93
|
+
self._jobs[response.action_id] = asyncio.create_task(self._execute(response, work),
|
|
94
|
+
name="agent-mcp-action-" + response.action_id)
|
|
95
|
+
return response.model_copy(deep=True)
|
|
96
|
+
|
|
97
|
+
def _save(self, response):
|
|
98
|
+
with self._database() as db:
|
|
99
|
+
db.execute("UPDATE actions SET response=?, finished=? WHERE id=?",
|
|
100
|
+
(response.model_dump_json(), time.time(), response.action_id))
|
|
101
|
+
|
|
102
|
+
def _flush_pending_saves(self):
|
|
103
|
+
# A temporary database outage must not leave a finished job permanently
|
|
104
|
+
# running. Keep its terminal response until a later call can commit it.
|
|
105
|
+
# New work is refused while these commits fail, bounding retained results
|
|
106
|
+
# by the existing action capacity and avoiding duplicate side effects.
|
|
107
|
+
for action_id, response in list(self._pending_saves.items()):
|
|
108
|
+
self._save(response)
|
|
109
|
+
del self._pending_saves[action_id]
|
|
110
|
+
|
|
111
|
+
async def _execute(self, response, work):
|
|
112
|
+
try:
|
|
113
|
+
response.result = (await work()).model_dump(mode="json")
|
|
114
|
+
response.status = "completed"
|
|
115
|
+
except asyncio.CancelledError:
|
|
116
|
+
response.status = "interrupted"
|
|
117
|
+
response.error = "Server shutdown interrupted this action; inspect session state before retrying."
|
|
118
|
+
raise
|
|
119
|
+
except ToolError as error:
|
|
120
|
+
response.status = "failed"
|
|
121
|
+
response.error = str(error)
|
|
122
|
+
response.error_code = getattr(error, "reason", None)
|
|
123
|
+
response.error_stage = getattr(error, "stage", None)
|
|
124
|
+
except Exception:
|
|
125
|
+
response.status = "failed"
|
|
126
|
+
response.error = "Action failed; private backend diagnostics were withheld."
|
|
127
|
+
finally:
|
|
128
|
+
response.retry_after_seconds = None
|
|
129
|
+
try:
|
|
130
|
+
self._save(response)
|
|
131
|
+
except ToolError:
|
|
132
|
+
self._pending_saves[response.action_id] = response
|
|
133
|
+
logging.getLogger(__name__).warning(
|
|
134
|
+
"Action result persistence failed; terminal response retained in memory for retry. No work will be rerun.")
|
|
135
|
+
finally:
|
|
136
|
+
self._jobs.pop(response.action_id, None)
|
|
137
|
+
|
|
138
|
+
def _lookup(self, action_id: str, tool: str, arguments: dict) -> ActionResult:
|
|
139
|
+
self._flush_pending_saves()
|
|
140
|
+
with self._database() as db:
|
|
141
|
+
self._prepare(db)
|
|
142
|
+
row = db.execute("SELECT fingerprint, response FROM actions WHERE id=?", (action_id,)).fetchone()
|
|
143
|
+
if row is None:
|
|
144
|
+
raise ToolError("Unknown or expired action_id. No task was started; inspect session state before submitting a new action.")
|
|
145
|
+
if row["fingerprint"] != self._fingerprint(tool, arguments):
|
|
146
|
+
raise ToolError("action_id belongs to a different tool or arguments. Repeat the original arguments exactly; no task was started.")
|
|
147
|
+
return ActionResult.model_validate_json(row["response"])
|
|
148
|
+
|
|
149
|
+
async def reconnect(self, action_id: str, tool: str, arguments: dict, *, wait_seconds: float = 20, progress=None) -> ActionResult:
|
|
150
|
+
if not 0 <= wait_seconds <= 20:
|
|
151
|
+
raise ToolError("wait_seconds must be between 0 and 20.")
|
|
152
|
+
response = self._lookup(action_id, tool, arguments)
|
|
153
|
+
deadline = time.monotonic() + wait_seconds
|
|
154
|
+
tick = 0
|
|
155
|
+
while response.status == "running" and time.monotonic() < deadline:
|
|
156
|
+
if progress is not None:
|
|
157
|
+
await progress(tick, message=f"Action {action_id} is running; reconnect with the same ID if disconnected.")
|
|
158
|
+
remaining = max(0, deadline - time.monotonic())
|
|
159
|
+
task = self._jobs.get(action_id)
|
|
160
|
+
if task is not None:
|
|
161
|
+
await asyncio.wait({task}, timeout=min(self.heartbeat, remaining))
|
|
162
|
+
else:
|
|
163
|
+
await asyncio.sleep(min(self.heartbeat, remaining))
|
|
164
|
+
tick += 1
|
|
165
|
+
response = self._lookup(action_id, tool, arguments)
|
|
166
|
+
return response
|
|
167
|
+
|
|
168
|
+
async def result(self, action_id: str, tool: str, arguments: dict) -> dict:
|
|
169
|
+
"""Internal scheduler/delete joins the same registered tool action."""
|
|
170
|
+
while True:
|
|
171
|
+
response = await self.reconnect(action_id, tool, arguments)
|
|
172
|
+
if response.status == "completed":
|
|
173
|
+
return response.result
|
|
174
|
+
if response.status != "running":
|
|
175
|
+
raise ToolError(response.error or "Action did not complete.")
|
|
176
|
+
|
|
177
|
+
async def close(self):
|
|
178
|
+
self._closed = True
|
|
179
|
+
tasks = list(self._jobs.items())
|
|
180
|
+
for _, task in tasks:
|
|
181
|
+
task.cancel()
|
|
182
|
+
await asyncio.gather(*(task for _, task in tasks), return_exceptions=True)
|
|
183
|
+
self._flush_pending_saves()
|
|
184
|
+
# A task cancelled before entering its coroutine cannot execute its finally.
|
|
185
|
+
with self._database() as db:
|
|
186
|
+
self._prepare(db)
|
|
187
|
+
for action_id, _ in tasks:
|
|
188
|
+
row = db.execute("SELECT response FROM actions WHERE id=? AND finished IS NULL", (action_id,)).fetchone()
|
|
189
|
+
if row:
|
|
190
|
+
response = ActionResult.model_validate_json(row["response"])
|
|
191
|
+
response.status, response.retry_after_seconds = "interrupted", None
|
|
192
|
+
response.error = "Server shutdown interrupted this action; inspect session state before retrying."
|
|
193
|
+
db.execute("UPDATE actions SET response=?, finished=? WHERE id=?",
|
|
194
|
+
(response.model_dump_json(), time.time(), action_id))
|
agent_mcp/auth.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Static Bearer-token authentication for the local MCP HTTP server."""
|
|
2
|
+
|
|
3
|
+
import hmac
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
from dotenv import dotenv_values
|
|
9
|
+
from starlette.responses import JSONResponse
|
|
10
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load_mcp_token(env_file: Path | None = None) -> str:
|
|
14
|
+
"""An exported MCP_TOKEN overrides the current working directory's .env."""
|
|
15
|
+
token = os.environ.get("MCP_TOKEN")
|
|
16
|
+
if token is None:
|
|
17
|
+
token = dotenv_values(env_file or Path.cwd() / ".env", interpolate=False).get("MCP_TOKEN")
|
|
18
|
+
if not token or not re.fullmatch(r"[A-Za-z0-9._~+/-]+=*", token):
|
|
19
|
+
raise ValueError("Set a nonempty, valid MCP_TOKEN in .env or the environment before starting agent-host-mcp.")
|
|
20
|
+
return token
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BearerTokenMiddleware:
|
|
24
|
+
def __init__(self, app: ASGIApp, token: str):
|
|
25
|
+
self.app = app
|
|
26
|
+
self.token = token.encode("ascii")
|
|
27
|
+
|
|
28
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
29
|
+
path, method = scope.get("path"), scope.get("method")
|
|
30
|
+
if (scope["type"] != "http"
|
|
31
|
+
or (path in ("/", "/knowledge", "/knowledge/") and method in ("GET", "HEAD"))
|
|
32
|
+
or (path == "/knowledge/login" and method == "POST")):
|
|
33
|
+
await self.app(scope, receive, send)
|
|
34
|
+
return
|
|
35
|
+
if path and path.startswith("/knowledge/") and knowledge_authorized(scope, self.token.decode("ascii")):
|
|
36
|
+
await self.app(scope, receive, send)
|
|
37
|
+
return
|
|
38
|
+
headers = [value for name, value in scope.get("headers", []) if name.lower() == b"authorization"]
|
|
39
|
+
parts = headers[0].split() if len(headers) == 1 else []
|
|
40
|
+
if (len(parts) != 2 or parts[0].lower() != b"bearer"
|
|
41
|
+
or not hmac.compare_digest(parts[1], self.token)):
|
|
42
|
+
response = JSONResponse({"error": "Unauthorized"}, status_code=401,
|
|
43
|
+
headers={"WWW-Authenticate": 'Bearer realm="agent-host-mcp"'})
|
|
44
|
+
await response(scope, receive, send)
|
|
45
|
+
return
|
|
46
|
+
await self.app(scope, receive, send)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
KNOWLEDGE_COOKIE = "agent_mcp_knowledge"
|
|
50
|
+
KNOWLEDGE_SESSION_SECONDS = 8 * 60 * 60
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def token_matches(candidate: str, expected: str) -> bool:
|
|
54
|
+
return hmac.compare_digest(candidate.encode("utf-8"), expected.encode("utf-8"))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def knowledge_cookie(token: str) -> str:
|
|
58
|
+
import hashlib
|
|
59
|
+
import time
|
|
60
|
+
expires = str(int(time.time()) + KNOWLEDGE_SESSION_SECONDS)
|
|
61
|
+
signature = hmac.new(token.encode(), ("knowledge:" + expires).encode(), hashlib.sha256).hexdigest()
|
|
62
|
+
return expires + "." + signature
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def valid_knowledge_cookie(value: str, token: str) -> bool:
|
|
66
|
+
import hashlib
|
|
67
|
+
import time
|
|
68
|
+
parts = value.split(".")
|
|
69
|
+
if len(parts) != 2 or not re.fullmatch(r"[0-9]{1,12}", parts[0]):
|
|
70
|
+
return False
|
|
71
|
+
if int(parts[0]) <= time.time():
|
|
72
|
+
return False
|
|
73
|
+
signature = hmac.new(token.encode(), ("knowledge:" + parts[0]).encode(), hashlib.sha256).hexdigest()
|
|
74
|
+
return hmac.compare_digest(parts[1].encode(), signature.encode())
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def knowledge_authorized(scope: Scope, token: str) -> bool:
|
|
78
|
+
from starlette.requests import Request
|
|
79
|
+
headers = [value for key, value in scope.get("headers", []) if key.lower() == b"authorization"]
|
|
80
|
+
parts = headers[0].split() if len(headers) == 1 else []
|
|
81
|
+
if len(parts) == 2 and parts[0].lower() == b"bearer" and hmac.compare_digest(parts[1], token.encode()):
|
|
82
|
+
return True
|
|
83
|
+
cookie = Request(scope).cookies.get(KNOWLEDGE_COOKIE, "")
|
|
84
|
+
return valid_knowledge_cookie(cookie, token)
|
agent_mcp/catalog.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Export the registered MCP catalog without executing tools or starting a server."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from dataclasses import asdict
|
|
9
|
+
|
|
10
|
+
from agent_mcp.server import AuthenticatedMCP, create_server
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def describe_tools(server: AuthenticatedMCP) -> dict:
|
|
14
|
+
return {
|
|
15
|
+
"catalog_version": 1,
|
|
16
|
+
**asdict(server.build_info),
|
|
17
|
+
"server": server.name,
|
|
18
|
+
"endpoint": "/mcp",
|
|
19
|
+
"transport": "streamable-http",
|
|
20
|
+
"authentication": {"scheme": "bearer", "token_environment_variable": "MCP_TOKEN"},
|
|
21
|
+
"instructions": server.instructions,
|
|
22
|
+
"tools": [tool.model_dump(mode="json", exclude_none=True, by_alias=True) for tool in await server.list_tools()],
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def main():
|
|
27
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
28
|
+
parser.add_argument("output", type=Path, help="Destination catalog JSON file")
|
|
29
|
+
output = parser.parse_args().output
|
|
30
|
+
# Use a nonsecret auth placeholder so export never depends on deployment
|
|
31
|
+
# credentials. Constructing metadata does not open a listening server.
|
|
32
|
+
previous_token = os.environ.get("MCP_TOKEN")
|
|
33
|
+
os.environ["MCP_TOKEN"] = "catalog-export-only"
|
|
34
|
+
try:
|
|
35
|
+
catalog = asyncio.run(describe_tools(create_server()))
|
|
36
|
+
finally:
|
|
37
|
+
if previous_token is None:
|
|
38
|
+
os.environ.pop("MCP_TOKEN", None)
|
|
39
|
+
else:
|
|
40
|
+
os.environ["MCP_TOKEN"] = previous_token
|
|
41
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
output.write_text(json.dumps(catalog, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
43
|
+
print(f"Exported {len(catalog['tools'])} tool definitions to {output}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
if __name__ == "__main__":
|
|
47
|
+
main()
|