From 0088c522df5a72056d8dbd26411bd3fb0f434c1e Mon Sep 17 00:00:00 2001 From: luke-speechify <289678208+luke-speechify@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:58:38 +0100 Subject: [PATCH 1/3] feat(mcp): relay `speechify mcp` to the hosted MCP server (DRG-482) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the CLI's bespoke MCP server with a transparent stdio→streamable-HTTP relay to Speechify's hosted MCP at https://mcp.speechify.ai/mcp. The CLI defines no tools of its own; it forwards JSON-RPC verbatim, so the hosted tool surface (today `ask`/`search`) is what clients see and grows with no CLI release. - stdio only: drop the local `--http`/`--host`/`--port` server and its unauthenticated, key-bearing endpoint and warnings. - Forward the resolved API key upstream as `Authorization: Bearer`. Optional — the hosted tools are public — and wired for future authenticated pass-through. - Add `--url` to point the relay at a different endpoint (staging/testing). - Keep the `--accept-alpha` gate and `mcp install`. - Remove the local TTS/voice MCP tools and the docs-search proxy with the server. --- README.md | 48 +++----- src/commands/mcp.ts | 64 +++++----- src/mcp/run.test.ts | 59 +++++++++ src/mcp/run.ts | 143 ++++++++++------------ src/mcp/server.test.ts | 272 ----------------------------------------- src/mcp/server.ts | 265 --------------------------------------- 6 files changed, 176 insertions(+), 675 deletions(-) create mode 100644 src/mcp/run.test.ts delete mode 100644 src/mcp/server.test.ts delete mode 100644 src/mcp/server.ts diff --git a/README.md b/README.md index ebd2725..4b6f733 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ The command-line companion to the [Speechify API](https://speechify.ai). Authenticate with an API key, then drive the API from your terminal. > **Status: early.** API-key auth, `say`, `voices list`/`get`, a raw -> [`api`](#api) passthrough, and an [`mcp`](#mcp-server) server work today. Not +> [`api`](#api) passthrough, and an [`mcp`](#mcp-server) relay work today. Not > yet published to npm — run from source (see [Development](#development)). ## Authentication @@ -102,42 +102,33 @@ full `https://…` endpoint is used as-is. > **Alpha — expect changes.** The mcp surface is alpha, so `speechify mcp` and > `speechify mcp install` require an explicit `--accept-alpha` opt-in and refuse -> to run without it. The tool implementations behind this command are expected to -> move to a hosted server, with `speechify mcp` becoming a relay to it. Don't -> build on the MCP surface in its current form. +> to run without it. The relay's tool surface is defined by the hosted server and +> will grow. Don't build on it in its current form. -`speechify mcp` runs a [Model Context Protocol](https://modelcontextprotocol.io) -server so AI clients (Claude Code, Cursor, Claude Desktop, …) can use Speechify -directly. Tools: +`speechify mcp` is a thin [Model Context Protocol](https://modelcontextprotocol.io) +relay: it speaks MCP over **stdio** to your local AI client (Claude Code, Cursor, +Claude Desktop, …) and forwards every request, verbatim, to Speechify's hosted MCP +server at `https://mcp.speechify.ai/mcp`. The CLI defines no tools of its own — the +hosted server owns the surface, so new capabilities appear with no CLI upgrade. -- **`search_docs`** — search the public Speechify docs. No auth required. -- **`list_voices`** / **`get_voice`** — list account voices, or fetch one by id. *(requires an API key)* -- **`text_to_speech`** — synthesize audio, returned inline or written to a path. *(requires an API key)* -- **`stream_text_to_speech`** — synthesize long-form audio straight to a file. *(requires an API key)* +Today the hosted server exposes: -The TTS tools that write files confine `outputPath` to a relative path **inside -the server's working directory** and never overwrite an existing file — a path -that escapes the directory (absolute, `../…`) or collides with a file is refused. +- **`ask`** — a grounded, cited answer to a natural-language question about Speechify (API, SDKs, docs, demos, code samples). +- **`search`** — raw ranked source passages for a query, no synthesis. ```bash -speechify mcp --accept-alpha # serve over stdio (the usual MCP transport) -speechify mcp --accept-alpha --http --port 3000 # serve streamable HTTP at POST /mcp instead +speechify mcp --accept-alpha # relay to the hosted server over stdio +speechify mcp --accept-alpha --url # relay to a different endpoint (staging/testing) ``` -The HTTP transport binds **`127.0.0.1` only** by default: the endpoint is -unauthenticated and uses your API key on every call, so it must not be reachable -off-box. `--host ` can bind a wider interface, but only put your own -authentication (a reverse proxy, network policy) in front of it first. - -All tools are always registered, so they stay discoverable to agents regardless -of auth state. Auth is resolved **per tool call**, so a server started before -`speechify login` picks up the key the moment it's stored — no restart. Calling -an authenticated tool without a key returns a clear "run `speechify login`" error -instead of the tool not existing. +If an API key is available (`speechify login`, `--api-key`, or `$SPEECHIFY_API_KEY`) +the relay forwards it upstream as `Authorization: Bearer`. It's **optional** — the +`ask`/`search` tools are public — and is wired so the hosted server can expose +authenticated, API-backed tools later without a CLI change. ### Install into a client -`speechify mcp install` writes the server into a client's MCP config for you: +`speechify mcp install` writes the relay into a client's MCP config for you: ```bash speechify mcp install --accept-alpha --all # every detected client @@ -184,4 +175,5 @@ node dist/bin.js whoami `src/auth/session.ts` resolves an API key (flag / env / stored) into a single `AuthContext` (the Bearer). `src/core/client.ts` wraps the `@speechify/api` SDK for TTS. Commands in `src/commands/` are thin adapters over `src/core/`; -`src/mcp/` builds the MCP server on top of the same `core/` services. +`src/mcp/` relays a local stdio MCP client to the hosted Speechify MCP server, +forwarding the resolved Bearer upstream. diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index aaa1ed0..062adaf 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -1,20 +1,21 @@ -// `speechify mcp` — run the SpeechifyAI MCP server (stdio by default, or --http) so -// AI agents can search docs, list voices, and synthesize speech. -// `speechify mcp install` writes the server into local AI clients' configs. +// `speechify mcp` — relay the local MCP client to Speechify's hosted MCP server +// (https://mcp.speechify.ai/mcp) over stdio. The CLI defines no tools of its own; +// it forwards JSON-RPC verbatim, so the hosted tool surface (today `ask`/`search`) +// is what clients see. `speechify mcp install` writes the relay into local AI +// clients' configs. // // The mcp surface is ALPHA: both `mcp` and `mcp install` refuse to run without an // explicit `--accept-alpha` opt-in, and `mcp install` bakes that flag into the // spawned-server config it writes (see cliInvocation in mcp-install.ts). -import { type Command, Option } from "commander"; +import type { Command } from "commander"; +import { type AuthInput, resolveAuth } from "../auth/session.js"; import { CliError, ExitCode } from "../core/errors.js"; -import { DEFAULT_HTTP_HOST, runMcp } from "../mcp/run.js"; -import { type GlobalOptions, intArg } from "../options.js"; +import { DEFAULT_MCP_URL, runMcp } from "../mcp/run.js"; +import type { GlobalOptions } from "../options.js"; import { CLIENT_IDS, type McpInstallOptions, runMcpInstall } from "./mcp-install.js"; interface McpCommandOptions extends GlobalOptions { - http?: boolean; - port: number; - host?: string; + url: string; acceptAlpha?: boolean; } @@ -30,41 +31,44 @@ function assertAlphaOptIn(accepted: boolean | undefined): void { ); } +/** + * Resolve the API key to forward upstream, if one is available. The relay is usable + * unauthenticated — the hosted `ask`/`search` tools are public — so a missing key is + * not an error here: we simply relay without a bearer. Any other auth failure still + * propagates. + */ +async function optionalBearer(input: AuthInput): Promise { + try { + return (await resolveAuth(input)).bearer; + } catch (err) { + if (err instanceof CliError && err.code === "not_authenticated") return undefined; + throw err; + } +} + export function registerMcpCommand(program: Command): void { const mcp = program .command("mcp") - .description("(alpha) Run the MCP server over stdio (or --http) for AI agents. Requires --accept-alpha.") - .option("--http", "serve over streamable HTTP instead of stdio") - .option( - "--host ", - "interface to bind with --http (default 127.0.0.1; the endpoint is unauthenticated, so binding a wider interface exposes your API key)", - DEFAULT_HTTP_HOST, + .description( + "(alpha) Relay the local MCP client to Speechify's hosted MCP server over stdio, for AI agents. Requires --accept-alpha.", ) + .option("--url ", "upstream MCP endpoint to relay to", DEFAULT_MCP_URL) .option(ACCEPT_ALPHA_FLAG, ACCEPT_ALPHA_DESC) - .addOption( - new Option("--port ", "HTTP port (with --http)") - .default(3000) - .argParser(intArg("--port", { min: 1, max: 65535 })), - ) .action(async (_options: unknown, command: Command) => { const opts = command.optsWithGlobals() as McpCommandOptions; assertAlphaOptIn(opts.acceptAlpha); - await runMcp({ - http: opts.http, - port: opts.port, - host: opts.host, - authInput: { - apiKey: opts.apiKey, - apiVersion: opts.apiVersion, - baseUrl: opts.baseUrl, - }, + const bearer = await optionalBearer({ + apiKey: opts.apiKey, + apiVersion: opts.apiVersion, + baseUrl: opts.baseUrl, }); + await runMcp({ url: opts.url, bearer }); }); mcp .command("install") .description( - "(alpha) Install the MCP server into local AI clients (Claude Code, Cursor, Claude Desktop, …). Requires --accept-alpha.", + "(alpha) Install the MCP relay into local AI clients (Claude Code, Cursor, Claude Desktop, …). Requires --accept-alpha.", ) .option("--client ", `client id(s): ${CLIENT_IDS.join(", ")}`) .option("--all", "install into every detected client") diff --git a/src/mcp/run.test.ts b/src/mcp/run.test.ts new file mode 100644 index 0000000..f3ae472 --- /dev/null +++ b/src/mcp/run.test.ts @@ -0,0 +1,59 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { bridge } from "./run.js"; + +describe("mcp relay bridge", () => { + it("forwards initialize, tools/list and tools/call transparently to the upstream", async () => { + // Upstream: a real MCP server with one tool, standing in for mcp.speechify.ai. + const upstream = new McpServer({ name: "upstream", version: "0.0.0" }); + upstream.registerTool( + "echo", + { description: "Echo the input back", inputSchema: { text: z.string() } }, + async ({ text }) => ({ content: [{ type: "text", text: `echo: ${text}` }] }), + ); + + // Two linked pairs, bridged in the middle: + // client ── clientSide │ relayLocal ══bridge══ relayRemote │ serverSide ── upstream + const [serverSide, relayRemote] = InMemoryTransport.createLinkedPair(); + const [clientSide, relayLocal] = InMemoryTransport.createLinkedPair(); + + await upstream.connect(serverSide); + bridge(relayLocal, relayRemote); + await relayLocal.start(); + await relayRemote.start(); + + const client = new Client({ name: "downstream", version: "0.0.0" }); + await client.connect(clientSide); + + // tools/list resolves to the UPSTREAM surface — the relay declares nothing itself. + const { tools } = await client.listTools(); + expect(tools.map((t) => t.name)).toEqual(["echo"]); + + // tools/call round-trips through the relay to the upstream and back. + const result = await client.callTool({ name: "echo", arguments: { text: "hi" } }); + expect(result.content).toEqual([{ type: "text", text: "echo: hi" }]); + + await client.close(); + }); + + it("tears down both sides when one closes", async () => { + const [, local] = InMemoryTransport.createLinkedPair(); + const [, remote] = InMemoryTransport.createLinkedPair(); + + let remoteClosed = false; + const closeRemote = remote.close.bind(remote); + remote.close = async () => { + remoteClosed = true; + await closeRemote(); + }; + + bridge(local, remote); + // A close on the downstream (local) side must propagate to the upstream (remote). + local.onclose?.(); + + expect(remoteClosed).toBe(true); + }); +}); diff --git a/src/mcp/run.ts b/src/mcp/run.ts index 182a9aa..7d8610f 100644 --- a/src/mcp/run.ts +++ b/src/mcp/run.ts @@ -1,97 +1,80 @@ -// Transport wiring for the MCP server: stdio (default) or streamable HTTP. -import http from "node:http"; +// `speechify mcp` runs a thin relay: it speaks MCP over stdio to the local client +// (Claude Desktop, Cursor, Claude Code, …) and forwards every JSON-RPC message, +// verbatim, to Speechify's hosted MCP server over streamable HTTP. The CLI defines +// no tools of its own — the hosted server owns the entire surface (today `ask` and +// `search`), so new hosted capabilities appear here with no CLI release. +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; -import type { AuthInput } from "../auth/session.js"; -import { buildServer } from "./server.js"; +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; -/** HTTP mode binds loopback by default: the endpoint is unauthenticated and resolves - * the operator's API key per call, so it must not be reachable off-box unless the - * operator explicitly asks for it. */ -export const DEFAULT_HTTP_HOST = "127.0.0.1"; +/** Hosted Speechify MCP server. Overridable via --url for staging/testing. */ +export const DEFAULT_MCP_URL = "https://mcp.speechify.ai/mcp"; export interface McpOptions { - http?: boolean; - port: number; - /** Interface to bind in HTTP mode. Defaults to loopback ({@link DEFAULT_HTTP_HOST}). */ - host?: string; - authInput?: AuthInput; -} - -/** Loopback binds never expose the port off-box; anything else does. */ -function isLoopbackHost(host: string): boolean { - return host === "127.0.0.1" || host === "::1" || host === "localhost"; + /** Upstream MCP endpoint. Defaults to {@link DEFAULT_MCP_URL}. */ + url?: string; + /** + * API key to forward upstream as `Authorization: Bearer`. Optional: the hosted + * `ask`/`search` tools are public, so the relay works with no key; a key is passed + * through so the hosted server can expose authenticated, API-backed tools later. + */ + bearer?: string; } /** - * IMPORTANT: never write to stdout here — on the stdio transport, stdout IS the - * MCP protocol channel. All human-readable logging goes to stderr. + * MCP stdio uses stdout as the protocol channel, so every human-readable line — + * including errors — must go to stderr, never stdout. */ -function logStatus(transport: string): void { - process.stderr.write( - `SpeechifyAI MCP server (alpha) ready on ${transport}\n` + - "Tools: search_docs, list_voices, get_voice, text_to_speech, stream_text_to_speech " + - "(everything but search_docs needs a stored API key (`speechify login`) or SPEECHIFY_API_KEY; auth is resolved per call)\n", - ); +function report(side: "client" | "upstream", err: unknown): void { + process.stderr.write(`SpeechifyAI MCP relay: ${side} error: ${(err as Error)?.message ?? String(err)}\n`); } -export async function runMcp(opts: McpOptions): Promise { - if (opts.http) { - await runHttp(opts.port, opts.host ?? DEFAULT_HTTP_HOST, opts.authInput); - return; - } +/** + * Wire two transports into a bidirectional JSON-RPC relay: every message each side + * emits is forwarded verbatim to the other, and a close (or fatal error) on either + * end tears down both. Pure wiring — the caller starts the transports afterwards, + * since the Transport contract requires callbacks to be installed before `start()`. + */ +export function bridge(local: Transport, remote: Transport): void { + let closing = false; + const shutdown = () => { + if (closing) return; + closing = true; + void local.close(); + void remote.close(); + }; - const server = buildServer({ authInput: opts.authInput }); - await server.connect(new StdioServerTransport()); - logStatus("stdio"); - // The stdio transport keeps the process alive until the client disconnects. + // A failed forward is reported, not fatal — the peer may still recover — but a + // transport error callback signals the connection is done, so tear down. + local.onmessage = (message) => void remote.send(message).catch((err) => report("upstream", err)); + remote.onmessage = (message) => void local.send(message).catch((err) => report("client", err)); + local.onclose = shutdown; + remote.onclose = shutdown; + local.onerror = (err) => { + report("client", err); + shutdown(); + }; + remote.onerror = (err) => { + report("upstream", err); + shutdown(); + }; } -/** Stateless streamable-HTTP mode: a fresh server + transport per request. */ -async function runHttp(port: number, host: string, authInput?: AuthInput): Promise { - // DNS-rebinding protection needs the exact Host values the client will send. For a - // loopback bind those are host:port and localhost:port; for an explicit external - // bind we can't enumerate them, so protection is left to the operator's opt-in. - const loopback = isLoopbackHost(host); - const allowedHosts = loopback ? [`${host}:${port}`, `localhost:${port}`, `127.0.0.1:${port}`] : undefined; +export async function runMcp(opts: McpOptions = {}): Promise { + const url = opts.url ?? DEFAULT_MCP_URL; + const remote = new StreamableHTTPClientTransport(new URL(url), { + requestInit: opts.bearer ? { headers: { Authorization: `Bearer ${opts.bearer}` } } : undefined, + }); + const local = new StdioServerTransport(); - const httpServer = http.createServer((req, res) => { - if (req.method !== "POST" || (req.url !== "/mcp" && req.url !== "/")) { - res.writeHead(405, { Allow: "POST" }).end("Method Not Allowed"); - return; - } + bridge(local, remote); - const chunks: Buffer[] = []; - req.on("data", (c) => chunks.push(c as Buffer)); - req.on("end", async () => { - try { - const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : undefined; - const server = buildServer({ authInput }); - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: undefined, - enableDnsRebindingProtection: loopback, - ...(allowedHosts ? { allowedHosts } : {}), - }); - res.on("close", () => { - void transport.close(); - void server.close(); - }); - await server.connect(transport); - await transport.handleRequest(req, res, body); - } catch (err) { - if (!res.headersSent) res.writeHead(400); - res.end(JSON.stringify({ error: String((err as Error)?.message ?? err) })); - } - }); - }); + // Start upstream first so it is ready before the client's `initialize` arrives. + await remote.start(); + await local.start(); - // Bind the chosen interface explicitly — never let Node default to every - // interface (0.0.0.0/::), which would put an unauthenticated, key-bearing - // endpoint on the LAN. - if (!loopback) { - process.stderr.write( - `WARNING: binding ${host}:${port} exposes an UNAUTHENTICATED MCP endpoint that uses your Speechify API key to anyone who can reach this host. Use ${DEFAULT_HTTP_HOST} unless you have put your own auth in front of it.\n`, - ); - } - httpServer.listen(port, host, () => logStatus(`http://${host}:${port}/mcp`)); + process.stderr.write( + `SpeechifyAI MCP relay (alpha) → ${url} ready on stdio${opts.bearer ? " (authenticated)" : ""}\n`, + ); + // The stdio transport keeps the process alive until the client disconnects. } diff --git a/src/mcp/server.test.ts b/src/mcp/server.test.ts deleted file mode 100644 index 0c63940..0000000 --- a/src/mcp/server.test.ts +++ /dev/null @@ -1,272 +0,0 @@ -import { readFile, rm } from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -// SDK shape: client.audio.speech() / client.audio.stream() / client.voices.list() -// / client.voices.get(). SpeechifyError is pulled in transitively by -// core/errors.ts, so the mock must export it too. list() resolves to a plain -// array here — `for await` in core/voices.ts adapts sync iterables, so the -// paginated Page shape needs no mock mirror. -const sdk = vi.hoisted(() => ({ speech: vi.fn(), list: vi.fn(), get: vi.fn(), stream: vi.fn() })); -vi.mock("@speechify/api", () => ({ - SpeechifyClient: class { - audio = { speech: sdk.speech, stream: sdk.stream }; - voices = { list: sdk.list, get: sdk.get }; - }, - SpeechifyError: class SpeechifyError extends Error {}, -})); - -// Stub resolveAuth to an api-key context. -vi.mock("../auth/session.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - resolveAuth: vi.fn(async () => ({ bearer: "sk_test", baseUrl: "https://api.example", keySource: "flag" })), - }; -}); - -import { resolveAuth } from "../auth/session.js"; -import { CliError, ExitCode } from "../core/errors.js"; -import { buildServer } from "./server.js"; - -/** Pull the first text block's text out of a tool result (throws if absent). */ -function firstText(res: unknown): string { - const blocks = ((res as { content?: unknown }).content ?? []) as Array<{ type: string; text?: string }>; - const text = blocks.find((b) => b.type === "text")?.text; - if (text === undefined) throw new Error("no text content block"); - return text; -} - -async function connect(): Promise { - const server = buildServer({ authInput: {} }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await server.connect(serverTransport); - const client = new Client({ name: "test", version: "1" }); - await client.connect(clientTransport); - return client; -} - -/** Answer client.audio.stream() with a body carrying `text`. */ -function streamReturns(text: string, headers: Record = {}): void { - sdk.stream.mockReturnValue({ - withRawResponse: async () => ({ - data: { - stream: () => - new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(text)); - controller.close(); - }, - }), - }, - rawResponse: { headers: new Headers(headers) }, - }), - }); -} - -beforeEach(() => { - sdk.speech.mockReset(); - sdk.list.mockReset(); - sdk.get.mockReset(); - sdk.stream.mockReset(); -}); - -describe("buildServer tool registration", () => { - it("always registers every tool, regardless of auth state", async () => { - const client = await connect(); - const names = (await client.listTools()).tools.map((t) => t.name).sort(); - expect(names).toEqual(["get_voice", "list_voices", "search_docs", "stream_text_to_speech", "text_to_speech"]); - await client.close(); - }); - - it("returns a clean, actionable error when a TTS tool is called unauthenticated", async () => { - vi.mocked(resolveAuth).mockRejectedValueOnce( - new CliError("Not authenticated. Run `speechify login`.", { - exitCode: ExitCode.CONFIG, - code: "not_authenticated", - }), - ); - const client = await connect(); - const res = await client.callTool({ name: "list_voices", arguments: {} }); - expect(res.isError).toBe(true); - expect(firstText(res)).toContain("speechify login"); - await client.close(); - }); -}); - -describe("list_voices tool", () => { - it("returns the voices mapped from the v3 SDK", async () => { - sdk.list.mockResolvedValue([ - { - id: "george", - display_name: "George", - gender: "male", - locale: "en-US", - type: "shared", - models: [{ name: "simba-english" }], - tags: [], - }, - ]); - const client = await connect(); - const res = await client.callTool({ name: "list_voices", arguments: {} }); - expect(JSON.parse(firstText(res))).toEqual([ - { - id: "george", - displayName: "George", - gender: "male", - locale: "en-US", - type: "shared", - models: ["simba-english"], - tags: [], - }, - ]); - await client.close(); - }); -}); - -describe("get_voice tool", () => { - it("returns the single voice mapped from the v3 SDK", async () => { - sdk.get.mockResolvedValue({ - id: "george", - display_name: "George", - gender: "male", - locale: "en-US", - type: "shared", - models: [{ name: "simba-english", languages: [{ locale: "en-US", preview_audio: "https://cdn/en.mp3" }] }], - preview_audio: "https://cdn/en.mp3", - avatar_image: "", - tags: [], - }); - const client = await connect(); - const res = await client.callTool({ name: "get_voice", arguments: { voiceId: "george" } }); - - expect(sdk.get).toHaveBeenCalledWith({ voice_id: "george" }); - expect(JSON.parse(firstText(res))).toEqual({ - id: "george", - displayName: "George", - gender: "male", - locale: "en-US", - type: "shared", - models: [{ name: "simba-english", languages: [{ locale: "en-US", previewAudio: "https://cdn/en.mp3" }] }], - tags: [], - previewAudio: "https://cdn/en.mp3", - }); - await client.close(); - }); - - it("rejects an empty voice id as a tool error without calling the API", async () => { - const client = await connect(); - const res = await client.callTool({ name: "get_voice", arguments: { voiceId: " " } }); - - expect(res.isError).toBe(true); - expect(firstText(res)).toContain("voices list"); - expect(sdk.get).not.toHaveBeenCalled(); - await client.close(); - }); -}); - -describe("text_to_speech tool", () => { - // outputPath is caller/model-controlled, so the server confines it to a relative - // path inside the working directory — an absolute tmpdir path is refused. - const outPath = `speechify-tts-${process.pid}.mp3`; - afterEach(() => rm(outPath, { force: true })); - - it("writes a file when outputPath is given", async () => { - sdk.speech.mockResolvedValue({ - audio_data: Buffer.from("AUDIOBYTES").toString("base64"), - audio_format: "mp3", - billable_characters_count: 5, - }); - const client = await connect(); - const res = await client.callTool({ - name: "text_to_speech", - arguments: { input: "hello", voiceId: "george", outputPath: outPath }, - }); - expect(firstText(res)).toContain(outPath); - expect(await readFile(outPath, "utf8")).toBe("AUDIOBYTES"); - await client.close(); - }); - - it("refuses an outputPath that escapes the working directory", async () => { - sdk.speech.mockResolvedValue({ - audio_data: Buffer.from("X").toString("base64"), - audio_format: "mp3", - billable_characters_count: 1, - }); - const client = await connect(); - for (const escaping of [path.join(os.tmpdir(), "pwn.mp3"), "../pwn.mp3", "/etc/pwned"]) { - const res = await client.callTool({ - name: "text_to_speech", - arguments: { input: "hi", outputPath: escaping }, - }); - expect(res.isError).toBe(true); - expect(firstText(res)).toMatch(/working directory|resolves outside/i); - } - // Never reached the API — a rejected path spends nothing. - expect(sdk.speech).not.toHaveBeenCalled(); - await client.close(); - }); - - it("returns inline audio when no outputPath is given", async () => { - sdk.speech.mockResolvedValue({ - audio_data: Buffer.from("XYZ").toString("base64"), - audio_format: "mp3", - billable_characters_count: 3, - }); - const client = await connect(); - const res = await client.callTool({ name: "text_to_speech", arguments: { input: "hi" } }); - const audio = (res.content as Array<{ type: string; data?: string; mimeType?: string }>).find( - (c) => c.type === "audio", - ); - expect(audio?.mimeType).toBe("audio/mpeg"); - expect(audio?.data).toBe(Buffer.from("XYZ").toString("base64")); - await client.close(); - }); -}); - -describe("stream_text_to_speech tool", () => { - const outPath = `speechify-stream-${process.pid}.mp3`; - afterEach(() => rm(outPath, { force: true })); - - it("writes the audio to the caller's path and returns the path, never the bytes", async () => { - streamReturns("STREAMEDAUDIO", { "content-type": "audio/mpeg" }); - const client = await connect(); - - const res = await client.callTool({ - name: "stream_text_to_speech", - arguments: { input: "a long article", outputPath: outPath }, - }); - - expect(await readFile(outPath, "utf8")).toBe("STREAMEDAUDIO"); - expect(firstText(res)).toContain(outPath); - expect(firstText(res)).toContain("13 bytes"); - // The point of this tool: the model pays for a path, not a megabyte of base64. - expect((res.content as Array<{ type: string }>).every((block) => block.type === "text")).toBe(true); - await client.close(); - }); - - it("requires an output path, so it can never dump audio into the conversation", async () => { - const client = await connect(); - const res = await client.callTool({ name: "stream_text_to_speech", arguments: { input: "hello" } }); - expect(res.isError).toBe(true); - await client.close(); - }); - - it("sends the container as the Accept header", async () => { - streamReturns("OGGBYTES", { "content-type": "audio/ogg" }); - const client = await connect(); - - await client.callTool({ - name: "stream_text_to_speech", - arguments: { input: "hello", outputPath: outPath, audioFormat: "ogg", voiceId: "henry" }, - }); - - expect(sdk.stream).toHaveBeenCalledWith( - expect.objectContaining({ Accept: "audio/ogg", body: expect.objectContaining({ voice_id: "henry" }) }), - ); - await client.close(); - }); -}); diff --git a/src/mcp/server.ts b/src/mcp/server.ts deleted file mode 100644 index 84bd20a..0000000 --- a/src/mcp/server.ts +++ /dev/null @@ -1,265 +0,0 @@ -// SpeechifyAI MCP server — exposes docs search + TTS tools to MCP clients (Claude -// Code, Cursor, Claude Desktop, …). -// -// All tools are always registered so they stay discoverable regardless of auth -// state. `search_docs` needs no auth. The TTS tools resolve our API key FRESH per -// call via resolveAuth(): a server started before `speechify login` starts working -// the moment a key is stored — no restart. When auth is missing, the call surfaces -// a clear "run login" error (the MCP SDK returns the CliError message as an isError -// tool result) rather than the tool silently not existing. -import { writeFile } from "node:fs/promises"; -import { isAbsolute, relative, resolve } from "node:path"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { assertPathAvailable, writeStreamToFile } from "../audio/sink.js"; -import { type AuthInput, resolveAuth } from "../auth/session.js"; -import { createClient } from "../core/client.js"; -import { CliError, ExitCode } from "../core/errors.js"; -import { resolveTimeoutMs } from "../core/fetchWithTimeout.js"; -import { - AUDIO_FORMATS, - AUDIO_MIME, - DEFAULT_STREAM_FORMAT, - DEFAULT_VOICE, - MAX_SPEECH_INPUT, - MAX_STREAM_INPUT, - SPEECH_MODELS, - STREAM_FORMATS, - streamSpeech, - synthesize, -} from "../core/speech.js"; -import { readStreamChunks } from "../core/stream.js"; -import { getVoice, listVoices } from "../core/voices.js"; - -/** Public, unauthenticated docs MCP server hosted by Fern for docs.speechify.ai. */ -const DOCS_MCP_URL = "https://docs.speechify.ai/_mcp/server"; - -/** - * Proxy a query to the public Speechify docs MCP server. We connect as an MCP - * client, discover the search tool (resilient to its exact name/argument), call - * it, and return the text blocks. No API key required. - */ -async function callDocsSearch(query: string): Promise { - // Every round-trip is bounded by the shared HTTP timeout so a half-open or - // unresponsive docs server can't hang the tool call forever. - const requestOptions = { timeout: resolveTimeoutMs() }; - const client = new Client({ name: "speechify-cli", version: __CLI_VERSION__ }); - await client.connect(new StreamableHTTPClientTransport(new URL(DOCS_MCP_URL)), requestOptions); - try { - const { tools } = await client.listTools(undefined, requestOptions); - const tool = tools.find((t) => /search/i.test(t.name)) ?? tools[0]; - if (!tool) throw new Error("The Speechify docs MCP server exposed no tools."); - - // Use the tool's first required (or first declared) property as the query arg. - const schema = (tool.inputSchema ?? {}) as { properties?: Record; required?: string[] }; - const argName = schema.required?.[0] ?? Object.keys(schema.properties ?? {})[0] ?? "query"; - - const result = await client.callTool( - { name: tool.name, arguments: { [argName]: query } }, - undefined, - requestOptions, - ); - const blocks = (result.content ?? []) as Array<{ type: string; text?: string }>; - return ( - blocks - .filter((b) => b.type === "text" && b.text) - .map((b) => b.text) - .join("\n\n") || "(no textual results)" - ); - } finally { - await client.close(); - } -} - -export interface ServerOptions { - /** Per-invocation auth overrides; resolveAuth() applies flag/env/stored precedence. */ - authInput?: AuthInput; -} - -/** - * Resolve a tool-supplied `outputPath` to a concrete file, safely. The value comes - * from the MCP caller (often a model acting on untrusted text), so it is confined - * to the server's working directory and never allowed to overwrite an existing - * file: a prompt-injection payload can't write `~/.ssh/authorized_keys`, escape via - * `../`, or clobber a file the operator cares about. - */ -async function resolveOutputPath(outputPath: string): Promise { - const cwd = process.cwd(); - const resolved = resolve(cwd, outputPath); - const rel = relative(cwd, resolved); - if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) { - throw new CliError( - `outputPath must be a relative path inside the working directory (${cwd}); "${outputPath}" resolves outside it.`, - { exitCode: ExitCode.DATA_ERR, code: "output_path_escapes_cwd" }, - ); - } - // Never overwrite: a colliding name is refused rather than silently replaced. - await assertPathAvailable(resolved); - return resolved; -} - -/** - * Build the SpeechifyAI MCP server. All tools are always registered; the TTS tools - * resolve auth per call and surface a clear error when the caller isn't authed. - */ -export function buildServer({ authInput = {} }: ServerOptions = {}): McpServer { - const server = new McpServer({ name: "speechify", version: __CLI_VERSION__ }); - - server.registerTool( - "search_docs", - { - description: - "Search the Speechify documentation (docs.speechify.ai) and return relevant excerpts. No API key required.", - inputSchema: { query: z.string().describe("What to look up in the Speechify docs") }, - }, - async ({ query }) => ({ content: [{ type: "text", text: await callDocsSearch(query) }] }), - ); - - // Resolve auth + a TTS client per call so a login that happens after the server - // starts is picked up without a restart. On failure this throws a CliError whose - // message the SDK returns as a tool error. - const ttsClient = async () => { - const auth = await resolveAuth(authInput); - return createClient({ - bearer: auth.bearer, - apiVersion: auth.apiVersion, - baseUrl: auth.baseUrl, - }); - }; - - server.registerTool( - "list_voices", - { - description: - "List the Speechify voices available to the authenticated account. Requires a `speechify login` session or SPEECHIFY_API_KEY.", - inputSchema: {}, - }, - async () => { - const voices = await listVoices(await ttsClient()); - return { content: [{ type: "text", text: JSON.stringify(voices, null, 2) }] }; - }, - ); - - server.registerTool( - "get_voice", - { - description: - "Fetch one Speechify voice by id, with the models it supports, the locales each model covers, its tags, and its preview URLs. Use it to confirm a voice id is usable before synthesizing, instead of listing the whole catalog. Requires a `speechify login` session or SPEECHIFY_API_KEY.", - inputSchema: { - voiceId: z.string().describe("Id of the voice to fetch (see list_voices), e.g. 'george'"), - }, - }, - async ({ voiceId }) => { - const voice = await getVoice(await ttsClient(), voiceId); - return { content: [{ type: "text", text: JSON.stringify(voice, null, 2) }] }; - }, - ); - - server.registerTool( - "text_to_speech", - { - description: - "Synthesize speech audio from text or SSML using Speechify. Returns the audio inline, or writes it to outputPath when provided. Requires a `speechify login` session or SPEECHIFY_API_KEY.", - inputSchema: { - input: z.string().describe("Plain text or SSML to synthesize"), - voiceId: z - .string() - .default(DEFAULT_VOICE) - .describe(`Voice id (see list_voices). Defaults to '${DEFAULT_VOICE}'.`), - model: z.enum(SPEECH_MODELS).optional().describe("Synthesis model"), - audioFormat: z.enum(AUDIO_FORMATS).default("mp3").describe("Output audio format"), - language: z.string().optional().describe("Input language as BCP-47 (e.g. en-US)"), - outputPath: z - .string() - .optional() - .describe("If set, write the audio to this file and return the path instead of inline audio."), - }, - }, - async ({ input, voiceId, model, audioFormat, language, outputPath }) => { - // Settle (and confine) the destination before spending a synthesis on a write - // we'd refuse anyway. - const target = outputPath ? await resolveOutputPath(outputPath) : undefined; - const result = await synthesize(await ttsClient(), { - input, - voiceId, - model, - format: audioFormat, - language, - }); - - if (target) { - await writeFile(target, result.audio); - return { - content: [ - { - type: "text", - text: `Wrote ${result.audio.length} bytes (${result.format}) to ${target}. Billable characters: ${result.billableCharacters}.`, - }, - ], - }; - } - - return { - content: [ - { type: "audio", data: result.audio.toString("base64"), mimeType: AUDIO_MIME[result.format] }, - { - type: "text", - text: `Synthesized ${result.audio.length} bytes (${result.format}). Billable characters: ${result.billableCharacters}.`, - }, - ], - }; - }, - ); - - server.registerTool( - "stream_text_to_speech", - { - description: - `Synthesize long-form speech (up to ${MAX_STREAM_INPUT} characters) and write it to outputPath as it is generated. ` + - `Reach for this instead of text_to_speech whenever the text exceeds ${MAX_SPEECH_INPUT} characters, or when the audio should stay out of the conversation. ` + - "Returns the path and byte count, never the audio itself. Requires a `speechify login` session or SPEECHIFY_API_KEY.", - inputSchema: { - input: z.string().describe("Plain text or SSML to synthesize"), - outputPath: z - .string() - .describe("File to write the audio to. Required: streamed audio is never returned inline."), - voiceId: z - .string() - .default(DEFAULT_VOICE) - .describe(`Voice id (see list_voices). Defaults to '${DEFAULT_VOICE}'.`), - model: z.enum(SPEECH_MODELS).optional().describe("Synthesis model"), - audioFormat: z - .enum(STREAM_FORMATS) - .default(DEFAULT_STREAM_FORMAT) - .describe("Output audio format (wav is unavailable on the streaming route)"), - language: z.string().optional().describe("Input language as BCP-47 (e.g. en-US)"), - }, - }, - async ({ input, outputPath, voiceId, model, audioFormat, language }) => { - const target = await resolveOutputPath(outputPath); - const result = await streamSpeech(await ttsClient(), { - input, - voiceId, - model, - format: audioFormat, - language, - }); - const bytes = await writeStreamToFile( - readStreamChunks(result.body, { stallTimeoutMs: resolveTimeoutMs() }), - target, - ); - return { - content: [ - { - type: "text", - text: `Wrote ${bytes} bytes of ${result.audio.codec} audio to ${target}. The streaming route reports no billable character count.`, - }, - ], - }; - }, - ); - - return server; -} From c3b89517039d4e194551a1e8fe90cc4096f1348a Mon Sep 17 00:00:00 2001 From: luke-speechify <289678208+luke-speechify@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:09:05 +0100 Subject: [PATCH 2/3] feat(mcp): reject --accept-alpha now the relay has graduated (DRG-482) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mcp surface is no longer alpha. `speechify mcp` and `mcp install` now REJECT `--accept-alpha` with a clear instruction to remove it (exit 78, code `alpha_flag_removed`), so a stale client config written by an older CLI fails loudly and prompts a re-install instead of silently passing a dead flag. - `mcp install` no longer bakes `--accept-alpha` into the config it writes (`cliInvocation` → args `["mcp"]`). - Drop the "(alpha) … Requires --accept-alpha" wording from command help. - README: replace the alpha note, remove the flag from every example. - Tests: assert both subcommands reject the flag, install works without it, and cliInvocation emits no `--accept-alpha`. --- README.md | 25 ++++++++++---------- src/commands/mcp-install.ts | 10 ++++---- src/commands/mcp.test.ts | 41 ++++++++++++++++++++++++-------- src/commands/mcp.ts | 47 +++++++++++++++++++++---------------- 4 files changed, 76 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 4b6f733..2962c10 100644 --- a/README.md +++ b/README.md @@ -100,10 +100,11 @@ full `https://…` endpoint is used as-is. ## MCP server -> **Alpha — expect changes.** The mcp surface is alpha, so `speechify mcp` and -> `speechify mcp install` require an explicit `--accept-alpha` opt-in and refuse -> to run without it. The relay's tool surface is defined by the hosted server and -> will grow. Don't build on it in its current form. +> **No longer alpha.** The old `--accept-alpha` opt-in has been removed — +> `speechify mcp` now **rejects** that flag with guidance to drop it. If you +> installed the server with an older CLI, re-run `speechify mcp install` to update +> the config. The relay's tool surface is defined by the hosted server and grows +> without a CLI upgrade. `speechify mcp` is a thin [Model Context Protocol](https://modelcontextprotocol.io) relay: it speaks MCP over **stdio** to your local AI client (Claude Code, Cursor, @@ -117,8 +118,8 @@ Today the hosted server exposes: - **`search`** — raw ranked source passages for a query, no synthesis. ```bash -speechify mcp --accept-alpha # relay to the hosted server over stdio -speechify mcp --accept-alpha --url # relay to a different endpoint (staging/testing) +speechify mcp # relay to the hosted server over stdio +speechify mcp --url # relay to a different endpoint (staging/testing) ``` If an API key is available (`speechify login`, `--api-key`, or `$SPEECHIFY_API_KEY`) @@ -131,10 +132,10 @@ authenticated, API-backed tools later without a CLI change. `speechify mcp install` writes the relay into a client's MCP config for you: ```bash -speechify mcp install --accept-alpha --all # every detected client -speechify mcp install --accept-alpha --client claude-code cursor # specific clients -speechify mcp install --accept-alpha --print # print the config block, write nothing -speechify mcp install --accept-alpha --client vscode --embed-key # bake $SPEECHIFY_API_KEY into the entry +speechify mcp install --all # every detected client +speechify mcp install --client claude-code cursor # specific clients +speechify mcp install --print # print the config block, write nothing +speechify mcp install --client vscode --embed-key # bake $SPEECHIFY_API_KEY into the entry ``` Supported ids: `claude-code`, `cursor`, `claude-desktop`, `windsurf`, `vscode`. @@ -150,12 +151,12 @@ on your `PATH`): ```json { "mcpServers": { - "speechify": { "command": "speechify", "args": ["mcp", "--accept-alpha"] } + "speechify": { "command": "speechify", "args": ["mcp"] } } } ``` -Run `speechify mcp install --accept-alpha --print` to see the exact command for your +Run `speechify mcp install --print` to see the exact command for your setup — until the CLI is published, it spawns the running binary by absolute path. ## Development diff --git a/src/commands/mcp-install.ts b/src/commands/mcp-install.ts index 0a9298d..b6bf08b 100644 --- a/src/commands/mcp-install.ts +++ b/src/commands/mcp-install.ts @@ -98,12 +98,12 @@ export function clients(): McpClient[] { * npx. (Once published, this can simplify to `npx -y @speechify/cli mcp`.) */ export function cliInvocation(): CliInvocation { - // `mcp` is alpha-gated, so the config we write must carry --accept-alpha or the - // spawned server would refuse to start. Installing already required the caller - // to pass --accept-alpha, so the opt-in is theirs, not implicit. + // The mcp surface is no longer alpha, so the config we write must NOT carry + // --accept-alpha — the relay now rejects that flag. (Configs written by an older + // CLI still carry it and will fail loudly, prompting a re-install.) const script = process.argv[1]; - if (!script) return { command: "speechify", args: ["mcp", "--accept-alpha"] }; - return { command: process.execPath, args: [path.resolve(script), "mcp", "--accept-alpha"] }; + if (!script) return { command: "speechify", args: ["mcp"] }; + return { command: process.execPath, args: [path.resolve(script), "mcp"] }; } /** Build the per-client server entry (pure). */ diff --git a/src/commands/mcp.test.ts b/src/commands/mcp.test.ts index acb109f..a634ac0 100644 --- a/src/commands/mcp.test.ts +++ b/src/commands/mcp.test.ts @@ -1,5 +1,5 @@ import { Command } from "commander"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { registerMcpCommand } from "./mcp.js"; import { cliInvocation } from "./mcp-install.js"; @@ -11,25 +11,46 @@ function buildProgram(): Command { return program; } -describe("mcp alpha gate", () => { - it("`mcp` refuses to run without --accept-alpha", async () => { - await expect(buildProgram().parseAsync(["node", "speechify", "mcp"])).rejects.toMatchObject({ - code: "alpha_opt_in_required", +describe("mcp alpha flag removed", () => { + it("`mcp --accept-alpha` refuses and tells the caller to drop the flag", async () => { + await expect(buildProgram().parseAsync(["node", "speechify", "mcp", "--accept-alpha"])).rejects.toMatchObject({ + code: "alpha_flag_removed", exitCode: 78, }); }); - it("`mcp install` refuses to run without --accept-alpha", async () => { + it("`mcp install --accept-alpha` refuses too", async () => { await expect( - buildProgram().parseAsync(["node", "speechify", "mcp", "install", "--print", "--client", "claude-code"]), - ).rejects.toMatchObject({ code: "alpha_opt_in_required", exitCode: 78 }); + buildProgram().parseAsync([ + "node", + "speechify", + "mcp", + "install", + "--accept-alpha", + "--print", + "--client", + "claude-code", + ]), + ).rejects.toMatchObject({ code: "alpha_flag_removed", exitCode: 78 }); + }); +}); + +describe("mcp install without the alpha flag", () => { + afterEach(() => vi.restoreAllMocks()); + + it("prints a config block and no longer requires --accept-alpha", async () => { + const write = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + await buildProgram().parseAsync(["node", "speechify", "mcp", "install", "--print", "--client", "claude-code"]); + const printed = write.mock.calls.map((c) => String(c[0])).join(""); + expect(printed).toContain("mcpServers"); + expect(printed).not.toContain("--accept-alpha"); }); }); describe("cliInvocation", () => { - it("bakes `mcp --accept-alpha` into the spawned server args so installed configs still start", () => { + it("spawns `mcp` without --accept-alpha so installed configs use the graduated surface", () => { const { args } = cliInvocation(); expect(args).toContain("mcp"); - expect(args).toContain("--accept-alpha"); + expect(args).not.toContain("--accept-alpha"); }); }); diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 062adaf..c14a981 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -4,10 +4,11 @@ // is what clients see. `speechify mcp install` writes the relay into local AI // clients' configs. // -// The mcp surface is ALPHA: both `mcp` and `mcp install` refuse to run without an -// explicit `--accept-alpha` opt-in, and `mcp install` bakes that flag into the -// spawned-server config it writes (see cliInvocation in mcp-install.ts). -import type { Command } from "commander"; +// The mcp surface is no longer alpha. The old `--accept-alpha` opt-in is now +// REJECTED with guidance to drop it — a stale installed config that still passes +// it fails loudly rather than silently ignoring a flag that no longer means +// anything. `mcp install` no longer bakes the flag in (see mcp-install.ts). +import { type Command, Option } from "commander"; import { type AuthInput, resolveAuth } from "../auth/session.js"; import { CliError, ExitCode } from "../core/errors.js"; import { DEFAULT_MCP_URL, runMcp } from "../mcp/run.js"; @@ -20,17 +21,27 @@ interface McpCommandOptions extends GlobalOptions { } const ACCEPT_ALPHA_FLAG = "--accept-alpha"; -const ACCEPT_ALPHA_DESC = "acknowledge the mcp command is alpha and may change or break without notice"; -/** Gate the alpha mcp surface: refuse to run unless the caller opted in. */ -function assertAlphaOptIn(accepted: boolean | undefined): void { - if (accepted) return; +/** + * The mcp surface graduated from alpha. `--accept-alpha` is still declared (hidden) + * only so we can give a clear error instead of commander's "unknown option": the + * flag is no longer accepted, and passing it — including from a client config + * installed by an older CLI — fails with instructions to remove it. + */ +function rejectAlphaFlag(passed: boolean | undefined): void { + if (!passed) return; throw new CliError( - "`speechify mcp` is alpha and may change or break without notice. Re-run with --accept-alpha to opt in.", - { exitCode: ExitCode.CONFIG, code: "alpha_opt_in_required" }, + "`speechify mcp` is no longer alpha — remove --accept-alpha to use the MCP relay. " + + "If it came from a client config, re-run `speechify mcp install` to update it.", + { exitCode: ExitCode.CONFIG, code: "alpha_flag_removed" }, ); } +/** The hidden, no-op `--accept-alpha` flag, declared so we can reject it clearly. */ +function alphaOption(): Option { + return new Option(ACCEPT_ALPHA_FLAG).hideHelp(); +} + /** * Resolve the API key to forward upstream, if one is available. The relay is usable * unauthenticated — the hosted `ask`/`search` tools are public — so a missing key is @@ -49,14 +60,12 @@ async function optionalBearer(input: AuthInput): Promise { export function registerMcpCommand(program: Command): void { const mcp = program .command("mcp") - .description( - "(alpha) Relay the local MCP client to Speechify's hosted MCP server over stdio, for AI agents. Requires --accept-alpha.", - ) + .description("Relay the local MCP client to Speechify's hosted MCP server over stdio, for AI agents.") .option("--url ", "upstream MCP endpoint to relay to", DEFAULT_MCP_URL) - .option(ACCEPT_ALPHA_FLAG, ACCEPT_ALPHA_DESC) + .addOption(alphaOption()) .action(async (_options: unknown, command: Command) => { const opts = command.optsWithGlobals() as McpCommandOptions; - assertAlphaOptIn(opts.acceptAlpha); + rejectAlphaFlag(opts.acceptAlpha); const bearer = await optionalBearer({ apiKey: opts.apiKey, apiVersion: opts.apiVersion, @@ -67,17 +76,15 @@ export function registerMcpCommand(program: Command): void { mcp .command("install") - .description( - "(alpha) Install the MCP relay into local AI clients (Claude Code, Cursor, Claude Desktop, …). Requires --accept-alpha.", - ) + .description("Install the MCP relay into local AI clients (Claude Code, Cursor, Claude Desktop, …).") .option("--client ", `client id(s): ${CLIENT_IDS.join(", ")}`) .option("--all", "install into every detected client") .option("--print", "print the config block instead of writing it") .option("--embed-key", "embed $SPEECHIFY_API_KEY in the client env (default: rely on the stored session)") - .option(ACCEPT_ALPHA_FLAG, ACCEPT_ALPHA_DESC) + .addOption(alphaOption()) .action(async (_options: unknown, command: Command) => { const opts = command.optsWithGlobals() as GlobalOptions & McpInstallOptions & { acceptAlpha?: boolean }; - assertAlphaOptIn(opts.acceptAlpha); + rejectAlphaFlag(opts.acceptAlpha); await runMcpInstall({ client: opts.client, all: opts.all, From 36cd7005ce82da5a905fb73e4c3115a17d426bc3 Mon Sep 17 00:00:00 2001 From: luke-speechify <289678208+luke-speechify@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:40:44 +0100 Subject: [PATCH 3/3] docs(mcp): drop hardcoded tool list; per-client setup in accordions The hosted server advertises its own tools to clients, so the README no longer enumerates them (they'd only go stale). Replace the flat install list with a collapsible
accordion per client (Claude Code, Cursor, Claude Desktop, Windsurf, VS Code), each with its install command, config path, and manual entry. --- README.md | 134 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 110 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 2962c10..5c1f4df 100644 --- a/README.md +++ b/README.md @@ -110,12 +110,8 @@ full `https://…` endpoint is used as-is. relay: it speaks MCP over **stdio** to your local AI client (Claude Code, Cursor, Claude Desktop, …) and forwards every request, verbatim, to Speechify's hosted MCP server at `https://mcp.speechify.ai/mcp`. The CLI defines no tools of its own — the -hosted server owns the surface, so new capabilities appear with no CLI upgrade. - -Today the hosted server exposes: - -- **`ask`** — a grounded, cited answer to a natural-language question about Speechify (API, SDKs, docs, demos, code samples). -- **`search`** — raw ranked source passages for a query, no synthesis. +hosted server owns the surface, so its tools show up in your client automatically +and grow with no CLI upgrade. ```bash speechify mcp # relay to the hosted server over stdio @@ -123,30 +119,74 @@ speechify mcp --url # relay to a different endpoint (staging/testing) ``` If an API key is available (`speechify login`, `--api-key`, or `$SPEECHIFY_API_KEY`) -the relay forwards it upstream as `Authorization: Bearer`. It's **optional** — the -`ask`/`search` tools are public — and is wired so the hosted server can expose -authenticated, API-backed tools later without a CLI change. +the relay forwards it upstream as `Authorization: Bearer`. It's **optional** and +wired so the hosted server can expose authenticated, API-backed tools later without +a CLI change. ### Install into a client -`speechify mcp install` writes the relay into a client's MCP config for you: +`speechify mcp install` writes the relay into a client's MCP config for you — or +add it by hand. By default no credential is embedded (the relay reads your stored +API key); `--embed-key` bakes `$SPEECHIFY_API_KEY` into the entry instead, writing +the key **in plaintext** (file set to `0600`). A config that can't be parsed safely +(e.g. JSONC with comments) is left untouched — add the block by hand in that case. + +```bash +speechify mcp install --all # every detected client +speechify mcp install --print # print the config block, write nothing +``` + +
+Claude Code + +```bash +speechify mcp install --client claude-code +# or, using Claude Code's own CLI: +claude mcp add speechify -- speechify mcp +``` + +Config: `~/.claude.json` (key `mcpServers`). Manual entry: + +```json +{ + "mcpServers": { + "speechify": { "command": "speechify", "args": ["mcp"] } + } +} +``` +
+ +
+Cursor + +```bash +speechify mcp install --client cursor +``` + +Config: `~/.cursor/mcp.json` (key `mcpServers`). Manual entry: + +```json +{ + "mcpServers": { + "speechify": { "command": "speechify", "args": ["mcp"] } + } +} +``` +
+ +
+Claude Desktop ```bash -speechify mcp install --all # every detected client -speechify mcp install --client claude-code cursor # specific clients -speechify mcp install --print # print the config block, write nothing -speechify mcp install --client vscode --embed-key # bake $SPEECHIFY_API_KEY into the entry +speechify mcp install --client claude-desktop ``` -Supported ids: `claude-code`, `cursor`, `claude-desktop`, `windsurf`, `vscode`. -By default no credential is embedded — the spawned server reads your stored API -key. `--embed-key` bakes `$SPEECHIFY_API_KEY` into the entry instead, writing the -key **in plaintext** into the client's config (the file is set to `0600`); prefer -the stored keychain credential unless a client can't reach it. An existing config -that can't be parsed safely (e.g. JSONC with comments) is left untouched. +Config (`mcpServers` key): +- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` +- Windows: `%APPDATA%\Claude\claude_desktop_config.json` +- Linux: `~/.config/Claude/claude_desktop_config.json` -To wire it up manually instead, the stdio entry looks like this (once the CLI is -on your `PATH`): +Manual entry: ```json { @@ -156,8 +196,54 @@ on your `PATH`): } ``` -Run `speechify mcp install --print` to see the exact command for your -setup — until the CLI is published, it spawns the running binary by absolute path. +Restart Claude Desktop to load the server. +
+ +
+Windsurf + +```bash +speechify mcp install --client windsurf +``` + +Config: `~/.codeium/windsurf/mcp_config.json` (key `mcpServers`). Manual entry: + +```json +{ + "mcpServers": { + "speechify": { "command": "speechify", "args": ["mcp"] } + } +} +``` +
+ +
+VS Code + +```bash +speechify mcp install --client vscode +``` + +Config: `mcp.json` in your VS Code user directory (key `servers`; each entry needs +an explicit `"type": "stdio"`): +- macOS: `~/Library/Application Support/Code/User/mcp.json` +- Windows: `%APPDATA%\Code\User\mcp.json` +- Linux: `~/.config/Code/User/mcp.json` + +Manual entry: + +```json +{ + "servers": { + "speechify": { "type": "stdio", "command": "speechify", "args": ["mcp"] } + } +} +``` +
+ +Run `speechify mcp install --print` to see the exact command for your setup — until +the CLI is published, it spawns the running binary by absolute path rather than a +bare `speechify` on your `PATH`. ## Development