Skip to content

feat(core,cli): M3c — MCP client (stdio) + /mcp slash + REPL wire-up#10

Merged
oratis merged 1 commit into
mainfrom
feat/m3c-mcp-client
May 28, 2026
Merged

feat(core,cli): M3c — MCP client (stdio) + /mcp slash + REPL wire-up#10
oratis merged 1 commit into
mainfrom
feat/m3c-mcp-client

Conversation

@oratis

@oratis oratis commented May 28, 2026

Copy link
Copy Markdown
Owner

Summary

  • Implements MCP stdio transport via official SDK. MCP server tools registered as mcp__<server>__<tool> into the same ToolRegistry the 6 P0 tools use.
  • /mcp slash command + REPL auto-connect from settings.mcpServers.
  • 6 new tests using disk-based in-process MCP servers. Total 264 passed.
  • HTTP/SSE/OAuth/serve deferred to M3c-ext.

Release notes

  • release-notes:feature

Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com

…L wire-up

Implements docs/DEVELOPMENT_PLAN.md §3.3 (stdio subset). HTTP/SSE/OAuth/
headersHelper/Elicitation/serve are M3c-ext (next PR).

What ships
----------
- packages/core/src/mcp/client.ts (~120 lines)
  · connectMcpServer(name, config)  — spawn server, init, list tools, return
                                       handle with ToolHandler[] registered as
                                       `mcp__<server>__<tool>`
  · connectAllMcpServers(servers)   — bulk + per-server failure isolation,
                                       supports enabledOnly + disabled filters
  · closeAllMcpServers(handles)     — graceful shutdown via Promise.allSettled

- packages/core/src/mcp/index.ts    — re-exports + module docstring listing
                                       what's in vs deferred

- Top-level @deepcode/core index    — exports mcp surface

- apps/cli/src/commands.ts          — /mcp slash command lists connected
                                       servers + tool counts + connection errors

- apps/cli/src/repl.ts              — on startup, connectAllMcpServers from
                                       settings.mcpServers (respecting
                                       enabledMcpjsonServers / disabledMcpjsonServers);
                                       register every MCP tool into ToolRegistry;
                                       cleanup on exit

Dep change
----------
- @deepcode/core depends on @modelcontextprotocol/sdk ^1.29.0

Tests (6 new, 264 total)
------------------------
- src/mcp/client.test.ts spawns small disk-based MCP server scripts (importing
  the SDK by absolute path, since /tmp lacks node_modules). Covers list-tools,
  call-tool, failure isolation, enabled/disabled filters, missing-command.

Total: 223 core + 41 cli + 7 skipped = 264 passed / 0 failed.

Verified
--------
  pnpm typecheck   → green
  pnpm test        → 264 passed / 0 failed
  CLI bin --help   → still works

