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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ agent-host-mcp = agent_mcp.server:main
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()