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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ pnpm-lock.yaml

# Local installation
.happy/
dev/.happy/

**/*.log
.release-notes-temp.md
.release-notes-temp.md
8 changes: 8 additions & 0 deletions dev/mcp-gateway/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
HAPPY_SERVER_URL=https://api.happy-servers.com
HAPPY_WEBAPP_URL=https://app.happy.engineering
# 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
# MCP_ALLOWED_ORIGINS=http://localhost:3030
42 changes: 42 additions & 0 deletions dev/mcp-gateway/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
### 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) 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
DOTENV_CONFIG_PATH=dev/mcp-gateway/.env npm run prep:mcp-creds
```
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.

#### 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.
72 changes: 72 additions & 0 deletions dev/mcp-gateway/scripts/smoke.ts
Original file line number Diff line number Diff line change
@@ -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);
});
63 changes: 63 additions & 0 deletions dev/mcp-gateway/scripts/write-access-key.ts
Original file line number Diff line number Diff line change
@@ -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();
Loading