From 24acee2fa6a1a00434f1bb92264e342a9a63b247 Mon Sep 17 00:00:00 2001 From: James White Date: Sun, 23 Nov 2025 18:15:16 +0000 Subject: [PATCH 1/2] feat(mcp-gateway): add minimal local MCP gateway proxy to Happy Server - Implement Streamable HTTP MCP server on localhost port 3030 - Provide tools: open_session, send_text, voice_token - Use existing Happy CLI credentials and E2E encryption - Add .env example and README for local dev usage - Configure TypeScript and npm scripts for dev gateway This enables local dev harness that proxies MCP protocol requests to the hosted Happy Server securely using CLI credentials. Co-authored-by: terragon-labs[bot] --- dev/mcp-gateway/.env.example | 7 ++ dev/mcp-gateway/README.md | 32 ++++++ dev/mcp-gateway/src/index.ts | 211 ++++++++++++++++++++++++++++++++++ dev/mcp-gateway/tsconfig.json | 14 +++ package.json | 1 + 5 files changed, 265 insertions(+) create mode 100644 dev/mcp-gateway/.env.example create mode 100644 dev/mcp-gateway/README.md create mode 100644 dev/mcp-gateway/src/index.ts create mode 100644 dev/mcp-gateway/tsconfig.json diff --git a/dev/mcp-gateway/.env.example b/dev/mcp-gateway/.env.example new file mode 100644 index 00000000..62e10486 --- /dev/null +++ b/dev/mcp-gateway/.env.example @@ -0,0 +1,7 @@ +HAPPY_SERVER_URL=https://api.happy-servers.com +HAPPY_WEBAPP_URL=https://app.happy.engineering +HAPPY_HOME_DIR=~/.happy +MCP_HTTP_PORT=3030 +# Optional: restrict allowed hosts/origins for the MCP HTTP server +# MCP_ALLOWED_HOSTS=localhost:3030,127.0.0.1:3030 +# MCP_ALLOWED_ORIGINS=http://localhost:3030 diff --git a/dev/mcp-gateway/README.md b/dev/mcp-gateway/README.md new file mode 100644 index 00000000..5310a19e --- /dev/null +++ b/dev/mcp-gateway/README.md @@ -0,0 +1,32 @@ +### MCP Gateway (local dev harness) + +Minimal local MCP server that proxies to the hosted Happy Server using your existing Happy CLI credentials (token + machine key) and the same E2E encryption pipeline as the CLI. + +#### What it does now +- Starts a **Streamable HTTP MCP server** on `MCP_HTTP_PORT` (default `3030`). +- Exposes three MCP tools: + - `open_session` – get/create a session by tag and keep a live Socket.IO client attached. + - `send_text` – send a user text message into a session (encrypted E2E). + - `voice_token` – fetch the ElevenLabs token via `/v1/voice/token`. +- Reuses the CLI’s `~/.happy/access.key` for JWT + encryption keys; honours `HAPPY_SERVER_URL`. + +#### Quick start +1) Copy env template and adjust the server URL/port if needed: +```bash +cp dev/mcp-gateway/.env.example dev/mcp-gateway/.env +``` +2) Authenticate the CLI on this machine if you haven’t already (produces `~/.happy/access.key`): +```bash +npm run start # or happy-next login flow with QR +``` +3) Run the gateway (Streamable HTTP MCP): +```bash +DOTENV_CONFIG_PATH=dev/mcp-gateway/.env npm run dev:mcp-gateway +``` +4) Point an MCP-capable client/model at `http://localhost:3030/mcp`. Use the `change_title` example client or any MCP HTTP client to exercise the tools. + +#### Notes +- Sessions are keyed by `tag`; the gateway keeps per-tag Socket.IO connections alive for low latency. +- All payloads stay encrypted end-to-end; the gateway never stores plaintext server-side. +- This is a dev harness; no auth is exposed on the MCP endpoint. Keep it on localhost only. +- Extend `src/index.ts` with more tools/resources (e.g., streaming updates to MCP notifications) as you iterate. diff --git a/dev/mcp-gateway/src/index.ts b/dev/mcp-gateway/src/index.ts new file mode 100644 index 00000000..0f583eba --- /dev/null +++ b/dev/mcp-gateway/src/index.ts @@ -0,0 +1,211 @@ +/** + * Minimal MCP HTTP gateway that proxies to the hosted Happy Server + * using the same credentials/encryption as the Happy CLI. + * + * Transport: Streamable HTTP (MCP) on localhost. + * Tools: + * - open_session(tag): create/attach to a session by tag + * - send_text(tag, text): send a user message into that session + * - voice_token(agentId?, revenueCatPublicKey?): fetch ElevenLabs token + * + * Keep this local; no auth is exposed on the MCP endpoint. + */ + +import 'dotenv/config'; +import { createServer } from 'node:http'; +import { randomUUID } from 'node:crypto'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { z } from 'zod'; +import axios from 'axios'; + +import { configuration } from '@/configuration'; +import { readCredentials } from '@/persistence'; +import { ApiClient } from '@/api/api'; +import { ApiSessionClient } from '@/api/apiSession'; +import { RawJSONLines } from '@/claude/types'; + +const port = Number(process.env.MCP_HTTP_PORT || 3030); +const allowedHosts = (process.env.MCP_ALLOWED_HOSTS || '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); +const allowedOrigins = (process.env.MCP_ALLOWED_ORIGINS || '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + +type SessionBundle = { + sessionClient: ApiSessionClient; + tag: string; +}; + +async function bootstrap() { + const credentials = await readCredentials(); + if (!credentials) { + throw new Error( + 'No Happy credentials found. Run the Happy CLI login/QR flow first to create ~/.happy/access.key' + ); + } + + const api = await ApiClient.create(credentials); + const sessionCache = new Map(); + + async function ensureSession(tag: string): Promise { + const existing = sessionCache.get(tag); + if (existing) return existing; + + // Minimal metadata to satisfy backend expectations + const metadata = { + path: process.cwd(), + host: 'mcp-gateway', + version: 'mcp-gateway', + name: tag, + homeDir: process.env.HOME || '', + happyHomeDir: process.env.HAPPY_HOME_DIR || `${process.env.HOME || ''}/.happy`, + happyLibDir: '', + happyToolsDir: '', + tools: ['mcp-gateway'], + }; + + const session = await api.getOrCreateSession({ + tag, + metadata, + state: null, + }); + + const sessionClient = api.sessionSyncClient(session); + await sessionClient.ensureConnected(); + + const bundle: SessionBundle = { sessionClient, tag }; + sessionCache.set(tag, bundle); + return bundle; + } + + // + // MCP server + // + const mcp = new McpServer({ + name: 'happy-mcp-gateway', + version: '0.1.0', + description: 'Local MCP gateway proxying to the hosted Happy Server', + }); + + // Tool: open or attach to a session by tag + mcp.tool( + 'open_session', + { + description: 'Create or attach to a Happy session identified by tag', + inputSchema: z.object({ + tag: z.string().min(1, 'tag is required'), + }), + }, + async ({ tag }) => { + const bundle = await ensureSession(tag); + return { + content: [ + { + type: 'text', + text: `Session ready for tag "${tag}"`, + }, + ], + }; + } + ); + + // Tool: send a user text message + mcp.tool( + 'send_text', + { + description: 'Send a user text message into a Happy session (encrypted E2E)', + inputSchema: z.object({ + tag: z.string().min(1, 'tag is required'), + text: z.string().min(1, 'text is required'), + }), + }, + async ({ tag, text }) => { + const bundle = await ensureSession(tag); + const msg: RawJSONLines = { + type: 'user', + uuid: randomUUID(), + message: { content: text }, + }; + await bundle.sessionClient.ensureConnected(); + bundle.sessionClient.sendClaudeSessionMessage(msg); + return { + content: [ + { + type: 'text', + text: `Sent message to tag "${tag}"`, + }, + ], + }; + } + ); + + // Tool: fetch a voice token (ElevenLabs) via the Happy Server + mcp.tool( + 'voice_token', + { + description: 'Request a voice conversation token via Happy Server (ElevenLabs passthrough)', + inputSchema: z.object({ + agentId: z.string().optional(), + revenueCatPublicKey: z.string().optional(), + }), + }, + async ({ agentId, revenueCatPublicKey }) => { + const body: Record = { + agentId, + revenueCatPublicKey, + }; + + const response = await axios.post( + `${configuration.serverUrl}/v1/voice/token`, + body, + { + headers: { + Authorization: `Bearer ${credentials.token}`, + 'Content-Type': 'application/json', + }, + timeout: 10_000, + } + ); + + return { + content: [ + { + type: 'text', + text: JSON.stringify(response.data, null, 2), + }, + ], + }; + } + ); + + // + // Transport + HTTP server + // + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + allowedHosts: allowedHosts.length ? allowedHosts : undefined, + allowedOrigins: allowedOrigins.length ? allowedOrigins : undefined, + enableDnsRebindingProtection: allowedHosts.length > 0 || allowedOrigins.length > 0, + }); + + await mcp.connect(transport); + + const httpServer = createServer((req, res) => { + transport.handleRequest(req, res); + }); + + httpServer.listen(port, () => { + console.error( + `[happy-mcp-gateway] listening on http://localhost:${port}/mcp -> ${configuration.serverUrl}` + ); + }); +} + +bootstrap().catch((err) => { + console.error('[happy-mcp-gateway] fatal', err); + process.exit(1); +}); diff --git a/dev/mcp-gateway/tsconfig.json b/dev/mcp-gateway/tsconfig.json new file mode 100644 index 00000000..c858764e --- /dev/null +++ b/dev/mcp-gateway/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "moduleResolution": "bundler", + "module": "ESNext", + "noEmit": true, + "paths": { + "@/*": ["../../src/*"] + } + }, + "include": ["src/**/*.ts"] +} diff --git a/package.json b/package.json index 8eb236d1..a1102455 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "dev": "tsx src/index.ts", "dev:local-server": "npm run build && tsx --env-file .env.dev-local-server src/index.ts", "dev:integration-test-env": "npm run build && tsx --env-file .env.integration-test src/index.ts", + "dev:mcp-gateway": "tsx --env-file dev/mcp-gateway/.env dev/mcp-gateway/src/index.ts", "prepare": "npm run build", "prepublishOnly": "npm run build && npm test", "release": "release-it", From f6a763c03e0377c7285511b67748394febcd3047 Mon Sep 17 00:00:00 2001 From: James White Date: Mon, 24 Nov 2025 17:33:24 +0000 Subject: [PATCH 2/2] feat(mcp-gateway): add smoke test and write-access-key scripts - Introduced dev/mcp-gateway/scripts/smoke.ts for connectivity smoke tests - Added dev/mcp-gateway/scripts/write-access-key.ts to write access keys from env vars - Updated README and env example with usage instructions - Added prep:mcp-creds and test:mcp-gateway npm scripts for developer workflow - Modified package.json to use node --import tsx consistently for dev commands - Updated .gitignore and HAPPY_HOME_DIR for local dev environment isolation The smoke test verifies session connectivity and message sending. The write-access-key script safely generates access.key from environment inputs. Co-authored-by: terragon-labs[bot] --- .gitignore | 3 +- dev/mcp-gateway/.env.example | 3 +- dev/mcp-gateway/README.md | 14 +++- dev/mcp-gateway/scripts/smoke.ts | 72 +++++++++++++++++++++ dev/mcp-gateway/scripts/write-access-key.ts | 63 ++++++++++++++++++ package.json | 10 +-- 6 files changed, 157 insertions(+), 8 deletions(-) create mode 100644 dev/mcp-gateway/scripts/smoke.ts create mode 100644 dev/mcp-gateway/scripts/write-access-key.ts diff --git a/.gitignore b/.gitignore index addc6567..dcf72d50 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ pnpm-lock.yaml # Local installation .happy/ +dev/.happy/ **/*.log -.release-notes-temp.md \ No newline at end of file +.release-notes-temp.md diff --git a/dev/mcp-gateway/.env.example b/dev/mcp-gateway/.env.example index 62e10486..4c71e3cb 100644 --- a/dev/mcp-gateway/.env.example +++ b/dev/mcp-gateway/.env.example @@ -1,6 +1,7 @@ HAPPY_SERVER_URL=https://api.happy-servers.com HAPPY_WEBAPP_URL=https://app.happy.engineering -HAPPY_HOME_DIR=~/.happy +# Use a repo-local happy home so we don't touch your real ~/.happy +HAPPY_HOME_DIR=./dev/.happy MCP_HTTP_PORT=3030 # Optional: restrict allowed hosts/origins for the MCP HTTP server # MCP_ALLOWED_HOSTS=localhost:3030,127.0.0.1:3030 diff --git a/dev/mcp-gateway/README.md b/dev/mcp-gateway/README.md index 5310a19e..5dea72c8 100644 --- a/dev/mcp-gateway/README.md +++ b/dev/mcp-gateway/README.md @@ -15,9 +15,12 @@ Minimal local MCP server that proxies to the hosted Happy Server using your exis ```bash cp dev/mcp-gateway/.env.example dev/mcp-gateway/.env ``` -2) Authenticate the CLI on this machine if you haven’t already (produces `~/.happy/access.key`): +2) Provide credentials (choose one): + - Set env `HAPPY_ACCESS_KEY_JSON` to the contents of your access.key, or + - Set `HAPPY_TOKEN` + (`HAPPY_SECRET_B64` for legacy) **or** `HAPPY_PUBLIC_KEY_B64` + `HAPPY_MACHINE_KEY_B64` (dataKey). + - Then hydrate the local dev keyfile: ```bash -npm run start # or happy-next login flow with QR +DOTENV_CONFIG_PATH=dev/mcp-gateway/.env npm run prep:mcp-creds ``` 3) Run the gateway (Streamable HTTP MCP): ```bash @@ -30,3 +33,10 @@ DOTENV_CONFIG_PATH=dev/mcp-gateway/.env npm run dev:mcp-gateway - All payloads stay encrypted end-to-end; the gateway never stores plaintext server-side. - This is a dev harness; no auth is exposed on the MCP endpoint. Keep it on localhost only. - Extend `src/index.ts` with more tools/resources (e.g., streaming updates to MCP notifications) as you iterate. + +#### Smoke test (optional) +With the same env vars set, run: +```bash +DOTENV_CONFIG_PATH=dev/mcp-gateway/.env npm run test:mcp-gateway +``` +This writes `dev/.happy/access.key` (if missing), opens/creates session tag `mcp-smoke`, and sends a short user message to the hosted server. diff --git a/dev/mcp-gateway/scripts/smoke.ts b/dev/mcp-gateway/scripts/smoke.ts new file mode 100644 index 00000000..2ba5f12f --- /dev/null +++ b/dev/mcp-gateway/scripts/smoke.ts @@ -0,0 +1,72 @@ +/** + * Simple connectivity smoke test: + * - writes the access.key (if envs provided) via write-access-key.ts + * - opens/creates a session tagged "mcp-smoke" + * - sends a short user message + */ + +import 'dotenv/config'; +import { randomUUID } from 'node:crypto'; +import { ApiClient } from '@/api/api'; +import { readCredentials } from '@/persistence'; +import { RawJSONLines } from '@/claude/types'; +import { configuration } from '@/configuration'; +import { execFileSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { existsSync } from 'node:fs'; + +function runWriteAccessKey() { + const script = resolve(dirname(__dirname), 'dev/mcp-gateway/scripts/write-access-key.ts'); + execFileSync('node', ['--import', 'tsx', script], { + env: process.env, + stdio: 'inherit', + }); +} + +async function main() { + // Ensure access.key exists; try writing if not + if (!existsSync(resolve(configuration.happyHomeDir, 'access.key'))) { + runWriteAccessKey(); + } + + const creds = await readCredentials(); + if (!creds) throw new Error('No credentials found. Provide env vars and rerun.'); + + const api = await ApiClient.create(creds); + + const session = await api.getOrCreateSession({ + tag: 'mcp-smoke', + metadata: { + path: process.cwd(), + host: 'mcp-gateway-smoke', + homeDir: process.env.HOME || '', + happyHomeDir: configuration.happyHomeDir, + happyLibDir: '', + happyToolsDir: '', + }, + state: null, + }); + + const client = api.sessionSyncClient(session); + await client.ensureConnected(); + + const msg: RawJSONLines = { + type: 'user', + uuid: randomUUID(), + message: { content: 'hello from mcp-gateway smoke test' }, + }; + + client.sendClaudeSessionMessage(msg); + + console.error( + `[smoke] sent message to session ${session.id} (tag: mcp-smoke) at ${configuration.serverUrl}` + ); + + // Close socket to exit + client.sendSessionDeath?.(); +} + +main().catch((err) => { + console.error('[smoke] failed', err); + process.exit(1); +}); diff --git a/dev/mcp-gateway/scripts/write-access-key.ts b/dev/mcp-gateway/scripts/write-access-key.ts new file mode 100644 index 00000000..7164b37f --- /dev/null +++ b/dev/mcp-gateway/scripts/write-access-key.ts @@ -0,0 +1,63 @@ +/** + * Write a Happy access.key into dev/.happy/access.key using env vars. + * + * Supported env inputs: + * - HAPPY_ACCESS_KEY_JSON: full JSON string of access.key + * - or broken out: + * HAPPY_TOKEN + * HAPPY_SECRET_B64 (legacy) + * HAPPY_PUBLIC_KEY_B64 (dataKey) + * HAPPY_MACHINE_KEY_B64 (dataKey) + * + * The file is written under HAPPY_HOME_DIR (defaults to ./dev/.happy). + * + * Secrets are NOT logged. + */ + +import { mkdirSync, writeFileSync, chmodSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; + +const happyHome = process.env.HAPPY_HOME_DIR || './dev/.happy'; +const target = resolve(join(happyHome, 'access.key')); + +function main() { + const fullJson = process.env.HAPPY_ACCESS_KEY_JSON; + let payload: any | null = null; + + if (fullJson) { + try { + payload = JSON.parse(fullJson); + } catch (err) { + throw new Error('HAPPY_ACCESS_KEY_JSON is not valid JSON'); + } + } else { + const token = process.env.HAPPY_TOKEN; + const secret = process.env.HAPPY_SECRET_B64; + const publicKey = process.env.HAPPY_PUBLIC_KEY_B64; + const machineKey = process.env.HAPPY_MACHINE_KEY_B64; + + if (!token) throw new Error('Set HAPPY_TOKEN or HAPPY_ACCESS_KEY_JSON'); + + if (secret) { + payload = { token, secret }; + } else if (publicKey && machineKey) { + payload = { + token, + encryption: { + publicKey, + machineKey, + }, + }; + } else { + throw new Error('Need either HAPPY_SECRET_B64 or both HAPPY_PUBLIC_KEY_B64 and HAPPY_MACHINE_KEY_B64'); + } + } + + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, JSON.stringify(payload, null, 2)); + chmodSync(target, 0o600); + + console.error(`[write-access-key] wrote ${target}`); +} + +main(); diff --git a/package.json b/package.json index a1102455..44bc4e05 100644 --- a/package.json +++ b/package.json @@ -61,10 +61,12 @@ "build:dev": "shx rm -rf dist && npx tsc --noEmit && pkgroll", "test": "npm run build && tsx --env-file .env.integration-test node_modules/.bin/vitest run", "start": "npm run build && ./bin/happy-next.mjs", - "dev": "tsx src/index.ts", - "dev:local-server": "npm run build && tsx --env-file .env.dev-local-server src/index.ts", - "dev:integration-test-env": "npm run build && tsx --env-file .env.integration-test src/index.ts", - "dev:mcp-gateway": "tsx --env-file dev/mcp-gateway/.env dev/mcp-gateway/src/index.ts", + "dev": "node --import tsx src/index.ts", + "dev:local-server": "npm run build && node --import tsx --env-file .env.dev-local-server src/index.ts", + "dev:integration-test-env": "npm run build && node --import tsx --env-file .env.integration-test src/index.ts", + "dev:mcp-gateway": "node --import tsx --env-file dev/mcp-gateway/.env dev/mcp-gateway/src/index.ts", + "prep:mcp-creds": "node --import tsx dev/mcp-gateway/scripts/write-access-key.ts", + "test:mcp-gateway": "node --import tsx --env-file dev/mcp-gateway/.env dev/mcp-gateway/scripts/smoke.ts", "prepare": "npm run build", "prepublishOnly": "npm run build && npm test", "release": "release-it",