Skip to content

Add kindex-remote: the graph over HTTPS behind Cloudflare Access - #85

Merged
jmc-wander merged 4 commits into
mainfrom
feat/remote-mcp
Oct 15, 2026
Merged

jmc-wander merged 4 commits into
mainfrom
feat/remote-mcp

Conversation

@jmc-wander

@jmc-wander jmc-wander commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Serves the Kindex MCP to agents on other machines. The new server is published through a Cloudflare Tunnel and gated by Cloudflare Access.

Design

  • Listener: loopback only; a non-loopback --host is refused. The tunnel is the one way in.
  • Access JWT on every request: checked even though the edge already authenticates, using Cf-Access-Jwt-Assertion with RS256 keys from the team's /cdn-cgi/access/certs.
    • A missing assertion gets 401; a bad one (wrong audience or issuer, expired, wrong key, garbage) gets 403.
    • Signing keys are fetched off the event loop.
  • Host header: DNS-rebinding protection allows only the public host and the loopback addresses.
  • Tools: an explicit allowlist, the same one as kindex-lite plus ask, suggest, graph_stats and the watch tools.
    • Not served: reminders (they run shell actions), ingest, modes, maintenance, merges, Kinbase (a local executable) and task_execute.
    • add refuses file referents, since they would hash host paths.
    • show never resolves saved sources, since that opens other databases on the host.
  • Transport: stateless HTTP with JSON responses.
  • Packaging: a new extra, kindex[remote], which adds pyjwt[crypto].

Tests

tests/test_mcp_remote.py has 12 tests:

  • The served tool list equals the allowlist.
  • Six bad or missing assertions are each refused.
  • A foreign Host header is refused.
  • The add and show guards hold.
  • A non-loopback listener is refused.

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


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

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

@adaptcom adaptcom Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread src/kindex/mcp_remote.py
# Resolving saved sources opens other databases on this host.
return handler(node_id, resolve_sources=False)
return show
return handler

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 354f1ef: task_add and task_list refuse a non-empty project_path remotely (test_task_tools_refuse_host_project_paths).

Comment thread src/kindex/mcp_remote.py Outdated
)
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread src/kindex/mcp_remote.py Outdated
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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--host localhost raises an uncaught ValueError traceback from ipaddress.ip_address; catch ValueError and route it through parser.error.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 354f1ef: ValueError routes through parser.error; the CLI test now covers localhost too.

Comment thread src/kindex/mcp_remote.py Outdated
self.verifier = verifier

async def __call__(self, scope, receive, send):
if scope["type"] != "http":

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread src/kindex/mcp_remote.py
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 354f1ef: the remote show takes only node_id and its description says saved sources are listed but not resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_add and task_list refuse a non-empty project_path, so the /proc/self/cwd leak 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 slow ask(answer=True) will hold up other tool calls, but no longer the Access check.
  • --host: localhost now fails cleanly through parser.error with "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.

jmc-wander and others added 3 commits October 14, 2026 18:40
…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
@jmc-wander
jmc-wander merged commit 23490c5 into main Oct 15, 2026
1 check passed
@jmc-wander
jmc-wander deleted the feat/remote-mcp branch October 15, 2026 03:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant