Skip to content

feat: daemon speaks both HTTP and MCP transports#191

Open
Gradata wants to merge 1 commit into
mainfrom
feat/daemon-speaks-both-transports
Open

feat: daemon speaks both HTTP and MCP transports#191
Gradata wants to merge 1 commit into
mainfrom
feat/daemon-speaks-both-transports

Conversation

@Gradata
Copy link
Copy Markdown
Owner

@Gradata Gradata commented May 14, 2026

Summary

Eliminates the brain-flock contention between gradata.daemon (HTTP :8765) and gradata.mcp_server (stdio). Daemon stays the sole brain owner; mcp_server becomes a thin stdio↔HTTP bridge.

Refs #190

Design (Option A)

  • Daemon owns Brain + flock (unchanged)
  • Daemon exposes two new HTTP endpoints: GET /mcp/tools and POST /mcp/tool-call, wired into the existing mcp_server._dispatch() under daemon._brain_lock (no logic duplication)
  • Daemon auto-writes <brain>/.daemon.json on start() and deletes it on cleanup — stdio clients can discover the port without a pidfile flag
  • mcp_server.run_server gains an HTTP-bridge path (_DaemonClient). When a daemon is reachable, all tools/call are forwarded over HTTP and Brain is never instantiated and the flock is never acquired
  • Discovery order: $GRADATA_DAEMON_URL$GRADATA_DAEMON_PORT<brain>/.daemon.json → fallback 127.0.0.1:8765
  • New --no-daemon CLI flag forces legacy in-process mode (tests, debugging)

Compatibility

  • Existing Claude Code stdio config (python3 -m gradata.mcp_server --brain-dir ...) keeps working unchanged — it transparently bridges to the running daemon
  • Cursor / dashboard / curl against :8765 unchanged

Tests

New: tests/test_mcp_daemon_bridge.py — 5 tests covering discover-none, discover-via-advert, /mcp/tools endpoint, /mcp/tool-call endpoint, and the headline run_server bridge test asserting Brain is never instantiated when a daemon is reachable.
Updated: tests/test_mcp_server.py passes use_daemon=False in run_server(...) call sites so existing tests don't accidentally bridge to a live host daemon.

Known caveat

_DaemonClient.discover falls back to 127.0.0.1:8765 even when brain_dir doesn't strictly match the daemon's reported brain_dir. Intentional for single-host single-daemon use. Could tighten by cross-checking health.brain_dir before accepting — worth doing if multi-brain is a real use case.

Copy link
Copy Markdown

@greptile-apps greptile-apps Bot left a comment

Choose a reason for hiding this comment

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

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 14, 2026

Review Change Stack

📝 Walkthrough
  • Daemon adds HTTP bridge endpoints: New GET /mcp/tools and POST /mcp/tool-call endpoints enable remote MCP tool schema listing and dispatch under the daemon's brain lock
  • Daemon advertises itself: Writes .daemon.json to brain directory on startup; clients discover daemon via $GRADATA_DAEMON_URL$GRADATA_DAEMON_PORT.daemon.json → fallback 127.0.0.1:8765
  • mcp_server becomes a bridge: New _DaemonClient forwards tool requests to daemon over HTTP when available; avoids Brain instantiation and flock acquisition in bridge mode
  • API signature changes: run_server() now accepts daemon_client and use_daemon=True parameters; _handle_tools_call() now accepts daemon_client parameter
  • New CLI flag: --no-daemon disables bridge mode for tests/debugging, forcing legacy in-process behavior
  • Backward compatible: Existing stdio usage (e.g., python3 -m gradata.mcp_server --brain-dir) now transparently bridges to running daemon without code changes
  • Comprehensive test coverage: New test module test_mcp_daemon_bridge.py with 5 tests verifying discovery, endpoints, and bridge mode (validates Brain not instantiated when daemon reachable)
  • Existing tests updated: test_mcp_server.py calls updated to pass use_daemon=False to avoid accidental bridging to live daemons

Walkthrough

This PR implements an MCP daemon bridge feature allowing the MCP stdio server to forward tool calls to a running Gradata daemon via HTTP instead of spawning local brains. The daemon advertises itself, both stdio and daemon expose HTTP MCP endpoints, and comprehensive tests validate end-to-end bridging.

Changes

MCP daemon bridge

