diff --git a/.gitignore b/.gitignore index a278c33..a188c81 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,29 @@ examples/skills/*/dist/ coverage/ *.log .tmp-tests/ +.tmp-video-studio-e2e/ .commandcode/ + +# LocalAnt runtime state accidentally created at the repo root (gateway home = cwd) +/approvals.json +/config.json +/secrets.json +/token +/vault.key +/audit/ +/output/ +/backups/ +/logs/ + +# Local render / e2e scratch +.tmp-e2e-render/ +.tmp-video-studio-variants/ +.tmp-localant-five-visual-styles/ + +# One-off dev scratch files +/x1 +/x2 +/xrun.cjs +/serve-output.cjs +/patch_remotion_template.py +/scripts/x5.ts diff --git a/README.md b/README.md index 875765c..2bca497 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,11 @@ localant tools profile coding # switch profile localant tools list # see what's exposed ``` +Optional product surfaces such as **LocalAnt Video Studio** and the generated-image +**Asset bridge** are disabled for ChatGPT by default. Enable them from +Dashboard → Settings → Optional ChatGPT tools when you want those tools +advertised over MCP. + Every tool's risk level (0–4) and how each family is gated is documented in [docs/tools.md](docs/tools.md). @@ -382,6 +387,18 @@ All routes share one validation path (magic-byte sniff → MIME allowlist → SVG-safety scan → sha256 → atomic write with backup). Risk 2 (no approval in `open` mode). See [docs/asset-bridge.md](docs/asset-bridge.md). +For Video Studio, save the generated image first, then attach it to a scene with +`video_studio_add_asset`. The image is copied into the project workspace and +passed to Remotion as a scene visual, so ChatGPT-generated images can become +animated short-video material. + +For large generated images, use `asset_upload_chunk` repeatedly and finish with +`asset_commit_upload`; the committed file can then be attached with +`video_studio_add_asset`. + +These tools are not advertised to ChatGPT unless the Asset bridge feature is +enabled in Dashboard → Settings → Optional ChatGPT tools. + ## Browser automation Playwright-based (optional peer dependency), using an **isolated profile** by diff --git a/docs/asset-bridge.md b/docs/asset-bridge.md index 65fc6f7..386a6b6 100644 --- a/docs/asset-bridge.md +++ b/docs/asset-bridge.md @@ -43,6 +43,46 @@ asset_save_image { Returns `{ path, bytes, mimeType, sha256, source, backupId? }`. +### Large generated images + +When ChatGPT can read the image bytes but a single `asset_save_image` base64 +payload would be too large, stream it in chunks: + +```jsonc +asset_upload_chunk { + "uploadId": "generated-scene-001", + "index": 0, + "data": "base64 text chunk" +} + +asset_commit_upload { + "uploadId": "generated-scene-001", + "chunks": 12, + "destination": "/absolute/path/to/scene-001.png" +} +``` + +`asset_commit_upload` assembles the chunks, then runs the exact same image +validation and atomic write path as `asset_save_image`. Chunk payloads are not +recorded in the audit log. + +## Video Studio handoff + +Use `asset_save_image` first, then import the saved file into a Video Studio +scene: + +```jsonc +video_studio_add_asset { + "projectId": "mqu-example", + "sceneId": "scene-001", + "path": "/absolute/path/to/chatgpt-generated.png" +} +``` + +`video_studio_add_asset` copies PNG/JPEG/WebP files into the project's +`assets/` directory, records the scene `assetPath`, and passes the image through +`render-props.json` so Remotion renders it as the scene visual. + ## Validation (every route) Before anything is written, the resolved bytes must pass: @@ -87,7 +127,8 @@ never leaves a corrupt asset in place. ## Profiles `asset_save_image` is exposed in the `coding` and `full` profiles (not -`minimal`). +`minimal`) only after the Asset bridge feature is enabled from Dashboard → +Settings → Optional ChatGPT tools. ```bash localant tools profile coding diff --git a/docs/skills.md b/docs/skills.md index 45f8b71..e536c3e 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -106,6 +106,7 @@ Ready-to-read references live in [`examples/skills/`](../examples/skills): | `file-organizer` | Filesystem writes — sort a folder by type/date. A "local hands" chore ChatGPT can't do itself. | | `local-backup` | Shell allowlist (`tar` only) — timestamped `.tar.gz` snapshots. | | `article-publisher` | Network + secrets + git — publish to Zenn/Qiita/note. | +| `flowkit` | Local REST API — control a running FlowKit video-generation server. | ## Generating a skill from ChatGPT diff --git a/docs/superpowers/specs/2026-06-26-video-studio-remotion-voicevox-design.md b/docs/superpowers/specs/2026-06-26-video-studio-remotion-voicevox-design.md new file mode 100644 index 0000000..7ec6df6 --- /dev/null +++ b/docs/superpowers/specs/2026-06-26-video-studio-remotion-voicevox-design.md @@ -0,0 +1,29 @@ +# LocalAnt Video Studio Remotion + VOICEVOX Design + +## Goal + +LocalAnt Video Studio generates presentation-style short videos for OSS introductions, app introductions, and product introductions. It does not call paid external text-to-video APIs. The primary path is local Remotion rendering with local VOICEVOX narration. + +## Primary Pipeline + +1. Create a script and scene manifest. +2. Detect VOICEVOX Engine at `http://127.0.0.1:50021` or configured endpoint. +3. Fetch `/speakers` and select a speaker/style. +4. For each scene, call `/audio_query`, then `/synthesis`, and write a scene WAV. +5. Probe each scene WAV with `ffprobe`; scene durations and total project duration are derived from actual audio. +6. Generate captions from audio-derived timings: `output.srt`, `output.ass`, and `words.json`. +7. Write `render/render-props.json` and `render/motion-plan.json`. +8. Render `output/output.mp4` and `output/thumbnail.jpg` with Remotion. +9. Review with `ffprobe`; fail if the rendered video is shorter than the narration audio. + +## Fallbacks + +Remotion is the primary renderer. The existing static ffmpeg slide renderer remains a fallback only when Remotion is unavailable. VOICEVOX is the primary Japanese TTS. macOS `say` is preview fallback only, and ffmpeg silence is the last local fallback for automated tests. + +## Dashboard + +The Dashboard Video Studio card shows the primary renderer, fallback renderer, VOICEVOX endpoint, speaker count, selected speaker, voice quality, and render readiness. Project rows continue exposing Generate, Review, Prepare Publish, Browser Upload Assist, and Publish. + +## Verification + +`pnpm build`, `pnpm test`, and `pnpm video-studio:e2e` must pass. The E2E output must contain `output.mp4`, `thumbnail.jpg`, `output.srt`, `output.ass`, `words.json`, `render-props.json`, and `motion-plan.json`. When VOICEVOX is running, E2E must use VOICEVOX; otherwise it must clearly report fallback audio in the result while keeping the primary status visible. diff --git a/docs/tools.md b/docs/tools.md index 57e6986..6d802da 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -27,7 +27,8 @@ is risk 0; a `git_reset --hard` is risk 4), so ranges are shown. |--------|------:|------------|-------| | Read / Search | 7 | 0 | `read`, `read_file_range`, `grep`, `glob`, `list_files`, `get_file_info`. Never mutates. | | Edit | 10 | 2–3 | `write`, `edit`, `multi_edit`, `apply_patch`, `move_file`, `copy_file`, `create_directory`, `delete_file`. | -| Asset bridge | 1 | 2 | `asset_save_image` — one tool, `source.kind` = `base64` / `url` / `latest_download`. Magic-byte + SSRF + SVG-safety checked. See [asset-bridge.md](asset-bridge.md). | +| Asset bridge | 3 | 2 | `asset_save_image`, plus `asset_upload_chunk` / `asset_commit_upload` for large generated images. Video Studio can attach saved images with `video_studio_add_asset`. Magic-byte + SSRF + SVG-safety checked. See [asset-bridge.md](asset-bridge.md). | +| Video Studio | 17 | 0–4 | Remotion + VOICEVOX short-video workflow. Disabled for ChatGPT by default; enable it from Dashboard → Settings → Optional ChatGPT tools. | | Shell | 16 | 0–3 | `bash`, background shell control, `command_exists`. Screened by CommandGuard + PathGuard. | | Git | 22 | 0–4 | Status/diff/log are 0; `git_commit`/`git_add` are 2–3; `git_reset`/destructive ops reach 4. | | Validate / Project | 8 | 0–3 | `project_run_tests`/`lint`/`typecheck`/`build`/`validation`; reads scripts at 0. | @@ -60,3 +61,8 @@ The advertised surface is narrowed by the active **tool profile**: localant tools profile coding localant tools list ``` + +Optional product surfaces are also gated by Dashboard feature toggles. LocalAnt +Video Studio and the generated-image Asset bridge are disabled for ChatGPT by +default; enable them in Dashboard → Settings → Optional ChatGPT tools before +they appear in MCP `tools/list`. diff --git a/docs/video-studio/oss-research.md b/docs/video-studio/oss-research.md new file mode 100644 index 0000000..00f4d07 --- /dev/null +++ b/docs/video-studio/oss-research.md @@ -0,0 +1,130 @@ +# OSS Research + +## Compared projects + +- name: Remotion +- url: https://github.com/remotion-dev/remotion and https://www.remotion.dev/docs/ +- stars if available: about 51.2k on GitHub when checked on 2026-06-25 +- last activity if available: latest release v4.0.482 on 2026-06-22 when checked +- license: custom Remotion license, with company-license requirements in some cases +- stack: TypeScript, React, browser rendering, programmatic composition +- useful ideas: composition objects, props-driven rendering, preview/render separation, reusable animated templates +- risks: heavier than a pure FFmpeg fallback and Remotion's custom license should stay visible to operators + +- name: MoviePy +- url: https://github.com/Zulko/moviepy and https://zulko.github.io/moviepy/ +- stars if available: GitHub project is a widely used Python video editing library +- last activity if available: active public repository when checked +- license: MIT +- stack: Python, FFmpeg-backed clip composition +- useful ideas: timeline-style clip composition, audio/video/text overlay separation, cross-platform fallback +- risks: adds Python runtime dependency and package installation surface to LocalAnt + +- name: WhisperX +- url: https://github.com/m-bain/whisperX +- stars if available: public GitHub project +- last activity if available: active public repository when checked +- license: BSD-style license in repository +- stack: Python, Whisper, alignment models +- useful ideas: word-level timestamp structure, future speech-to-caption alignment, diarization-ready JSON +- risks: large ML/runtime dependencies; not suitable as required default + +- name: FFmpeg +- url: https://ffmpeg.org/ +- stars if available: not applicable +- last activity if available: active upstream project +- license: LGPL/GPL depending on build options +- stack: native CLI +- useful ideas: final render, audio muxing, ASS subtitle burn-in, thumbnail extraction, ffprobe validation +- risks: local install required; codec/filter availability varies by build + +- name: Aegisub / ASS subtitle design +- url: https://github.com/TypesettingTools/Aegisub +- stars if available: public GitHub project +- last activity if available: active public repository when checked +- license: BSD-style license in repository +- stack: C++, ASS subtitle editor +- useful ideas: style/timing separation, safe area, outline/shadow readability, karaoke-style future extension +- risks: editor code is not needed; LocalAnt should emit ASS directly + +- name: OpenShorts +- url: https://github.com/mutonby/openshorts +- stars if available: GitHub badge present; exact count can drift +- last activity if available: active enough to have current docs/site when checked +- license: MIT +- stack: self-hosted Docker app, short-video workflows +- useful ideas: clip generation, AI shorts, YouTube Studio style workflow grouping +- risks: full platform scope is larger than LocalAnt's first pass + +- name: gyoridavid/short-video-maker +- url: https://github.com/gyoridavid/short-video-maker +- stars if available: public GitHub project +- last activity if available: active public repository when checked +- license: repository license should be checked before copying any code +- stack: MCP/REST, Kokoro TTS, Whisper, Pexels, Remotion +- useful ideas: text to TTS to captions to Remotion render pipeline, MCP-compatible public surface +- risks: Pexels/API-backed media search and ML dependencies are not acceptable as mandatory defaults here + +- name: VOICEVOX Engine +- url: https://github.com/VOICEVOX/voicevox_engine and https://voicevox.github.io/voicevox_engine/api/ +- stars if available: public GitHub project +- last activity if available: active public repository when checked +- license: LGPL-3.0 for the engine; voice library terms are separate +- stack: local HTTP TTS engine, `/speakers`, `/audio_query`, `/synthesis` +- useful ideas: free local Japanese narration, explicit speaker/style selection, scene-level WAV generation +- risks: the engine must be running locally; character/voice terms must be respected by users + +## Design decisions for LocalAnt + +- what to adopt: Remotion's scene/composition/props model as the primary renderer, VOICEVOX Engine as the primary Japanese TTS, MoviePy's separation of clips/audio/overlays, WhisperX's word-timing JSON shape, FFmpeg/ffprobe for fallback and validation, ASS subtitles for readable Shorts/Reels captions, and a browser-upload-assist provider that stops before submit. +- what not to adopt: paid external video generation APIs, cloud text-to-video APIs, mandatory Python/ML stack, API-only publishers as the default path, or any copied OSS source. +- why: the product requirement is a free local fallback that can generate a real uploadable MP4 from ChatGPT/MCP. Optional paid or reviewed APIs can exist later, but they cannot be required for video generation. + +## Implementation mapping + +No source code copied. The implementation only adopts architecture patterns and file-format ideas from the projects above. + +- Remotion mapping: + - OSS idea: represent videos as parameterized compositions, separate preview/editing from final render, and reuse templates. + - LocalAnt implementation: `VideoProject`, `VideoScene`, `render/render-props.json`, and `render/motion-plan.json` drive a Remotion composition with animated background, card, title, captions, progress bar, and CTA. Dashboard preview/review is separated from `video_studio_render_video`. The `generate_video` tool orchestrates reusable steps instead of hiding everything in a single opaque command. + - Not adopted: Lambda/Cloud Run rendering or remote render services. + +- VOICEVOX mapping: + - OSS idea: run Japanese TTS locally through a documented HTTP engine. + - LocalAnt implementation: status checks `http://127.0.0.1:50021/speakers` by default, selects a speaker/style, then calls `/audio_query` and `/synthesis` per scene. Scene WAV durations are measured with `ffprobe`, and video timings are derived from those durations. + - Not adopted: bundled VOICEVOX engine binaries, remote TTS, or paid voice APIs. + +- MoviePy mapping: + - OSS idea: think in clips and layers: image/video clip, audio clip, text overlay, final composition. + - LocalAnt implementation: each scene becomes a generated visual clip, each narration segment becomes a WAV clip, captions are emitted as SRT/ASS/words, and FFmpeg composes the final MP4. The project directory keeps each layer in `assets/`, `audio/`, `captions/`, `render/`, and `output/`. + - Not adopted: Python dependency, MoviePy runtime, or dynamic Python package installation. + +- WhisperX mapping: + - OSS idea: word-level timestamp data enables later subtitle alignment, highlighting, and diarization. + - LocalAnt implementation: `captions/words.json` uses `{ word, start, end, sceneId }` entries so the initial deterministic caption splitter can later be replaced by true WhisperX-style alignment without changing downstream render/review APIs. + - Not adopted: ML transcription, GPU requirements, diarization, or model downloads as mandatory defaults. + +- FFmpeg mapping: + - OSS idea: use CLI primitives for final rendering, audio muxing, thumbnail extraction, ffprobe validation, progress overlays, and optional subtitle filters. + - LocalAnt implementation: FFmpeg static-slide rendering is fallback. `ffprobe` remains mandatory for audio length measurement and review validation, including failing when rendered video is shorter than narration. + - Compatibility decision: FFmpeg subtitle rendering requires libass-enabled builds. Since this Mac's FFmpeg build did not expose `ass/subtitles` filters, LocalAnt still writes `.ass` and `.srt`, but burns readable caption text into generated scene visuals so output remains uploadable on common free FFmpeg installs. + +- Aegisub / ASS mapping: + - OSS idea: keep subtitle style, safe area, outline, timing, and text content separate. + - LocalAnt implementation: `output.ass` uses a dedicated style block with large white text, outline/shadow, bottom safe area, and per-scene dialogue events. The renderer can consume this directly later when libass is available. + - Not adopted: Aegisub editor/runtime or UI code. + +- OpenShorts / short-video-maker mapping: + - OSS idea: creator-facing pipeline should be script -> scene plan -> TTS/audio -> captions -> render -> publish preparation, exposed through an automation API. + - LocalAnt implementation: MCP tools follow that pipeline exactly and keep one-shot `video_studio_generate_video` as a wrapper over explicit steps. The browser publisher mirrors the practical OSS pattern of using local/browser upload flows when official APIs require review. + - Not adopted: Pexels/media API search, paid model calls, mandatory Kokoro/Whisper/Remotion stack, or unreviewed third-party API posting as the default. + +## Final architecture + +- renderer: Remotion first. It renders animated presentation-style shorts from `render-props.json`; `builtin-ffmpeg` static slides are fallback only. +- script generator: deterministic template generator that works without an LLM API. +- asset generator: free local generated visuals. It writes per-scene PNGs using FFmpeg color/image generation when available, with SVG placeholders retained for inspection. +- audio generator: VOICEVOX first for Japanese narration. macOS `say` is preview fallback, and FFmpeg silence is only the last local fallback for tests or setup-limited environments. +- caption system: SRT, ASS, and `words.json` are generated from scene timings, with the JSON shaped for future WhisperX word alignment. +- publisher system: browser upload assist and dry-run metadata first. Official APIs are readiness-checked but not required. +- dashboard integration: dashboard routes call the same MCP tools so ChatGPT and the local UI share one implementation. diff --git a/package.json b/package.json index 74e3455..2a934d9 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", + "video-studio:e2e": "tsx scripts/video-studio-e2e.ts", "validate": "pnpm build && pnpm test", "start": "node packages/cli/dist/bin.js start", "setup": "node packages/cli/dist/bin.js setup", @@ -45,9 +46,14 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", + "@remotion/bundler": "^4.0.482", + "@remotion/renderer": "^4.0.482", "commander": "^15.0.0", "express": "^5.0.1", "nanoid": "^5.0.9", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "remotion": "^4.0.482", "tsx": "^4.19.2", "zod": "^4.4.3" }, @@ -71,6 +77,8 @@ "@localant/skill-sdk": "workspace:*", "@types/express": "^5.0.0", "@types/node": "^22.10.0", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^8.18.0", "@typescript-eslint/parser": "^8.18.0", "@vitest/coverage-v8": "^2.1.9", diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 4aeab87..5f35a4b 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -563,8 +563,9 @@ toolsCmd .description("List tools exposed under the active profile") .action(() => { const gw = createGateway(); - const profile = gw.config().tools.profile; - const tools = gw.registry.list().filter((t) => isToolInProfile(t.name, profile)); + const toolsConfig = gw.config().tools; + const profile = toolsConfig.profile; + const tools = gw.registry.list().filter((t) => isToolInProfile(t.name, profile, toolsConfig.features)); console.log(c.bold(`Profile: ${profile} (${tools.length} tools)`)); for (const t of tools) console.log(` ${t.name} ${c.gray(`[risk ${t.risk}]`)}`); }); @@ -580,7 +581,7 @@ toolsCmd if (!["minimal", "coding", "full"].includes(name)) { return console.log(fail("Profile must be one of: minimal, coding, full")); } - gw.saveConfig({ ...gw.config(), tools: { profile: name as "minimal" | "coding" | "full" } }); + gw.saveConfig({ ...gw.config(), tools: { ...gw.config().tools, profile: name as "minimal" | "coding" | "full" } }); console.log(ok(`Tool profile set to '${name}'. Restart the gateway for it to take effect.`)); }); diff --git a/packages/cli/src/runtime.ts b/packages/cli/src/runtime.ts index bc375b4..b2a7228 100644 --- a/packages/cli/src/runtime.ts +++ b/packages/cli/src/runtime.ts @@ -3,6 +3,7 @@ import { createGateway, type Gateway } from "@localant/gateway"; import { startHttpServers } from "@localant/mcp"; import { c, ok, warn, copyToClipboard, openBrowser, urlBox } from "./util.js"; import { resolveAppVersion, APP_VERSION } from "@localant/shared"; +import { buildRoutedMcpEndpoint, ensureSharedRouterRoute } from "./shared-router.js"; export interface StartOptions { noTunnel?: boolean; @@ -50,12 +51,35 @@ export async function runGateway(gw: Gateway, opts: StartOptions): Promise if (!opts.noTunnel && cfg.tunnel.provider !== "none") { if (!opts.quiet) process.stdout.write(c.gray("Starting tunnel… ")); - const tunnel = await gw.tunnel.start(servers.gatewayPort); + const tunnel = + cfg.tunnel.provider === "tailscale" && (cfg.tunnel.domain || cfg.tunnel.publicUrl) + ? await (async () => { + const publicBaseUrl = cfg.tunnel.publicUrl || `https://${cfg.tunnel.domain}`; + const route = await ensureSharedRouterRoute({ + name: "localant", + prefix: "/localant", + target: `http://127.0.0.1:${servers.gatewayPort}`, + publicBaseUrl, + }); + return { + provider: "tailscale-shared-router", + url: route.publicBaseUrl, + status: "running" as const, + error: undefined, + }; + })() + : await gw.tunnel.start(servers.gatewayPort); if (tunnel.status === "running" && tunnel.url) { - const isReachable = await verifyTunnelReachable(tunnel.url); + const isReachable = + tunnel.provider === "tailscale-shared-router" + ? await verifyTunnelReachable(`${tunnel.url.replace(/\/$/, "")}/localant`) + : await verifyTunnelReachable(tunnel.url); if (isReachable) { - mcpEndpoint = `${tunnel.url.replace(/\/$/, "")}/mcp?key=${gw.configStore.getToken()}`; + mcpEndpoint = + tunnel.provider === "tailscale-shared-router" + ? buildRoutedMcpEndpoint(tunnel.url, "/localant", gw.configStore.getToken()) + : `${tunnel.url.replace(/\/$/, "")}/mcp?key=${gw.configStore.getToken()}`; if (!opts.quiet) process.stdout.write(c.green("ok\n")); } else { if (!opts.quiet) process.stdout.write(c.red("failed (unreachable)\n")); diff --git a/packages/cli/src/shared-router-daemon.ts b/packages/cli/src/shared-router-daemon.ts new file mode 100644 index 0000000..2fa1637 --- /dev/null +++ b/packages/cli/src/shared-router-daemon.ts @@ -0,0 +1,6 @@ +import { createSharedRouterServer, loadRouterConfig, routerConfigPath } from "./shared-router.js"; + +const configFile = process.env.MCP_TAILSCALE_ROUTER_CONFIG || routerConfigPath(); +const server = createSharedRouterServer(configFile); + +server.listen(loadRouterConfig(configFile).port, "127.0.0.1"); diff --git a/packages/cli/src/shared-router.ts b/packages/cli/src/shared-router.ts new file mode 100644 index 0000000..7d5a279 --- /dev/null +++ b/packages/cli/src/shared-router.ts @@ -0,0 +1,174 @@ +import fs from "node:fs"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { resolveTailscale, tailscaleEnv } from "@localant/gateway"; + +export interface SharedRouterRoute { + name: string; + prefix: string; + target: string; +} + +export interface SharedRouterConfig { + port: number; + routes: Record; +} + +export interface EnsureSharedRouterOptions { + name: string; + prefix: string; + target: string; + publicBaseUrl: string; +} + +export interface SharedRouterRegistration { + publicBaseUrl: string; + routeBaseUrl: string; +} + +const ROUTER_DIR = path.join(os.homedir(), ".mcp-tailscale-router"); +const CONFIG_FILE = path.join(ROUTER_DIR, "config.json"); +const PID_FILE = path.join(ROUTER_DIR, "router.pid"); +const DEFAULT_PORT = 8790; + +export function normalizeRoutePrefix(prefix: string): string { + const trimmed = prefix.trim().replace(/^\/+/, "").replace(/\/+$/, ""); + if (!trimmed || trimmed.includes("..") || /[^a-zA-Z0-9._-]/.test(trimmed)) { + throw new Error(`Invalid shared router prefix "${prefix}". Use letters, numbers, dot, underscore or dash.`); + } + return `/${trimmed}`; +} + +export function buildRoutedMcpEndpoint(publicBaseUrl: string, prefix: string, token: string): string { + return `${publicBaseUrl.replace(/\/$/, "")}${normalizeRoutePrefix(prefix)}/mcp?key=${encodeURIComponent(token)}`; +} + +export function loadRouterConfig(configFile = CONFIG_FILE): SharedRouterConfig { + if (!fs.existsSync(configFile)) return { port: DEFAULT_PORT, routes: {} }; + try { + const parsed = JSON.parse(fs.readFileSync(configFile, "utf8")) as Partial; + return { port: parsed.port ?? DEFAULT_PORT, routes: parsed.routes ?? {} }; + } catch { + return { port: DEFAULT_PORT, routes: {} }; + } +} + +function saveRouterConfig(config: SharedRouterConfig): void { + fs.mkdirSync(ROUTER_DIR, { recursive: true }); + fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); +} + +async function routerHealthy(port: number): Promise { + try { + const res = await fetch(`http://127.0.0.1:${port}/__mcp_router/healthz`, { signal: AbortSignal.timeout(1000) }); + return res.ok; + } catch { + return false; + } +} + +async function waitForRouter(port: number): Promise { + for (let i = 0; i < 30; i++) { + if (await routerHealthy(port)) return; + await new Promise((resolve) => setTimeout(resolve, 200)); + } + throw new Error(`Shared MCP router did not start on port ${port}.`); +} + +async function ensureRouterProcess(port: number): Promise { + if (await routerHealthy(port)) return; + const child = spawn(process.execPath, [new URL("./shared-router-daemon.js", import.meta.url).pathname], { + detached: true, + stdio: "ignore", + env: { ...process.env, MCP_TAILSCALE_ROUTER_CONFIG: CONFIG_FILE }, + }); + child.unref(); + fs.mkdirSync(ROUTER_DIR, { recursive: true }); + fs.writeFileSync(PID_FILE, String(child.pid ?? "")); + await waitForRouter(port); +} + +async function ensureTailscaleFunnel(port: number): Promise { + const bin = await resolveTailscale(); + if (!bin) throw new Error("Tailscale CLI was not found. Install Tailscale or set a non-Tailscale tunnel provider."); + const env = tailscaleEnv(); + await new Promise((resolve) => { + const reset = spawn(bin, ["funnel", "reset"], { env, stdio: "ignore" }); + reset.on("close", () => resolve()); + reset.on("error", () => resolve()); + }); + await new Promise((resolve, reject) => { + const child = spawn(bin, ["funnel", "--bg", String(port)], { env, stdio: "ignore" }); + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) resolve(); + else reject(new Error(`Tailscale Funnel failed to publish shared router on port ${port} (exit ${code}).`)); + }); + }); +} + +export async function ensureSharedRouterRoute(options: EnsureSharedRouterOptions): Promise { + const prefix = normalizeRoutePrefix(options.prefix); + const config = loadRouterConfig(); + config.routes[options.name] = { + name: options.name, + prefix, + target: options.target.replace(/\/$/, ""), + }; + saveRouterConfig(config); + await ensureRouterProcess(config.port); + await ensureTailscaleFunnel(config.port); + const publicBaseUrl = options.publicBaseUrl.replace(/\/$/, ""); + return { publicBaseUrl, routeBaseUrl: `${publicBaseUrl}${prefix}` }; +} + +export function createSharedRouterServer(configFile = CONFIG_FILE): http.Server { + return http.createServer((req, res) => { + const url = new URL(req.url ?? "/", "http://127.0.0.1"); + if (url.pathname === "/__mcp_router/healthz") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: true, service: "mcp-tailscale-router" })); + return; + } + + const config = loadRouterConfig(configFile); + const route = Object.values(config.routes).find((candidate) => { + return url.pathname === candidate.prefix || url.pathname.startsWith(`${candidate.prefix}/`); + }); + if (!route) { + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: "route_not_found" })); + return; + } + + const target = new URL(route.target); + const stripped = url.pathname.slice(route.prefix.length) || "/"; + target.pathname = `${target.pathname.replace(/\/$/, "")}${stripped}`; + target.search = url.search; + + const headers = { ...req.headers }; + headers.host = target.host; + const proxy = http.request( + target, + { + method: req.method, + headers, + }, + (upstream) => { + res.writeHead(upstream.statusCode ?? 502, upstream.headers); + upstream.pipe(res); + }, + ); + proxy.on("error", (err) => { + if (!res.headersSent) res.writeHead(502, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: "upstream_error", message: err.message })); + }); + req.pipe(proxy); + }); +} + +export function routerConfigPath(): string { + return CONFIG_FILE; +} diff --git a/packages/dashboard/src/index.ts b/packages/dashboard/src/index.ts index 684932b..0513098 100644 --- a/packages/dashboard/src/index.ts +++ b/packages/dashboard/src/index.ts @@ -96,7 +96,7 @@ export function dashboardHtml(token = ""): string {