Docs
----
- docs/milestones/M3c-mcp.md — what shipped, what M3c-ext adds

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@oratis oratis merged commit b367555 into main May 28, 2026
@oratis oratis deleted the feat/m3c-mcp-client branch May 28, 2026 04:24
oratis added a commit that referenced this pull request May 28, 2026
Summary of this overnight run (PR #10-15):
- M3c MCP stdio + /mcp slash
- M3c compaction + StatusLine + CLI flag wiring
- M3c-ext http/prompt hooks + if field + ApiKeyHelperRefresher + auto-compact
- M3.5 sandbox subsystem (macOS sandbox-exec + Linux bwrap)
- 15 built-in SKILL.md + effort-bench.ts + release.yml + BEHAVIOR_PARITY.md
- M5.1 plugin subprocess + JSON-RPC + capability passing + token+env-strip

Plus honest accounting of what's NOT done:
- M6 Mac client (zero code)
- M7 file panel UI (depends on M6)
- M8 Vim/voice/headless polish
- M5.2 plugin live wire-up + marketplace
- M3.5 attack vector test suite
- /init multi-phase + auto classifier mode + mcp_tool/agent hooks

Total: ~65-70% of v1 scope on main.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
oratis added a commit that referenced this pull request Jun 1, 2026
… of #10) (#142)

* test(sandbox): real-kernel bwrap integration tests + Linux CI setup

Step 1 of the §3.9a network-allowlist work: prove the Linux sandbox actually
sandboxes on a real kernel (so far only ARG GENERATION was tested), and stand
up the CI Linux harness the slirp4netns selective-allowlist work (step 2) will
build on.

- bwrap-integration.test.ts: spawns the real bwrap-wrapped command and asserts
  rw-cwd writes succeed, /etc writes fail (ro), /usr is readable, and deny-all
  network (allowedDomains: []) blocks outbound. Gated on `bwrap` present →
  runs on the Linux CI runner, skips on macOS/dev.
- ci.yml: on Linux, apt-install bubblewrap + slirp4netns + curl and relax
  Ubuntu 24.04's unprivileged-userns AppArmor restriction so bwrap can unshare.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sandbox): correct contradictory assertion in bwrap e2e write test

The pre-existing "blocks writing outside the bound cwd" test was dormant
in CI (no bwrap installed) until the new Linux sandbox-tools step
activated it. Its own comment notes that /tmp inside the sandbox is a
fresh tmpfs, so a write there *succeeds* (exit 0) into that ephemeral,
isolated filesystem — yet the test also asserted a non-zero exit. The
real security property is that the write never reaches the HOST, which
the `exists === false` check already verifies. Drop the contradictory
exit-code assertion; a genuine read-only-bind denial (non-zero exit) is
covered by bwrap-integration.test.ts (/etc write).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
oratis added a commit that referenced this pull request Jun 1, 2026
Implements the Linux selective network allowlist (the last gap in §3.9a's
sandbox). When sandbox.network.allowedDomains is a non-empty allowlist,
spawnNetworkSandbox (netns.ts) orchestrates:

  1. the allowlisting DNS proxy (dns-proxy.ts) on 127.0.0.1:53 — forwards
     allowed lookups upstream, returns NXDOMAIN for everything else;
  2. bwrap --unshare-net --uid 0 --gid 0 with our resolv.conf bound (at the
     symlink-resolved real path) and --info-fd/--block-fd for PID handoff +
     readiness gating;
  3. slirp4netns --configure --disable-dns attached to bwrap's netns by PID,
     giving rootless userspace NAT (tap0, 10.0.2.100/24, gateway 10.0.2.2 →
     host loopback where the proxy listens).

The decisive detail: --uid 0 --gid 0 maps the host user to root inside
bwrap's userns, which is what lets slirp (the host user, owner of that
userns) gain CAP_SYS_ADMIN on entry and setns() into the netns — without it
setns(CLONE_NEWNET) is EPERM.

Threat model: DNS-NAME allowlisting (raw-IP dials bypass it) — adequate for
the git/npm/pip-over-https agent workload, and --disable-dns closes the
10.0.2.3 bypass. Requires binding :53 (CAP_NET_BIND_SERVICE or a relaxed
ip_unprivileged_port_start); when unavailable, callers fail CLOSED via
NetworkSandboxUnavailable rather than running unrestricted.

Verified on the Linux CI runner by netns-integration.test.ts (gated on
DC_SANDBOX_NET_TEST + bwrap + slirp4netns): an allowlisted domain returns
HTTP 200 while a non-allowlisted domain fails to resolve. The mechanics were
proven first via a throwaway CI PoC (now removed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
oratis added a commit that referenced this pull request Jun 1, 2026
…10 step 2 (#143)

* chore(sandbox): diagnostic PoC for slirp4netns net-allowlist mechanics

TEMPORARY. Adds scripts/sandbox-net-poc.sh + a continue-on-error Linux CI
step that exercises the full bwrap --unshare-net + slirp4netns + DNS-proxy
allowlist flow on a real kernel, printing diagnostics. This nails down the
exact info-fd/ready-fd handshake, host-loopback DNS routing, and port-53
bindability before they are encoded in packages/core/src/sandbox/netns.ts.
Both the script and the CI step are removed once the TS orchestrator +
integration test land.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(sandbox): make net PoC hang-proof (sleep readiness + watchdog)

The FIFO-based readiness handshake deadlocked in CI (host open(O_WRONLY)
blocks forever when the in-sandbox reader and host writer don't share the
inode across the bind mount). Replace it with a 3s sleep window inside the
sandbox (slirp configures in <1s) plus a 45s background watchdog that
hard-kills the sandbox and a trap that always tears down slirp/proxy/tmp.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(sandbox): fix resolv.conf bind (dangling symlink) + --disable-dns

bwrap couldn't bind our resolv.conf onto /etc/resolv.conf because on the
runner it's a dangling symlink (→ /run/systemd/resolve/stub-resolv.conf,
not mounted in the sandbox). Bind our file at the readlink-resolved real
path so the preserved symlink leads to it. Add slirp4netns --disable-dns
to close the 10.0.2.3 bypass (all resolution must traverse our allowlist).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(sandbox): slirp --userns-path to fix setns EPERM into bwrap netns

slirp4netns failed with setns(CLONE_NEWNET): Operation not permitted —
bwrap's net namespace is owned by bwrap's child user namespace, which the
host-user slirp process has no CAP_SYS_ADMIN over. Pass
--userns-path=/proc/<pid>/ns/user so slirp enters that userns (where it is
root) before the netns. resolv.conf bind now confirmed working.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(sandbox): PoC — bwrap --uid 0 + README-exact slirp + ns diagnostics

--userns-path did not resolve setns(CLONE_NEWNET) EPERM. Dump the namespace
topology (child user/net ns links + uid_map/gid_map + lsns) to see whether
bwrap nests the userns such that the netns is owned by a parent userns slirp
can't gain caps over. Also try the slirp4netns README-exact incantation:
bwrap --uid 0 --gid 0 (root-mapped userns) + plain `slirp4netns --configure
<pid> tap0` (no --userns-path/--disable-dns).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sandbox): slirp4netns selective per-domain network allowlist (#10)

Implements the Linux selective network allowlist (the last gap in §3.9a's
sandbox). When sandbox.network.allowedDomains is a non-empty allowlist,
spawnNetworkSandbox (netns.ts) orchestrates:

  1. the allowlisting DNS proxy (dns-proxy.ts) on 127.0.0.1:53 — forwards
     allowed lookups upstream, returns NXDOMAIN for everything else;
  2. bwrap --unshare-net --uid 0 --gid 0 with our resolv.conf bound (at the
     symlink-resolved real path) and --info-fd/--block-fd for PID handoff +
     readiness gating;
  3. slirp4netns --configure --disable-dns attached to bwrap's netns by PID,
     giving rootless userspace NAT (tap0, 10.0.2.100/24, gateway 10.0.2.2 →
     host loopback where the proxy listens).

The decisive detail: --uid 0 --gid 0 maps the host user to root inside
bwrap's userns, which is what lets slirp (the host user, owner of that
userns) gain CAP_SYS_ADMIN on entry and setns() into the netns — without it
setns(CLONE_NEWNET) is EPERM.

Threat model: DNS-NAME allowlisting (raw-IP dials bypass it) — adequate for
the git/npm/pip-over-https agent workload, and --disable-dns closes the
10.0.2.3 bypass. Requires binding :53 (CAP_NET_BIND_SERVICE or a relaxed
ip_unprivileged_port_start); when unavailable, callers fail CLOSED via
NetworkSandboxUnavailable rather than running unrestricted.

Verified on the Linux CI runner by netns-integration.test.ts (gated on
DC_SANDBOX_NET_TEST + bwrap + slirp4netns): an allowlisted domain returns
HTTP 200 while a non-allowlisted domain fails to resolve. The mechanics were
proven first via a throwaway CI PoC (now removed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sandbox): swallow stream errors on netns teardown

The integration test's assertions passed but the run failed: SIGTERM-ing
slirp4netns / bwrap in close() reset their stdio pipes, emitting
`read ECONNRESET` with no 'error' listener → vitest flagged 2 unhandled
errors. Attach no-op 'error' handlers to both child processes and all their
stdio streams so teardown resets are absorbed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
oratis added a commit that referenced this pull request Jun 1, 2026
…144)

BashTool now routes commands through spawnNetworkSandbox when
network.allowedDomains is a non-empty allowlist on Linux. If the slirp
sandbox can't be set up (e.g. the DNS proxy can't bind 127.0.0.1:53), it
FAILS CLOSED — re-runs under deny-all-net with a clear note — rather than
running unrestricted. Background commands always fail closed (the slirp
helper can't safely outlive the turn).

- netns.ts: add pure helpers needsNetworkSandbox() (linux + enabled +
  non-empty allowlist) and denyAllNetwork() (fail-closed config).
- bash.ts: foreground net path (capture/timeout/abort via the handle),
  fail-closed fallback, background deny-all; shared summarize() helper.
- Tests: netns.test.ts (decision + config helpers, runs everywhere) +
  bash.test.ts wiring tests using an injected fake spawner (net path,
  fail-closed fallback, background) — no real bwrap needed.
- docs/security-model.md: document the Linux allowlist, its DNS-name threat
  model, the :53 requirement, and the fail-closed behavior.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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