Layer / File(s) Summary
Daemon HTTP MCP endpoints and advertisement
Gradata/src/gradata/daemon.py
Added GET /mcp/tools and POST /mcp/tool-call HTTP endpoints to serve MCP schemas and dispatch tool calls against the in-memory Brain. Daemon writes .daemon.json on startup and removes it on shutdown for local discovery.
MCP stdio daemon client: discovery and HTTP forwarding
Gradata/src/gradata/mcp_server.py
Implemented _DaemonClient with daemon discovery (env vars, .daemon.json, legacy port fallback), health probing, and JSON POST calls to daemon's /mcp/tool-call endpoint with error handling.
MCP stdio daemon client integration
Gradata/src/gradata/mcp_server.py
Updated run_server to discover and connect to daemon when use_daemon=True, skip local Brain initialization if daemon found, and route tools/call through daemon client. Added --no-daemon CLI flag to disable bridge mode.
Daemon bridge test infrastructure
Gradata/tests/test_mcp_daemon_bridge.py
Provided JSON-RPC frame helpers and live_daemon pytest fixture that starts an ephemeral daemon with proper advertisement and cleanup for integration testing.
Daemon bridge integration and end-to-end tests
Gradata/tests/test_mcp_daemon_bridge.py, Gradata/tests/test_mcp_server.py
Tests validate daemon discovery behavior, HTTP endpoint responses, bridge-mode stdio server delegation (without opening local Brain), and JSON-RPC response integrity. Existing tests updated to pass use_daemon=False.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested labels

feature, breaking-change

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: daemon speaks both HTTP and MCP transports' accurately captures the main architectural change: the daemon now handles both HTTP and MCP protocols as described in the changeset.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, providing design rationale, compatibility notes, test coverage, and known caveats for the daemon bridge feature.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/daemon-speaks-both-transports

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 OpenGrep (1.20.0)

OpenGrep fatal error (exit code 2):
┌──────────────┐
│ Opengrep CLI │
└──────────────┘

