molecule-core/workspace-server/internal/handlers/external_connection.go
Hongming Wang 876c0bfcd4 docs(canvas): update Universal MCP snippet — molecule-mcp now standalone
The canvas tab snippet for the Universal MCP path was written before
this PR added the built-in register + heartbeat thread. Earlier wording
described it as "outbound-only — pair with the Claude Code or Python SDK
tab for heartbeat + inbound messages" — that's stale. molecule-mcp now
handles register + heartbeat itself; the only thing it doesn't yet do is
inbound A2A delivery.

Updated:
- externalUniversalMcpTemplate header comment + body — describes
  standalone behavior, points operators at SDK/channel only when they
  need INBOUND (not heartbeat).
- Drops the now-redundant curl-register step from the snippet — the
  binary registers itself on startup.
- Canvas modal label likewise updated.

No runtime / behavior change; pure docs polish so a copy-pasting
operator's mental model matches what the binary actually does.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 15:52:15 -07:00

189 lines
7.8 KiB
Go

package handlers
// external_connection.go — copy-paste connection payload shown once to
// the operator when they create a runtime="external" workspace.
//
// The canvas UI surfaces these in a single modal so the operator can
// hand the block to whoever runs their external agent without having
// to piece together workspace_id + platform_url + auth_token + API
// shape from the docs. curl snippet has zero dependencies; Python
// snippet pairs with molecule-sdk-python's A2AServer + RemoteAgentClient.
import (
"os"
"github.com/gin-gonic/gin"
)
// externalPlatformURL returns the public URL at which this workspace-
// server instance is reachable by the operator's external agent. This
// is NOT necessarily the caller's Host header (which could be an
// internal CF tunnel hostname). Prefer the EXTERNAL_PLATFORM_URL env
// that Railway/ops sets for the tenant; fall back to the request's
// Host + scheme if unset.
func externalPlatformURL(c *gin.Context) string {
if v := os.Getenv("EXTERNAL_PLATFORM_URL"); v != "" {
return v
}
scheme := "https"
if xf := c.Request.Header.Get("X-Forwarded-Proto"); xf != "" {
scheme = xf
} else if c.Request.TLS == nil {
scheme = "http"
}
host := c.Request.Host
if xh := c.Request.Header.Get("X-Forwarded-Host"); xh != "" {
host = xh
}
return scheme + "://" + host
}
// externalCurlTemplate — zero-dependency register snippet. Placeholders:
// - {{PLATFORM_URL}}, {{WORKSPACE_ID}} — filled server-side
// - $WORKSPACE_AUTH_TOKEN — env var, operator sets
// - $AGENT_URL — env var, operator's public HTTPS endpoint
//
// SSRF filter rejects private IPs at register time, so AGENT_URL must
// resolve to a public host.
//
// Heartbeat loop is NOT included here — curl is fine for one-shot
// register; keeping the workspace alive wants a real loop, so point
// operators at the Python snippet for long-lived setups.
const externalCurlTemplate = `# Replace AGENT_URL with YOUR agent's public HTTPS endpoint, then run:
export WORKSPACE_AUTH_TOKEN="<paste from create response>"
export AGENT_URL="https://your-agent.example.com"
# NOTE on the "Origin" header below: hosted SaaS tenants run behind an
# edge WAF that requires same-origin requests. Without "Origin", paths
# like /workspaces/* silently 404 (rewritten to the canvas Next.js).
# /registry/register is currently allowed without Origin, but setting
# it preemptively keeps your snippet working if the WAF rules expand.
curl -fsS -X POST "{{PLATFORM_URL}}/registry/register" \
-H "Authorization: Bearer $WORKSPACE_AUTH_TOKEN" \
-H "Origin: {{PLATFORM_URL}}" \
-H "Content-Type: application/json" \
-d '{
"id": "{{WORKSPACE_ID}}",
"url": "'"$AGENT_URL"'",
"agent_card": {
"name": "My External Agent",
"description": "",
"version": "0.1.0"
}
}'
`
// externalChannelTemplate — Claude Code channel plugin install + .env. For
// operators whose external agent IS a Claude Code session (laptop or
// remote dev VM); routes the workspace's A2A traffic into the running
// Claude Code session as conversation turns via MCP. The plugin source
// lives at github.com/Molecule-AI/molecule-mcp-claude-channel — polling
// based, no tunnel required (uses /workspaces/:id/activity?since_secs=,
// platform-side support shipped in #2300).
const externalChannelTemplate = `# Claude Code channel — bridges this workspace's A2A traffic into your
# Claude Code session. No tunnel/public URL needed (polling-based).
#
# 1. Save this token + workspace_id, then create ~/.claude/channels/molecule/.env:
mkdir -p ~/.claude/channels/molecule
cat > ~/.claude/channels/molecule/.env <<'EOF'
MOLECULE_PLATFORM_URL={{PLATFORM_URL}}
MOLECULE_WORKSPACE_IDS={{WORKSPACE_ID}}
MOLECULE_WORKSPACE_TOKENS=<paste auth_token from create response>
EOF
chmod 600 ~/.claude/channels/molecule/.env
# 2. Launch Claude Code with the channel enabled:
claude --channels plugin:molecule@Molecule-AI/molecule-mcp-claude-channel
# Inbound A2A messages now surface as conversation turns. Claude's
# replies route back via the reply_to_workspace MCP tool — no extra
# wiring on your side.
#
# Multi-workspace: comma-separate IDs and tokens (same order). See
# https://github.com/Molecule-AI/molecule-mcp-claude-channel for
# pairing flow, push-mode upgrade, and v0.2 roadmap.
`
// externalUniversalMcpTemplate — runtime-agnostic standalone path.
// Ships as the `molecule-mcp` console script in the
// molecule-ai-workspace-runtime PyPI wheel (workspace/mcp_cli.py).
// Any MCP-aware runtime (Claude Code, hermes, codex, third-party)
// registers it once and gets the same 8 universal tools that
// container-bound runtimes use today: delegate_task, list_peers,
// send_message_to_user, commit_memory, etc.
//
// Standalone: the binary itself handles register-on-startup +
// continuous heartbeats (daemon thread, 20s cadence). No separate
// SDK or channel process needed to keep the workspace online. The
// only thing it does NOT yet do is poll inbound A2A messages — for
// runtimes that need their agent to react to canvas messages or
// peer-initiated tasks, pair with the Claude Code channel tab
// (poll-based inbound delivery into a Claude Code session) or the
// Python SDK tab (push-mode inbound + heartbeat).
//
// Origin/WAF: handled automatically by platform_auth.auth_headers()
// in the wheel — operator doesn't need to configure anything.
const externalUniversalMcpTemplate = `# Universal MCP — standalone register + heartbeat + outbound platform tools
# for any MCP-aware runtime (Claude Code, hermes, codex, etc.).
# Pair with the Claude Code or Python SDK tab if your runtime needs
# inbound A2A delivery (canvas messages → agent conversation turns).
# 1. Install the workspace runtime wheel:
pip install molecule-ai-workspace-runtime
# 2. Wire molecule-mcp into your agent's MCP config. Claude Code:
claude mcp add molecule -s user -- env \
WORKSPACE_ID={{WORKSPACE_ID}} \
PLATFORM_URL={{PLATFORM_URL}} \
MOLECULE_WORKSPACE_TOKEN="<paste from create response>" \
molecule-mcp
# molecule-mcp registers the workspace + heartbeats every 20s in a
# daemon thread, then runs the MCP stdio loop. Same env-var contract
# works with hermes-agent, codex, or any MCP stdio runtime. Tools
# exposed: delegate_task, delegate_task_async, check_task_status,
# list_peers, get_workspace_info, send_message_to_user,
# commit_memory, recall_memory.
#
# Origin/WAF handling is built into the wheel — no manual headers
# needed when calling tools through the MCP server.
`
// externalPythonTemplate uses molecule-sdk-python's RemoteAgentClient +
// A2AServer (PR #13 in that repo). Until the SDK cuts a v0.y release
// to PyPI the snippet pins git+main.
const externalPythonTemplate = `# pip install 'git+https://github.com/Molecule-AI/molecule-sdk-python.git@main'
import asyncio
from molecule_agent import RemoteAgentClient, A2AServer
WORKSPACE_ID = "{{WORKSPACE_ID}}"
PLATFORM_URL = "{{PLATFORM_URL}}"
AUTH_TOKEN = "<paste from create response>"
INBOUND_URL = "https://your-agent.example.com/a2a/inbound" # your public HTTPS endpoint
async def handle(request: dict) -> dict:
# request has parts, message, task_id, idempotency_key
text = "".join(p.get("text", "") for p in request.get("parts", []) if p.get("type") == "text")
return {"parts": [{"type": "text", "text": f"echo: {text}"}]}
async def main():
client = RemoteAgentClient(
workspace_id=WORKSPACE_ID,
platform_url=PLATFORM_URL,
auth_token=AUTH_TOKEN,
)
server = A2AServer(
agent_id=client.workspace_id,
inbound_url=INBOUND_URL,
message_handler=handle,
)
server.start_in_background()
client.reported_url = INBOUND_URL
client.register() # one-shot announcement
await client.run_heartbeat_loop_async() # keeps the workspace online
if __name__ == "__main__":
asyncio.run(main())
`