Mcp bridge#754
Conversation
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]>
Summary of ChangesHello, 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
Using Gemini Code AssistThe 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
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 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
|
There was a problem hiding this comment.
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.
| 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" |
There was a problem hiding this comment.
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
- 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)
- 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.
| 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}"); | ||
| } |
There was a problem hiding this comment.
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
- 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.
- To improve performance, avoid redundant expensive I/O operations such as opening a file multiple times on the same path.
- Avoid logging sensitive information such as physical host paths in standard error logs to prevent information leakage.
|
Heads-up: this PR has a companion change in unicity-aos/capsule-cli#16 (Forward direct tool calls + attribute by principal). The bridge sends Happy to rebase / split / restructure either PR however makes review easiest. |
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 interactiveclaude) runswith native
Read/Write/Bash/etc. disabled and the bridge as thesole 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 onlyforwards the chat-shaped surface (
user.v1.prompt, etc.). A smallcompanion change to
unicity-astrid/capsule-cliis required to widenthe ingress allowlist to
tool.v1.execute.*and attribute publishesto the originating client's principal via
publish_json_as. Thatchange lives on a local branch (
forward-tool-calls) that hasn'tbeen pushed upstream yet — happy to open it as a companion PR if this
direction is acceptable.
Changes
crates/astrid-mcp-bridge/— rmcp 0.15 server-mode overstdio. Builds the tool catalog at startup by calling
capsule-system'slist_capsules+inspect_capsule. TranslatesMCP
tools/call↔ToolExecuteRequest/Result. SurfacesApprovalRequired↔ MCP elicitation (fail-closed onDecline/Cancel/timeout/transport-error).
crates/astrid-ipc-client/—SocketClientextractedfrom
crates/astrid-cli/so both the CLI and the new bridge canuse it without one depending on a binary crate. Call sites in
astrid-cliupdated.astrid mcp bridge(incrates/astrid-cli/src/commands/mcp.rs) — thin shim that bootsthe bridge with
BridgeConfig::default().IpcPayloadvariants inastrid-types:ToolListRequest { request_id }andToolListResponse { request_id, tools: Vec<ToolDescriptor> },with a new
ToolDescriptorstruct. Updatesis_known_tagand theEXPECTED_VARIANT_COUNTtest guard.Cargo.toml—rmcpfeatures bumped to includeserver+macros(we previously consumed it client-only);schemarsadded to[workspace.dependencies]to match what rmcppulls.
scripts/claude-astrid(bash) — wrapsclaudewith--mcp-configpointing at the bridge and--disallowed-toolsblocking the native FS/shell/web tools.
scripts/claude-astrid.mcp.json— MCP config template.scripts/e2e-smoke.sh— smoke tests for the v1 successcriteria.
Minor API change worth flagging:
SocketClient::send_inputnowtakes
(text, caller)instead of just(text). The previous in-CLIimplementation called
crate::context::active_agent()for thecaller; pulling that out of
socket_client.rscleanly requiredthreading 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 --releasepassescargo test -p astrid-types— 30 tests pass (was 27;3 new for the variants)
cargo test -p astrid-mcp-bridge— 7 unit + 4 integrationtests pass. The integration suite is
#[ignore]'d because itneeds a running daemon; verified manually.
Manual
Requires a running daemon (
astrid start) with the companioncapsule-climodification installed.astrid mcp bridgeruns and waits on stdin (smoke check thesubcommand wires through clap correctly).
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.).system_list_capsuleswith no arguments — round-tripsinstantly, returns the installed capsule list.
shell_run_shell_commandwith{"command": "pwd"}— Astrid's interceptor demands approval; thebridge surfaces the prompt via MCP elicitation. Approving runs
pwd; declining (or letting the elicitation time out) denies thecall with
is_error: true.scripts/e2e-smoke.sh— covers the shell-tool roundtrip and aastrid -pregression check.Verified manually on macOS 14 with Claude Code 2.1.121, rmcp 0.15.0,
Rust 1.94.
Checklist
[Unreleased](I haven't touchedCHANGELOG.md — happy to add an entry if the project keeps one;
didn't find it in the tree. Point me at the right file?)