�[32m✔�[39m �[1mOpengrep OSS�[0m
�[32m✔�[39m Basic security coverage for first-party code vulnerabilities.

�[1m Loading rules from local config...�[0m
[00.35][ERROR]: Error: exception Glob.Lexer.Syntax_error("malformed glob pattern: missing ']'")
Raised at Glob__Lexer.syntax_error in file "libs/glob/Lexer.mll", line 8, characters 2-26
Called from Glob__Lexer.__ocaml_lex_token_rec in file "libs/glob/Lexer.mll", line 29, characters 26-53
Cal


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Gradata/src/gradata/daemon.py`:
- Around line 798-807: Replace the direct call to _write_pid_file(...) that
writes self._brain_dir / ".daemon.json" with the repository's atomic JSON write
helper so the file is written atomically; call the helper to write the same
payload (port, pid/process info and started_at) to self._brain_dir /
".daemon.json" instead of using _write_pid_file, using the same inputs
(actual_port and self._started_at) and keep the contextlib.suppress(OSError)
wrapper around the atomic write to preserve best-effort, non-fatal behavior.
- Around line 662-666: The endpoint currently assumes the parsed JSON from
self._read_json() is a dict and calls body.get(...), which raises AttributeError
for non-object JSON; modify the handler to first check that body is an instance
of dict (e.g., if not isinstance(body, dict): self._send_json({"error":"request
body must be an object"}, 400) and return) before accessing body.get("name") and
body.get("arguments"), and keep using _send_json to return the 400 error for
invalid bodies.

In `@Gradata/src/gradata/mcp_server.py`:
- Around line 123-130: The loop currently accepts any daemon that answers
/health; change the probe-and-accept logic to verify the daemon’s reported
brain_dir matches the requested brain before returning a client: modify or
overload cls._probe(url) to return the health dict (or add a new method like
cls._probe_health(url)), then in the candidates loop compare
health.get("brain_dir") to the requested_brain_dir parameter (or an
expected_brain_dir variable passed into the caller) and only do _log.info(...)
and return cls(url) when they match; allow returning a client for mismatched
brain_dir only when an explicit override flag (e.g., allow_cross_brain or an env
var like GRADATA_DAEMON_ALLOW_CROSS_BRAIN) is set and documented.

In `@Gradata/tests/test_mcp_daemon_bridge.py`:
- Around line 107-113: The test test_discover_finds_daemon_via_advert_file calls
_DaemonClient.discover but does not clear environment overrides, so clear
GRADATA_DAEMON_URL and GRADATA_DAEMON_PORT before discovery (use pytest's
monkeypatch.delenv or os.environ.pop with default) to ensure advert-file
discovery is deterministic; apply the same change to the other advert-based test
referenced around lines 148-169 so both tests remove those env vars prior to
calling _DaemonClient.discover.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: d9ace558-08bb-42fb-ba1d-367dc4e69154

📥 Commits

Reviewing files that changed from the base of the PR and between 708ce01 and 1a6b731.

📒 Files selected for processing (4)
  • Gradata/src/gradata/daemon.py
  • Gradata/src/gradata/mcp_server.py
  • Gradata/tests/test_mcp_daemon_bridge.py
  • Gradata/tests/test_mcp_server.py
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (8)
  • GitHub Check: pytest (py3.12)
  • GitHub Check: pytest (py3.11)
  • GitHub Check: pytest windows-latest / py3.12
  • GitHub Check: pytest ubuntu-latest / py3.12
  • GitHub Check: pytest ubuntu-latest / py3.11
  • GitHub Check: pytest macos-latest / py3.11
  • GitHub Check: pytest macos-latest / py3.12
  • GitHub Check: pytest windows-latest / py3.11
🧰 Additional context used
📓 Path-based instructions (2)
Gradata/tests/**/*.py

📄 CodeRabbit inference engine (Gradata/AGENTS.md)

Gradata/tests/**/*.py: Set BRAIN_DIR environment variable via tmp_path in conftest.py for test isolation — ensure _paths.py module cache refreshes when calling Brain.init() directly inside tests
Add unit tests in tests/test_*.py for every CI push without LLM calls (deterministic); mark integration tests with @pytest.mark.integration and skip them by default (they hit real LLM APIs)

Files:

  • Gradata/tests/test_mcp_server.py
  • Gradata/tests/test_mcp_daemon_bridge.py
Gradata/src/**/*.py

📄 CodeRabbit inference engine (Gradata/AGENTS.md)

Gradata/src/**/*.py: Prefer sentence-transformers for local embeddings, google-genai for Gemini embeddings, cryptography for AES-GCM encrypted system.db, bm25s for BM25 rule ranking, and mem0ai for external memory adapters — guard all optional dependency imports with try / except ImportError at the call site, never at module level
Maintain strict layering: Layer 0 (Primitives: _types.py, _db.py, _events.py, _paths.py, _file_lock.py; Patterns: contrib/patterns/) must never import from Layer 1 (Enhancements: enhancements/, rules/) or Layer 2 (Public API: brain.py, cli.py, daemon.py, mcp_server.py)
Never use bare except: pass — use typed exceptions or at minimum logger.warning(...) with exc_info=True to avoid silent failure in a memory product
Never import from out-of-scope sibling directories ../Sprites/ or ../Hausgem/ within gradata/* code — that is a layering bug
Never leak private-sibling paths into public docs/code — no references to ../Sprites/, ../Hausgem/, email addresses, OneDrive paths, or Sprites-specific examples from inside gradata/*
Use atomic-write helper when writing JSON files to prevent corruption from mid-write crashes

Files:

  • Gradata/src/gradata/mcp_server.py
  • Gradata/src/gradata/daemon.py

Comment on lines +662 to +666
body = self._read_json()
tool_name = body.get("name", "")
arguments = body.get("arguments") or {}
if not isinstance(arguments, dict):
self._send_json({"error": "arguments must be an object"}, 400)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Reject non-object request bodies before calling .get().

json.loads() can return a list/string/number. In that case Line 663 raises AttributeError, and this endpoint drops the request instead of returning a 400.

Suggested fix
     def _handle_mcp_tool_call(self) -> None:
         self.daemon._reset_idle_timer()
         body = self._read_json()
+        if not isinstance(body, dict):
+            self._send_json({"error": "request body must be an object"}, 400)
+            return
         tool_name = body.get("name", "")
         arguments = body.get("arguments") or {}
         if not isinstance(arguments, dict):
             self._send_json({"error": "arguments must be an object"}, 400)
             return
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Gradata/src/gradata/daemon.py` around lines 662 - 666, The endpoint currently
assumes the parsed JSON from self._read_json() is a dict and calls
body.get(...), which raises AttributeError for non-object JSON; modify the
handler to first check that body is an instance of dict (e.g., if not
isinstance(body, dict): self._send_json({"error":"request body must be an
object"}, 400) and return) before accessing body.get("name") and
body.get("arguments"), and keep using _send_json to return the 400 error for
invalid bodies.

Comment on lines +798 to +807
# Always advertise the daemon inside the brain dir so the stdio
# MCP bridge (and any other local client) can discover us without
# needing an explicit --pid-file. Best-effort; failures are non-fatal.
with contextlib.suppress(OSError):
_write_pid_file(
self._brain_dir / ".daemon.json",
actual_port,
self._brain_dir,
self._started_at,
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Write .daemon.json atomically.

This advert is now part of the discovery path. A mid-write crash can leave malformed JSON behind, causing the bridge to miss the right daemon or fall through to another candidate. Please route the .daemon.json write through the repo’s atomic JSON helper instead of _write_pid_file().

As per coding guidelines, "Use atomic-write helper when writing JSON files to prevent corruption from mid-write crashes".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Gradata/src/gradata/daemon.py` around lines 798 - 807, Replace the direct
call to _write_pid_file(...) that writes self._brain_dir / ".daemon.json" with
the repository's atomic JSON write helper so the file is written atomically;
call the helper to write the same payload (port, pid/process info and
started_at) to self._brain_dir / ".daemon.json" instead of using
_write_pid_file, using the same inputs (actual_port and self._started_at) and
keep the contextlib.suppress(OSError) wrapper around the atomic write to
preserve best-effort, non-fatal behavior.

Comment on lines +123 to +130
for url in candidates:
url = url.rstrip("/")
if url in seen:
continue
seen.add(url)
if cls._probe(url):
_log.info("MCP bridge: connected to gradata daemon at %s", url)
return cls(url)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate health["brain_dir"] before accepting a daemon candidate.

Line 128 treats any daemon that answers /health as compatible. If the caller asked for brain B while brain A is listening via GRADATA_DAEMON_URL, GRADATA_DAEMON_PORT, or the :8765 fallback, mutating tools will be forwarded into the wrong brain. Please compare the daemon’s reported brain_dir with the requested brain_dir before returning a client, and only allow cross-brain forwarding via an explicit override.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Gradata/src/gradata/mcp_server.py` around lines 123 - 130, The loop currently
accepts any daemon that answers /health; change the probe-and-accept logic to
verify the daemon’s reported brain_dir matches the requested brain before
returning a client: modify or overload cls._probe(url) to return the health dict
(or add a new method like cls._probe_health(url)), then in the candidates loop
compare health.get("brain_dir") to the requested_brain_dir parameter (or an
expected_brain_dir variable passed into the caller) and only do _log.info(...)
and return cls(url) when they match; allow returning a client for mismatched
brain_dir only when an explicit override flag (e.g., allow_cross_brain or an env
var like GRADATA_DAEMON_ALLOW_CROSS_BRAIN) is set and documented.

Comment on lines +107 to +113
def test_discover_finds_daemon_via_advert_file(live_daemon, brain_dir: Path) -> None:
"""A daemon advertising itself in <brain>/.daemon.json must be discovered."""
_d, base = live_daemon
client = _DaemonClient.discover(brain_dir)
assert client is not None
# The brain-dir-advertised port should win over the 8765 fallback.
assert client.base_url == base
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Clear daemon env overrides in advert-based bridge tests.

Discovery checks GRADATA_DAEMON_URL and GRADATA_DAEMON_PORT before <brain>/.daemon.json. Without removing those vars here, these tests become host-dependent and can bind to the wrong daemon on a developer machine or CI runner.

As per coding guidelines, "Add unit tests in tests/test_*.py for every CI push without LLM calls (deterministic); mark integration tests with @pytest.mark.integration and skip them by default (they hit real LLM APIs)".

Also applies to: 148-169

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Gradata/tests/test_mcp_daemon_bridge.py` around lines 107 - 113, The test
test_discover_finds_daemon_via_advert_file calls _DaemonClient.discover but does
not clear environment overrides, so clear GRADATA_DAEMON_URL and
GRADATA_DAEMON_PORT before discovery (use pytest's monkeypatch.delenv or
os.environ.pop with default) to ensure advert-file discovery is deterministic;
apply the same change to the other advert-based test referenced around lines
148-169 so both tests remove those env vars prior to calling
_DaemonClient.discover.

@Gradata
Copy link
Copy Markdown
Owner Author

Gradata commented May 14, 2026

Boss review (comment-only — gh CLI blocks formal reviews on org-owned PRs):

Approve. Solid architecture — MCP stdio bridge delegating to daemon over HTTP avoids double-flock cleanly.

Key points:

  • .daemon.json write/unlink lifecycle in start()/cleanup() is correct (best-effort, non-fatal)
  • _DaemonClient.discover() probes 4 sources in priority with health check — well-designed
  • _handle_tools_call transparently forwards to daemon or falls back to in-process Brain
  • --no-daemon flag for test isolation
  • 5 new tests in test_mcp_daemon_bridge.py cover discovery, endpoints, bridge mode
  • Existing tests updated with use_daemon=False to preserve isolation

One nit: live_daemon fixture manually writes .daemon.json; consider using daemon.start() to test the real lifecycle. Non-blocking.

No security concerns — all traffic stays on 127.0.0.1.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant