Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions cmd/destila-mcp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# destila-mcp

Stdio MCP bridge that translates `claude` CLI tool calls to Destila's
HTTP+SSE endpoint.

## Build

```sh
cd cmd/destila-mcp
go build -o destila-mcp ./...
```

The resulting binary is referenced from Destila's per-session
`.mcp.json` files (see `Destila.Agent.McpConfigWriter`).

## Environment

| Variable | Purpose |
|----------------------|-----------------------------------------------|
| `DESTILA_SESSION_ID` | The agent session id chosen by Destila |
| `DESTILA_MCP_TOKEN` | Bearer token (same as `:destila, :mcp_token`) |
| `DESTILA_MCP_URL` | e.g. `http://127.0.0.1:4000/mcp` |

## Scope

This bridge implements only the subset of MCP needed by Claude Code:

- `initialize`
- `tools/list`
- `tools/call`
- `notifications/initialized`
- `notifications/cancelled`
- `ping`

It forwards each frame verbatim to Destila — wire-protocol changes can be
absorbed here without touching the Elixir code.
3 changes: 3 additions & 0 deletions cmd/destila-mcp/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/destila/destila-mcp

go 1.22
64 changes: 64 additions & 0 deletions cmd/destila-mcp/internal/httpclient/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Package httpclient is the bridge's outward face to Destila.
//
// Sends each JSON-RPC message as a POST to /mcp/<sessionID>/rpc with a
// Bearer token, an X-Destila-Session-Id header, and an X-Destila-Bridge-Version
// header. Returns the raw response body (or empty for HTTP 204).
package httpclient

import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
)

const BridgeVersion = "0.1.0"

type Client struct {
BaseURL string
Token string
SessionID string
HTTP *http.Client
}

func New(baseURL, token, sessionID string, httpClient *http.Client) *Client {
if httpClient == nil {
httpClient = http.DefaultClient
}
return &Client{BaseURL: baseURL, Token: token, SessionID: sessionID, HTTP: httpClient}
}

func (c *Client) PostRPC(payload json.RawMessage) ([]byte, error) {
url := fmt.Sprintf("%s/%s/rpc", c.BaseURL, c.SessionID)

req, err := http.NewRequest("POST", url, bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.Token)
req.Header.Set("X-Destila-Session-Id", c.SessionID)
req.Header.Set("X-Destila-Bridge-Version", BridgeVersion)

resp, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}

if resp.StatusCode == http.StatusNoContent {
return nil, nil
}

if resp.StatusCode/100 != 2 {
return nil, fmt.Errorf("destila returned HTTP %d: %s", resp.StatusCode, string(body))
}

return body, nil
}
59 changes: 59 additions & 0 deletions cmd/destila-mcp/internal/mcpstdio/stdio.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Package mcpstdio implements LSP-style framed JSON-RPC over stdio.
//
// Each message is preceded by a `Content-Length: <N>\r\n\r\n` header.
// This is the framing the Claude Code MCP client uses on stdio transports.
package mcpstdio

import (
"bufio"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
)

// ReadMessage reads one framed JSON message from r. Returns the raw JSON body.
func ReadMessage(r *bufio.Reader) (json.RawMessage, error) {
var contentLength int

for {
line, err := r.ReadString('\n')
if err != nil {
return nil, err
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
break
}
if strings.HasPrefix(strings.ToLower(line), "content-length:") {
v := strings.TrimSpace(line[len("Content-Length:"):])
n, err := strconv.Atoi(v)
if err != nil {
return nil, fmt.Errorf("invalid Content-Length: %w", err)
}
contentLength = n
}
}

if contentLength <= 0 {
return nil, fmt.Errorf("missing or zero Content-Length")
}

buf := make([]byte, contentLength)
if _, err := io.ReadFull(r, buf); err != nil {
return nil, err
}

return buf, nil
}

// WriteMessage writes one framed JSON message to w.
func WriteMessage(w io.Writer, payload []byte) error {
header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(payload))
if _, err := w.Write([]byte(header)); err != nil {
return err
}
_, err := w.Write(payload)
return err
}
90 changes: 90 additions & 0 deletions cmd/destila-mcp/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Destila MCP bridge.
//
// Speaks stdio-MCP on its inward face (to claude) and translates each
// tools/call (and other JSON-RPC methods) to Destila's HTTP+SSE endpoint
// on its outward face. The bridge insulates Destila from changes in the
// MCP wire protocol — only this binary needs to track upstream MCP drift.
//
// Environment variables required:
// DESTILA_SESSION_ID - per-session id chosen by Destila
// DESTILA_MCP_TOKEN - global bearer token
// DESTILA_MCP_URL - e.g. http://127.0.0.1:4000/mcp
package main

import (
"bufio"
"encoding/json"
"fmt"
"io"
"net/http"
"os"

"github.com/destila/destila-mcp/internal/httpclient"
"github.com/destila/destila-mcp/internal/mcpstdio"
)

func main() {
sessionID := os.Getenv("DESTILA_SESSION_ID")
token := os.Getenv("DESTILA_MCP_TOKEN")
url := os.Getenv("DESTILA_MCP_URL")

if sessionID == "" || token == "" || url == "" {
fmt.Fprintln(os.Stderr, "DESTILA_SESSION_ID, DESTILA_MCP_TOKEN, DESTILA_MCP_URL must be set")
os.Exit(2)
}

client := httpclient.New(url, token, sessionID, http.DefaultClient)

stdin := bufio.NewReader(os.Stdin)
stdout := os.Stdout

for {
msg, err := mcpstdio.ReadMessage(stdin)
if err == io.EOF {
return
}
if err != nil {
fmt.Fprintf(os.Stderr, "bridge read error: %v\n", err)
return
}

// Forward to Destila as JSON-RPC over HTTP.
respBody, err := client.PostRPC(msg)
if err != nil {
writeErrorResponse(stdout, msg, fmt.Sprintf("HTTP error: %v", err))
continue
}

if len(respBody) == 0 {
// 204 No Content — notifications produce no reply.
continue
}

// Validate JSON and forward verbatim.
var anyJSON json.RawMessage
if err := json.Unmarshal(respBody, &anyJSON); err != nil {
writeErrorResponse(stdout, msg, fmt.Sprintf("invalid response from server: %v", err))
continue
}

if err := mcpstdio.WriteMessage(stdout, respBody); err != nil {
fmt.Fprintf(os.Stderr, "bridge write error: %v\n", err)
return
}
}
}

func writeErrorResponse(w io.Writer, req json.RawMessage, message string) {
var parsed struct {
ID json.RawMessage `json:"id"`
}
_ = json.Unmarshal(req, &parsed)

resp, _ := json.Marshal(map[string]interface{}{
"jsonrpc": "2.0",
"id": parsed.ID,
"error": map[string]interface{}{"code": -32603, "message": message},
})

_ = mcpstdio.WriteMessage(w, resp)
}
20 changes: 20 additions & 0 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,26 @@ config :destila, :proxy,
basic_auth_user: System.get_env("DESTILA_BASIC_AUTH_USER"),
basic_auth_password: System.get_env("DESTILA_BASIC_AUTH_PASSWORD")

# DESTILA_MCP_TOKEN authenticates MCP clients (the Go bridge) against the
# Phoenix /mcp endpoint. Required in prod; a documented dev-only default
# is used otherwise.
config :destila,
:mcp_token,
System.get_env("DESTILA_MCP_TOKEN") ||
if(config_env() == :prod,
do:
raise("""
environment variable DESTILA_MCP_TOKEN is missing.
Set it to a strong random value to authenticate the MCP bridge.
"""),
else: "destila-dev-only-token"
)

config :destila,
:mcp_bridge_path,
System.get_env("DESTILA_MCP_BRIDGE_PATH") ||
Path.expand("../cmd/destila-mcp/destila-mcp", __DIR__)

if config_env() == :prod do
database_path =
System.get_env("DATABASE_PATH") ||
Expand Down
37 changes: 37 additions & 0 deletions docs/mcp_smoke_test.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# MCP smoke test

Manual smoke test for the HTTP+SSE MCP transport. Lives in
`scripts/mcp_smoke.sh`.

## When to run it

Before any release that touches the new agent path (anything under
`lib/destila/agent/`, `lib/destila_web/mcp/`, or `cmd/destila-mcp/`).

## How to run it

1. Start the dev server:
```sh
elixir --sname destila -S mix phx.server
```
2. In another shell:
```sh
./scripts/mcp_smoke.sh
```

The script:
- Builds the Go bridge into `cmd/destila-mcp/destila-mcp`.
- POSTs a `tools/list` JSON-RPC envelope at `/mcp/<session_id>/rpc` with the
default dev token.
- Asserts HTTP 200 and a `"tools"` field in the response.

Set `DESTILA_MCP_TOKEN` and `DESTILA_MCP_URL` to override defaults.

## Troubleshooting

- `401 unauthorized` — your `DESTILA_MCP_TOKEN` doesn't match the dev
default `destila-dev-only-token`. Set the env var to match.
- `connection refused` — the dev server isn't running, or it's on a
different port. Check `config/dev.exs` and `PORT`.
- The script intentionally does not attempt to drive a real `claude`
CLI when the binary is not installed.
Loading