Repository navigation
Add kindex-remote: the graph over HTTPS behind Cloudflare Access - #85
Conversation
kindex-remote serves an allowlist of graph tools over streamable HTTP on loopback for a Cloudflare Tunnel to publish. Cloudflare Access authenticates callers (Managed OAuth or a service token); the server also verifies the Access JWT (signature, audience, issuer, expiry) on every request and refuses a foreign Host header. Reminders, ingestion, modes, maintenance, Kinbase and task_execute are not served; add accepts only URL referents and show does not resolve saved sources. Extra: kindex[remote]. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JLsrWfwExqFBAtkEz75Tpz
There was a problem hiding this comment.
Confidence Score: 4/5
Summary
Adds a loopback-only streamable-HTTP MCP behind Cloudflare Access with per-request RS256 JWT checks and a tool allowlist. Auth held up under sandbox probes; remaining gaps are hardening, not blockers.
Important Files Changed
| File | Overview |
|---|---|
| src/kindex/mcp_remote.py | New ASGI Access-JWT middleware, tool allowlist, add/show guards, loopback-only CLI |
| tests/test_mcp_remote.py | 12 passing tests; JWKS client stubbed so kid lookup path is unexercised |
| pyproject.toml | New remote extra and kindex-remote entry point |
| README.md | Remote setup and tool-surface docs |
| CHANGELOG.md | Unreleased entry for kindex-remote |
Reviewed cf91b58...f194dc6 · ↻ Re-run review · View in Adapt
| # Resolving saved sources opens other databases on this host. | ||
| return handler(node_id, resolve_sources=False) | ||
| return show | ||
| return handler |
There was a problem hiding this comment.
task_add/task_list project_path is unguarded: Path(...).resolve() reads host symlinks; task_add(project_path="/proc/self/cwd") stored and returned the server cwd. Refuse or drop it like add's file referents.
There was a problem hiding this comment.
Fixed in 354f1ef: task_add and task_list refuse a non-empty project_path remotely (test_task_tools_refuse_host_project_paths).
| ) | ||
| for name in REMOTE_TOOLS: | ||
| # Keep the full server's guard (errors, redaction, health) on each tool. | ||
| server.add_tool(_guard(name, getattr(mcp_server, name)), name=name) |
There was a problem hiding this comment.
FastMCP runs these sync handlers on the event loop, so ask(answer=True)/learn LLM calls and SQLite work stall every concurrent request; wrap handlers with anyio.to_thread.run_sync.
There was a problem hiding this comment.
Fixed in 354f1ef: every handler is wrapped async and runs on one dedicated worker thread via run_in_executor (context copied). One thread, not anyio's pool, because the shared SQLite connection must stay on the thread that opened it; the event loop stays free. Test: test_tool_handlers_run_off_the_event_loop; schemas still carry handler parameters (test_served_tool_schemas_keep_handler_parameters).
| parser.add_argument("--port", type=int, default=8767) | ||
| parser.add_argument("--profile", help="Kindex profile to serve (default: normal resolution)") | ||
| args = parser.parse_args() | ||
| if not ipaddress.ip_address(args.host).is_loopback: |
There was a problem hiding this comment.
--host localhost raises an uncaught ValueError traceback from ipaddress.ip_address; catch ValueError and route it through parser.error.
There was a problem hiding this comment.
Fixed in 354f1ef: ValueError routes through parser.error; the CLI test now covers localhost too.
| self.verifier = verifier | ||
|
|
||
| async def __call__(self, scope, receive, send): | ||
| if scope["type"] != "http": |
There was a problem hiding this comment.
Every non-HTTP scope (websocket included) bypasses the JWT check; pass through only scope type lifespan so a future websocket route cannot be served unauthenticated.
There was a problem hiding this comment.
Fixed in 354f1ef: only lifespan passes unchecked; websockets get close 1008 before reaching the app, other scope types are dropped (test_websockets_are_refused_before_the_app).
| scope = values.get("referent_scope") or ("url" if "://" in referent else "file") | ||
| if referent and scope != "url": | ||
| return "Error: remote add accepts only URL referents; file referents name this host's paths" | ||
| return handler(*args, **kwargs) |
There was a problem hiding this comment.
show still advertises resolve_sources in its schema and copied docstring while silently ignoring it; state the no-op in the description or drop the parameter.
There was a problem hiding this comment.
Fixed in 354f1ef: the remote show takes only node_id and its description says saved sources are listed but not resolved.
There was a problem hiding this comment.
I checked all five fixes at 354f1ef; they look correct. The 18 tests in tests/test_mcp_remote.py pass (I didn't rerun the full suite).
- project_path:
task_addandtask_listrefuse a non-emptyproject_path, so the/proc/self/cwdleak is closed. - Off-loop handlers: using one worker thread with the context copied is the right call for the shared SQLite connection. The tool schemas still come through via
__wrapped__. Tool calls now run one at a time, so a slowask(answer=True)will hold up other tool calls, but no longer the Access check. - --host:
localhostnow fails cleanly throughparser.errorwith "must be a loopback IP address" instead of a traceback. - Websockets: only lifespan passes through unchecked. Websockets get closed with code 1008, and any other scope type is dropped.
- show: the schema now takes only
node_id, and the description says saved sources aren't resolved remotely.
…ow schema - Tool handlers run on one dedicated worker thread, so LLM calls and SQLite work never stall the event loop; one thread keeps the shared connection on the thread that opened it. - task_add/task_list refuse project_path (it resolves host paths). - Only lifespan scopes pass the Access guard unchecked; websockets are closed. - Remote show has no resolve_sources parameter and says so. - --host localhost exits through parser.error instead of a traceback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JLsrWfwExqFBAtkEz75Tpz
… logs A live run against a 25k-node graph showed graph_stats held the single worker for minutes, stalling every other remote call; it is no longer served. On exit the store is closed on the worker thread that opened it, and per-request MCP INFO logging is off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JLsrWfwExqFBAtkEz75Tpz
FastMCP configures logging when mcp_server is imported, so the earlier basicConfig was a no-op and the audit line never reached the log. Logging is now set after the app is built: warnings plus one line per request naming the Access identity. README notes graph_stats and project_path limits, the serial worker, and the Managed OAuth requirement. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JLsrWfwExqFBAtkEz75Tpz
Serves the Kindex MCP to agents on other machines. The new server is published through a Cloudflare Tunnel and gated by Cloudflare Access.
Design
--hostis refused. The tunnel is the one way in.Cf-Access-Jwt-Assertionwith RS256 keys from the team's/cdn-cgi/access/certs.ask,suggest,graph_statsand the watch tools.task_execute.addrefuses file referents, since they would hash host paths.shownever resolves saved sources, since that opens other databases on the host.kindex[remote], which addspyjwt[crypto].Tests
tests/test_mcp_remote.pyhas 12 tests:addandshowguards hold.Mutation check: letting every request through fails all six refusal cases. The full suite passes: 3897 passed.
🤖 Generated with Claude Code
https://claude.ai/code/session_01JLsrWfwExqFBAtkEz75Tpz
Need help on this PR? Tag
@codesmith-botwith what you need.