Skip to content

Mcp bridge#754

Open
jvsteiner wants to merge 9 commits into
astrid-runtime:mainfrom
jvsteiner:mcp-bridge
Open

Mcp bridge#754
jvsteiner wants to merge 9 commits into
astrid-runtime:mainfrom
jvsteiner:mcp-bridge

Conversation

@jvsteiner

@jvsteiner jvsteiner commented May 24, 2026

Copy link
Copy Markdown

Linked Issue

Closes #N/A — no upstream issue tracks this. If upstream wants to
accept, an issue should be opened first per the template requirement.

Summary

Adds an MCP server bridge that exposes Astrid capsule tools to external
MCP clients (Claude Code in particular). With the wrapper script
scripts/claude-astrid, claude -p (or interactive claude) runs
with native Read/Write/Bash/etc. disabled and the bridge as the
sole tool provider — so every filesystem, shell, and HTTP call routes
through Astrid's capability, audit, budget, and approval layer.

Heads-up for reviewers: this PR is a 2-repo change. The bridge
calls tools through capsule-cli's ingress proxy, which today only
forwards the chat-shaped surface (user.v1.prompt, etc.). A small
companion change to unicity-astrid/capsule-cli is required to widen
the ingress allowlist to tool.v1.execute.* and attribute publishes
to the originating client's principal via publish_json_as. That
change lives on a local branch (forward-tool-calls) that hasn't
been pushed upstream yet — happy to open it as a companion PR if this
direction is acceptable.

Changes

  • New crate crates/astrid-mcp-bridge/ — rmcp 0.15 server-mode over
    stdio. Builds the tool catalog at startup by calling
    capsule-system's list_capsules + inspect_capsule. Translates
    MCP tools/callToolExecuteRequest/Result. Surfaces
    ApprovalRequired ↔ MCP elicitation (fail-closed on
    Decline/Cancel/timeout/transport-error).
  • New crate crates/astrid-ipc-client/SocketClient extracted
    from crates/astrid-cli/ so both the CLI and the new bridge can
    use it without one depending on a binary crate. Call sites in
    astrid-cli updated.
  • New CLI subcommand astrid mcp bridge (in
    crates/astrid-cli/src/commands/mcp.rs) — thin shim that boots
    the bridge with BridgeConfig::default().
  • Two new IpcPayload variants in astrid-types:
    ToolListRequest { request_id } and
    ToolListResponse { request_id, tools: Vec<ToolDescriptor> },
    with a new ToolDescriptor struct. Updates is_known_tag and the
    EXPECTED_VARIANT_COUNT test guard.
  • Workspace Cargo.tomlrmcp features bumped to include
    server + macros (we previously consumed it client-only);
    schemars added to [workspace.dependencies] to match what rmcp
    pulls.
  • New scripts/claude-astrid (bash) — wraps claude with
    --mcp-config pointing at the bridge and --disallowed-tools
    blocking the native FS/shell/web tools.
  • New scripts/claude-astrid.mcp.json — MCP config template.
  • New scripts/e2e-smoke.sh — smoke tests for the v1 success
    criteria.

Minor API change worth flagging: SocketClient::send_input now
takes (text, caller) instead of just (text). The previous in-CLI
implementation called crate::context::active_agent() for the
caller; pulling that out of socket_client.rs cleanly required
threading principal resolution to the four CLI call sites
(tui/headless.rs, tui/mod.rs, commands/headless.rs,
commands/chat.rs). All call sites updated in this PR.

Test Plan

Automated

  • cargo build --workspace --release passes
  • cargo test -p astrid-types — 30 tests pass (was 27;
    3 new for the variants)
  • cargo test -p astrid-mcp-bridge — 7 unit + 4 integration
    tests pass. The integration suite is #[ignore]'d because it
    needs a running daemon; verified manually.
  • No new clippy warnings introduced by this branch.

Manual

Requires a running daemon (astrid start) with the companion
capsule-cli modification installed.

  1. astrid mcp bridge runs and waits on stdin (smoke check the
    subcommand wires through clap correctly).
  2. claude --mcp-config scripts/claude-astrid.mcp.json --disallowed-tools "Read,Write,Edit,Bash,WebFetch,WebSearch,Glob,Grep,NotebookEdit"
    in the resulting session, ask Claude to list its astrid MCP tools;
    you should see 21 tools (fs_*, shell_*, http_*, etc.).
  3. Invoke system_list_capsules with no arguments — round-trips
    instantly, returns the installed capsule list.
  4. Invoke shell_run_shell_command with
    {"command": "pwd"} — Astrid's interceptor demands approval; the
    bridge surfaces the prompt via MCP elicitation. Approving runs
    pwd; declining (or letting the elicitation time out) denies the
    call with is_error: true.
  5. scripts/e2e-smoke.sh — covers the shell-tool roundtrip and a
    astrid -p regression check.

Verified manually on macOS 14 with Claude Code 2.1.121, rmcp 0.15.0,
Rust 1.94.

Checklist

  • Linked to an issue (no upstream issue exists; flagged above)
  • CHANGELOG.md updated under [Unreleased] (I haven't touched
    CHANGELOG.md — happy to add an entry if the project keeps one;
    didn't find it in the tree. Point me at the right file?)

jvsteiner and others added 9 commits May 24, 2026 16:23
Carrier types for the external tool API. The MCP bridge sends
ToolListRequest to enumerate, capsule-cli forwards it on the bus,
and a future aggregator (or the bridge itself, composing
list_capsules + inspect_capsule) responds with ToolListResponse.
Extracts SocketClient from astrid-cli into a new astrid-ipc-client
crate so the bridge can reuse it. Adds an empty astrid-mcp-bridge
lib + the 'astrid mcp bridge' subcommand stub that prints a
not-yet-implemented error. Real protocol handling in subsequent
commits.

SocketClient::send_input now takes the caller principal as a
parameter (CLI resolves it via crate::context::active_agent;
other embedders pass their own) — necessary to decouple
astrid-ipc-client from CLI-local state.

Also bumps rmcp workspace features to include server + macros
(per the rmcp spike findings) and adds schemars to workspace
dependencies.
Minimal MCP server scaffold using rmcp 0.15 server-side stdio
transport. Returns correct server_info; tool catalog empty for
Task 4. Tool dispatch lands in Task 6.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
New DaemonConnection wraps SocketClient with bridge-friendly
methods: connect+handshake, build_message stamped with the
connection's principal, send, recv. SocketClient already
exposed send_message(IpcMessage), so no extension was needed.

Adds an ignored integration test that verifies the bridge can
complete the bearer-token handshake against a running daemon.
The bridge connects to the daemon at startup, calls list_capsules +
inspect_capsule per capsule, parses the manifests to extract tool
names, and caches the resulting Tool[] for the session. tools/list
returns the cached catalog. Tool dispatch in Task 7.

Surprises observed:
- ToolCallResult.content is double-JSON-encoded for capsules returning
  serde_json::to_string_pretty payloads, and single-encoded for plain
  String returns; unwrap_json_string_layers + unwrap_string_layer
  handle both.
- inspect_capsule returns a plain-text blob "=== Capsule.toml ===\n...
  \n\n=== meta.json ===\n..." rather than structured JSON. Tool names
  live in the TOML's [subscribe] table or [[interceptor]] array as
  topics like tool.v1.execute.<name>, not in meta.json.
- capsule-react subscribes to "tool.v1.execute.result" (the framework
  fanout topic) which our naive prefix-strip would mistake for a tool
  called "result"; filter explicitly.

The #[tool_handler] macro injects its own list_tools/call_tool/get_tool
that delegate to the (empty) tool_router, so we now implement
ServerHandler by hand to return our daemon-built catalog.

Call_tool currently returns method_not_found; Task 7 will wire it to
DaemonConnection::call_tool_round_trip and undo the short.tool prefix.
The MCP server's call_tool now looks up the named tool in the cached
catalog, strips the short-capsule prefix, and round-trips through the
daemon. ToolCallResult content + is_error map directly to MCP
CallToolResult.

To keep tool calls from stalling, the round-trip loop also auto-approves
any ApprovalRequired that arrives mid-call by sending back an
ApprovalResponse on astrid.v1.approval.response.{request_id}. Claude
Code is itself the gating UI, so deferring to it is safe; future tasks
can route these as MCP elicitations instead.
Replaces the Task 7 auto-approve stub with a real human-in-the-loop
flow. ApprovalRequired messages mid-tool-call are now surfaced to the
MCP client (Claude Code) as form-style elicitations with a single
boolean `approved` field; the user's decision is converted back into
an ApprovalResponse on the IPC bus.

- daemon.rs: `call_tool_round_trip` now takes an `on_approval`
  callback (`Fn(ApprovalRequest) -> Future<ApprovalDecision>`) and
  routes the decision to the right wire string.
- mcp.rs: closure issues `peer.create_elicitation_with_timeout` with
  a 120s deadline; Decline / Cancel / transport errors / missing
  `approved` field all map to Deny (fail-closed).
- catalog.rs: introspection calls run pre-MCP-handshake, so they
  pass a `deny_during_catalog` policy with a WARN log — the
  introspection tools shouldn't ever trip the approval interceptor,
  and if one does we refuse rather than auto-grant capabilities the
  user never consented to.
- integration.rs: `live_call_shell_tool` renamed to
  `live_call_shell_tool_denies_on_unhandled_elicitation` and now
  asserts `is_error == true` (the test rmcp client doesn't register
  an elicitation handler, so the bridge maps the resulting error to
  Deny — exactly what we want to verify).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Wraps 'claude' with --mcp-config pointing at the Astrid MCP bridge
and --disallowed-tools blocking native Read/Write/Edit/Bash/etc.
Result: Claude Code can only act through Astrid capsules. Smoke
test verifies the shell.run_shell_command roundtrip + that the
existing astrid -p path didn't regress.
Claude Code swallows MCP subprocess stderr in normal mode, so the
bridge's tracing calls go nowhere visible during debugging. Add a
small synchronous file appender (default /tmp/astrid-bridge.log)
gated on ASTRID_BRIDGE_DEBUG (defaults on; set to "0" to silence;
override path with ASTRID_BRIDGE_LOG_PATH).

Instrumented at the key decision points: run_stdio boot, call_tool
entry, dispatch, approval callback invocation + decision, elicitation
issue + return, and final round-trip success/error. Just enough to
localize "the call timed out" failures to a specific stage without
needing to attach a debugger.

Used this to verify the Claude Code 2.1.121 elicitation flow works
end-to-end: log shows the elicitation prompt is issued; the user just
needs to find Claude's UI for it (it's an unfamiliar element since
few MCP servers exercise elicitation today).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@jvsteiner jvsteiner requested a review from joshuajbouw as a code owner May 24, 2026 14:11
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request implements an MCP (Model Context Protocol) bridge for Astrid, enabling AI agents like Claude Code to discover and execute Astrid's capsule-based tools. The changes include extracting IPC client logic into a shared crate, creating the MCP bridge server, and adding CLI support to run the bridge. This enables a seamless integration between Astrid's tool ecosystem and external AI-assisted development environments.

Highlights

  • New Crate: astrid-mcp-bridge: Introduced a new crate to implement the MCP server bridge, allowing external clients to interact with Astrid tools.
  • New Crate: astrid-ipc-client: Extracted shared IPC communication logic from the CLI into a dedicated crate for reuse by the MCP bridge.
  • CLI Integration: Added an 'astrid mcp bridge' command to the CLI to facilitate running the bridge server.
  • Tool Cataloging: Implemented automatic tool discovery by introspecting installed capsules, exposing them as MCP tools.
  • Integration Scripts: Added configuration and helper scripts to easily integrate the bridge with Claude Code.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.


A bridge of code to span the gap, With tools that fit inside the map. From IPC to MCP, Astrid sets the agents free.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces the astrid-mcp-bridge and astrid-ipc-client crates to enable external MCP clients to interact with Astrid capsule tools. The SocketClient logic was extracted from the CLI into a standalone crate for reuse, and the send_input method was updated to require a caller principal. New IPC message variants for tool listing were added to astrid-types. Feedback includes a request to justify or pin the new schemars dependency and a recommendation to optimize the file-based logging in the bridge by persisting the file handle instead of reopening it on every call.

Comment thread Cargo.toml
rmcp = { version = "0.15", features = ["client", "transport-child-process", "transport-io", "elicitation"] }
rmcp = { version = "0.15", features = ["client", "server", "macros", "transport-child-process", "transport-io", "elicitation"] }
rustyline = { version = "15", features = ["derive"] }
schemars = "0.8"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The schemars dependency has been added to the workspace. If this component is intended to be self-contained and eventually moved to its own repository, please pin the dependency inline in the crate's Cargo.toml rather than using workspace dependencies to avoid creating tight coupling (Rule: Pin dependencies inline). Otherwise, please justify this addition and verify that it does not duplicate existing functionality already present in the workspace.

References
  1. Flag any new dependencies added to Cargo.toml. Demand that the PR author justifies the addition and verifies it doesn't duplicate existing functionality in the workspace. (link)
  2. For components intended to be self-contained and eventually moved to their own repository, pin dependencies inline in their Cargo.toml rather than using workspace dependencies to avoid creating tight coupling.

Comment on lines +38 to +55
pub fn log(args: Arguments<'_>) {
if !enabled() {
return;
}
let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path())
else {
return;
};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
let secs = now.as_secs();
let ms = now.subsec_millis();
let _ = writeln!(f, "[{secs}.{ms:03}] {args}");
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

This log function opens and closes the file on every call, which is a performance anti-pattern. While Rule 4 notes that synchronous I/O can be acceptable in async contexts for trivial operations, Rule 5 emphasizes avoiding redundant expensive I/O. Using a OnceLock to persist the file handle is significantly more efficient. Additionally, per Rule 8, ensure that the log path does not expose sensitive physical host paths. Consider using the tracing facade for better visibility and non-blocking options.

pub fn log(args: Arguments<'_>) {
    if !enabled() {
        return;
    }
    static FILE: OnceLock<std::sync::Mutex<std::fs::File>> = OnceLock::new();
    let Some(mutex) = FILE.get_or_init(|| {
        std::fs::OpenOptions::new()
            .create(true)
            .append(true)
            .open(path())
            .map(std::sync::Mutex::new)
            .ok()
    }) else {
        return;
    };
    let Ok(mut f) = mutex.lock() else {
        return;
    };
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default();
    let secs = now.as_secs();
    let ms = now.subsec_millis();
    let _ = writeln!(f, "[{secs}.{ms:03}] {args}");
}
References
  1. In an async context, it is acceptable to use synchronous, blocking I/O for trivial, infrequent metadata operations where the overhead of async outweighs the benefit.
  2. To improve performance, avoid redundant expensive I/O operations such as opening a file multiple times on the same path.
  3. Avoid logging sensitive information such as physical host paths in standard error logs to prevent information leakage.

@jvsteiner

Copy link
Copy Markdown
Author

Heads-up: this PR has a companion change in unicity-aos/capsule-cli#16 (Forward direct tool calls + attribute by principal).

The bridge sends tool.v1.execute.* IPC traffic that capsule-cli's current ingress allowlist drops, and tool calls need to be attributed to the originating client's principal (the bridge passes one in the IpcMessage envelope; today capsule-cli's handle_ingress uses anonymous publish_json). The companion PR widens the allowlist + switches to publish_json_as. Without it merged, this bridge connects and enumerates tools but every tools/call times out at the capsule-system interceptor stage.

Happy to rebase / split / restructure either PR however makes review easiest.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant