mcp-integration · git:20260410.eb63c8f · 2026-04-10 · sha256 2e0899c7003b8934

mcp-integration git:20260410.eb63c8fA

Immutable. This exact content is served forever at /api/v1/blob/2e0899c7003b8934.

---
name: mcp-integration
description: MCP client integration patterns for packages/tools/src/mcp/. Docker container lifecycle, transport auto-detection, two-phase container naming, and cleanup. Use when working on MCP server configuration, docker-based MCP tools, or the mcp-client.
user-invocable: false
---

# MCP Integration Patterns

## The Critical Rule

```
docker rm -f <containerName> is the ONLY reliable container stop.
subprocess.kill() is NOT sufficient.
```

Killing the `docker run` process leaves the container alive in the Docker daemon. `docker rm -f` is the only operation that reliably terminates AND removes the container. This is non-negotiable.

## Two Docker MCP Patterns

| Pattern | Examples | Container behavior |
|---------|---------|-------------------|
| **stdio MCP** | GitHub MCP, filesystem MCP | Container reads JSON-RPC from stdin; responds on stdout |
| **HTTP-only MCP** | mcp/context7 | Container starts an HTTP server on a port; ignores stdin |

Both are handled transparently. The client auto-detects which pattern applies.

## Transport Auto-Detection

The MCP client races two connection methods when starting a docker container:

1. **stdio connect** — attempts to connect via stdin/stdout immediately
2. **HTTP URL detection** — watches container stderr for a URL pattern (e.g., `http://localhost:3000`)

When HTTP wins the race, the client switches to port-mapped HTTP mode automatically. No manual configuration needed.

**Transport inference rules** (for non-docker configs):

- `command` field present → `"stdio"`
- endpoint contains `/mcp` → `"streamable-http"`
- any other endpoint → `"sse"`
- `transport` field in `MCPServerConfig` is optional — auto-inferred if not set

## Two-Phase Container Naming

Docker containers are created in two phases. This prevents conflicts between concurrent agents running the same MCP server.

| Phase | Name pattern | Purpose |
|-------|-------------|---------|
| Probe | `rax-probe-<name>-<pid>` | Initial stdio connection attempt |
| Managed | `rax-mcp-<name>-<pid>` | Port-mapped HTTP mode (after HTTP detected) |

`<pid>` = process ID of the agent. Two agents running the same MCP server get different container names.

## Cleanup Pattern

Always call `cleanupMcpTransport(serverName)` — not just `transport.close()`.

```typescript
// WRONG — leaves container running in Docker daemon:
await transport.close();

// CORRECT — removes container first, then closes transport:
await cleanupMcpTransport(serverName);
// Internally: docker rm -f rax-mcp-<name>-<pid> && transport.close()
```

`cleanupMcpTransport` is called in:

- Cortex DELETE `/api/mcp-servers/:id`
- Agent `dispose()` lifecycle hook

## MCPServerConfig Schema

```typescript
// packages/tools/src/mcp/types.ts
interface MCPServerConfig {
  readonly name: string;
  readonly command?: string;           // e.g., "docker"
  readonly args?: readonly string[];   // e.g., ["run", "--rm", "-i", "ghcr.io/..."]
  readonly env?: Record<string, string>;
  readonly endpoint?: string;          // e.g., "http://localhost:3000/mcp"
  readonly transport?: "stdio" | "streamable-http" | "sse"; // optional — auto-inferred
}
```

The `transport` field is optional. Do not require it in new code.

## Cortex MCP Config Import

Cortex accepts MCP configs in two JSON shapes. Both are handled by `parseConfigBody` + `expandMcpConfigsFromJson`:

**Shape 1 — Cursor format:**

```json
{
  "mcpServers": {
    "github": { "command": "docker", "args": ["run", ...] }
  }
}
```

**Shape 2 — Claude Desktop format:**

```json
{
  "mcpServers": {
    "github": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"] }
  }
}
```

Do not add new shape parsers without updating `expandMcpConfigsFromJson`.

## Testing MCP Integration

```typescript
// Run: bun test packages/tools/tests/mcp-client.test.ts --timeout 15000
import { Effect, Layer } from "effect";
import { describe, it, expect } from "bun:test";

it("should auto-detect transport from stdio config", async () => {
  const config: MCPServerConfig = {
    name: "test-mcp",
    command: "docker",
    args: ["run", "--rm", "-i", "some-mcp-image"],
    // transport not set — should be inferred as "stdio"
  };

  const transport = inferTransport(config);
  expect(transport).toBe("stdio");
}, 15000);

it("should infer streamable-http for /mcp endpoint", async () => {
  const config: MCPServerConfig = {
    name: "context7",
    endpoint: "http://localhost:3000/mcp",
  };

  const transport = inferTransport(config);
  expect(transport).toBe("streamable-http");
}, 15000);
```

For docker integration tests, mock the docker subprocess to avoid requiring Docker in CI:

```typescript
const mockDockerProcess = {
  stdin: { write: vi.fn() },
  stdout: { on: vi.fn() },
  stderr: { on: vi.fn() },
  kill: vi.fn(), // NOTE: this does NOT stop the container — tests should verify docker rm -f is called
};
```