diff --git a/.wave/repo.json b/.wave/repo.json index f40940a..a098708 100644 --- a/.wave/repo.json +++ b/.wave/repo.json @@ -2,7 +2,7 @@ "name": "@wave-av/mcp-server", "kind": "mcp-server", "domain": "agent-ops", - "purpose": "MCP (Model Context Protocol) server that exposes WAVE streaming APIs as tools for AI coding assistants (Claude Code, Cursor, Windsurf).", + "purpose": "MCP (Model Context Protocol) server that exposes WAVE as tools for AI coding assistants (Claude Code, Cursor, Windsurf): 25 tools spanning streams, studio productions, analytics and billing, the voice/transcribe/captions product spokes, WAVE Dispatch model routing, and the public x402/MPP agent-payment rails.", "visibility": "public", "primaryLanguage": "TypeScript", "topics": [ @@ -112,16 +112,51 @@ "id": "wave_mark_highlight", "does": "Mark a moment in a stream as a highlight for later clipping.", "status": "preview" + }, + { + "id": "wave_speak", + "does": "Synthesize speech from text via POST /v1/voice. Returns a receipt — content type, size, and billed usage — rather than the audio itself, since an MCP tool result is a text block and raw audio cannot travel through one.", + "status": "ga" + }, + { + "id": "wave_transcribe", + "does": "Transcribe audio from a publicly reachable URL via POST /v1/transcribe, returning the JSON transcript. Optional engine (auto/whisper/deepgram/elevenlabs), language hint, and speaker diarization.", + "status": "ga" + }, + { + "id": "wave_caption", + "does": "Generate a caption file from audio at a URL via POST /v1/captions, returning WebVTT, SubRip, or JSON cues as text.", + "status": "ga" + }, + { + "id": "wave_route", + "does": "Classify a prompt with WAVE Dispatch and return the model route it selected plus the reasoning, so an agent can pick the cheapest capable model instead of hardcoding one. Calls Dispatch on its own host, not the product gateway.", + "status": "ga" + }, + { + "id": "wave_list_routing_profiles", + "does": "List WAVE Dispatch's named routing profiles (Fast / Expert / Heavy / Code). Feature-flagged server-side, so it can legitimately report as unavailable in a given environment.", + "status": "preview" + }, + { + "id": "wave_find_paid_services", + "does": "Semantic search over WAVE's MPP directory for machine-payable services an agent can buy from. Public: no API key is sent, because the route takes none.", + "status": "preview" + }, + { + "id": "wave_payment_schemes", + "does": "List the payment schemes and networks WAVE's x402 or MPP facilitator supports, so an agent settles on a scheme WAVE accepts. Public: no API key is sent. Only the read half of the rails is exposed — verify and settle are deliberately not wrapped as tools.", + "status": "ga" } ], "claims": [ { "id": "claim-tool-count", - "text": "Exposes 18 MCP tools", + "text": "Exposes 25 MCP tools", "resolver": { "type": "grep", "target": "capabilities.json", - "expect": "18-tools" + "expect": "25-tools" } }, { @@ -151,6 +186,69 @@ "expect": "docs.wave.online/mcp" } }, + { + "id": "claim-tool-speak", + "text": "wave_speak tool defined in src/tools/fleet.ts", + "resolver": { + "type": "grep", + "target": "src/tools/fleet.ts", + "expect": "wave_speak" + } + }, + { + "id": "claim-tool-transcribe", + "text": "wave_transcribe tool defined in src/tools/fleet.ts", + "resolver": { + "type": "grep", + "target": "src/tools/fleet.ts", + "expect": "wave_transcribe" + } + }, + { + "id": "claim-tool-caption", + "text": "wave_caption tool defined in src/tools/fleet.ts", + "resolver": { + "type": "grep", + "target": "src/tools/fleet.ts", + "expect": "wave_caption" + } + }, + { + "id": "claim-tool-route", + "text": "wave_route tool defined in src/tools/dispatch.ts", + "resolver": { + "type": "grep", + "target": "src/tools/dispatch.ts", + "expect": "wave_route" + } + }, + { + "id": "claim-tool-find-paid-services", + "text": "wave_find_paid_services tool defined in src/tools/payments.ts", + "resolver": { + "type": "grep", + "target": "src/tools/payments.ts", + "expect": "wave_find_paid_services" + } + }, + { + "id": "claim-payments-sends-no-key", + "text": "The payment-rail tools send no Authorization header, because those gateway routes are public and a key sent to them would leak for no benefit", + "resolver": { + "type": "grep", + "target": "src/tools/payments.ts", + "expect": "No Authorization header on purpose" + } + }, + { + "id": "claim-fleet-paths-grounded-in-spokes", + "text": "The voice/transcribe/captions paths come from each spoke's own router, not from api-spec/openapi.yaml, which over-declares endpoints that 404 in production", + "resolver": { + "type": "grep", + "target": "src/tools/fleet.ts", + "expect": "api-spec#33" + } + }, { "id": "claim-tool-list-streams", "text": "wave_list_streams tool defined in src/tools/streams.ts", @@ -419,6 +517,64 @@ ] ] }, + { + "kind": "table", + "heading": "Available tools — Fleet products", + "columns": [ + "Tool", + "Description" + ], + "rows": [ + [ + "`wave_speak`", + "Synthesize speech from text (POST /v1/voice). Returns a receipt — content type, size, billed usage — not the audio, since an MCP tool result is a text block" + ], + [ + "`wave_transcribe`", + "Transcribe audio from a public URL (POST /v1/transcribe), returning the JSON transcript" + ], + [ + "`wave_caption`", + "Generate a caption file from audio at a URL (POST /v1/captions) — WebVTT, SubRip, or JSON cues" + ] + ] + }, + { + "kind": "table", + "heading": "Available tools — Model routing", + "columns": [ + "Tool", + "Description" + ], + "rows": [ + [ + "`wave_route`", + "Classify a prompt with WAVE Dispatch and get the selected model route plus its reasoning" + ], + [ + "`wave_list_routing_profiles`", + "List the named routing profiles (Fast / Expert / Heavy / Code). Feature-flagged server-side" + ] + ] + }, + { + "kind": "table", + "heading": "Available tools — Agent payments", + "columns": [ + "Tool", + "Description" + ], + "rows": [ + [ + "`wave_find_paid_services`", + "Semantic search over the MPP directory for machine-payable services. Public — no API key required" + ], + [ + "`wave_payment_schemes`", + "Payment schemes and networks the x402 or MPP facilitator supports. Public — no API key required" + ] + ] + }, { "kind": "prose", "heading": "Resources", @@ -445,6 +601,18 @@ "No", "`https://wave.online`", "API base URL" + ], + [ + "`WAVE_API_BASE_URL`", + "No", + "`https://api.wave.online`", + "Product gateway front door — fronts the voice/transcribe/captions spokes and the payment rails. Distinct from `WAVE_BASE_URL`, which is the app surface" + ], + [ + "`WAVE_DISPATCH_URL`", + "No", + "`https://dispatch.wave.online`", + "WAVE Dispatch host. Dispatch is not behind the gateway" ] ] }, @@ -480,4 +648,4 @@ "body": "MIT" } ] -} \ No newline at end of file +} diff --git a/capabilities.json b/capabilities.json index 5e88f47..224fa0f 100644 --- a/capabilities.json +++ b/capabilities.json @@ -45,7 +45,7 @@ "tags": [ "mcp-server", "public", - "18-tools", + "25-tools", "agent-ops" ] } \ No newline at end of file diff --git a/src/auth.ts b/src/auth.ts index b9a0ee2..37307b9 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -4,10 +4,21 @@ * Reads credentials from environment variables: * - WAVE_API_KEY: Required. Bearer token for WAVE API authentication. * - WAVE_BASE_URL: Optional. Defaults to https://wave.online. + * - WAVE_API_BASE_URL: Optional. Defaults to https://api.wave.online — the GATEWAY front door. + * - WAVE_DISPATCH_URL: Optional. Defaults to https://dispatch.wave.online. */ const DEFAULT_BASE_URL = "https://wave.online"; +/** The gateway front door. DISTINCT from `WAVE_BASE_URL`: the app surface (`/api/v1/billing/...`) + * lives on wave.online, while the metered product spokes (voice / transcribe / captions, and the + * x402+MPP payment rails) are served through the gateway at api.wave.online under `/v1/...`. + * Pointing product calls at the app host silently 404s, so the two are kept separate on purpose. */ +const DEFAULT_API_BASE_URL = "https://api.wave.online"; + +/** wave-dispatch runs on its OWN host, not behind the product gateway, and takes a plain bearer. */ +const DEFAULT_DISPATCH_URL = "https://dispatch.wave.online"; + export function getApiKey(): string { const key = process.env["WAVE_API_KEY"]; if (!key) { @@ -24,6 +35,17 @@ export function getBaseUrl(): string { return process.env["WAVE_BASE_URL"] ?? DEFAULT_BASE_URL; } +/** Base URL for gateway-fronted product calls (`/v1/voice`, `/v1/transcribe`, `/v1/captions`, + * `/v1/mpp/*`). See {@link DEFAULT_API_BASE_URL} for why this is not `getBaseUrl()`. */ +export function getApiBaseUrl(): string { + return process.env["WAVE_API_BASE_URL"] ?? DEFAULT_API_BASE_URL; +} + +/** Base URL for wave-dispatch, which is NOT behind the product gateway. */ +export function getDispatchUrl(): string { + return process.env["WAVE_DISPATCH_URL"] ?? DEFAULT_DISPATCH_URL; +} + export function getAuthHeaders(): Record { return { Authorization: `Bearer ${getApiKey()}`, diff --git a/src/tools/dispatch.ts b/src/tools/dispatch.ts new file mode 100644 index 0000000..e647a1f --- /dev/null +++ b/src/tools/dispatch.ts @@ -0,0 +1,74 @@ +// wave-dispatch routing tools. +// +// Dispatch is NOT behind the product gateway: it runs on its own host and takes a plain bearer, so +// these tools use `getDispatchUrl()` rather than the gateway helpers in ./shared.ts. Paths come from +// wave-dispatch's own committed facts (`.wave/repo.json` endpoints, resolver-verified): +// +// POST / classify a prompt → { route, decision } +// GET /profiles named routing profiles (Fast/Expert/Heavy/Code) — flag-gated +// GET /sovereign sovereign-tier routing profile — flag-gated +// +// Both GETs are behind a server-side feature flag (WAVE_PROFILES), so they can legitimately return a +// not-enabled response in a given environment. That is surfaced to the caller rather than hidden. +import { z } from "zod"; +import { defineTool, errorContent, textContent, type WaveToolDef } from "./shared.js"; +import { getApiKey, getDispatchUrl } from "../auth.js"; + +async function dispatchFetch( + path: string, + init?: RequestInit, +): Promise<{ ok: boolean; status: number; body: string }> { + const res = await fetch(`${getDispatchUrl()}${path}`, { + ...init, + headers: { + Authorization: `Bearer ${getApiKey()}`, + "User-Agent": "wave-mcp-server/0.1.0", + ...init?.headers, + }, + }); + return { ok: res.ok, status: res.status, body: await res.text() }; +} + +export const dispatchTools: WaveToolDef[] = [ + defineTool({ + name: "wave_route", + description: + "Classify a prompt with WAVE Dispatch and get back the model route it selected plus the " + + "reasoning behind that decision. Use this to pick the cheapest capable model for a task " + + "instead of hardcoding one.", + inputSchema: { + prompt: z.string().min(1).describe("The prompt to classify and route"), + profile: z + .string() + .optional() + .describe("Named routing profile to route under (see wave_list_routing_profiles)"), + }, + handler: async ({ prompt, profile }) => { + const body: Record = { prompt }; + if (profile) body["profile"] = profile; + + const res = await dispatchFetch("/", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) return errorContent(res.status, res.body); + + return textContent(res.body); + }, + }), + + defineTool({ + name: "wave_list_routing_profiles", + description: + "List WAVE Dispatch's named routing profiles (Fast / Expert / Heavy / Code chains). This is " + + "feature-flagged server-side and may report as unavailable in some environments.", + inputSchema: {}, + handler: async () => { + const res = await dispatchFetch("/profiles"); + if (!res.ok) return errorContent(res.status, res.body); + + return textContent(res.body); + }, + }), +]; diff --git a/src/tools/fleet.ts b/src/tools/fleet.ts new file mode 100644 index 0000000..612bbe3 --- /dev/null +++ b/src/tools/fleet.ts @@ -0,0 +1,117 @@ +// Tools for the gateway-fronted product spokes: voice (TTS), transcribe (STT), and captions. +// +// Every path and parameter here is taken from the spokes' own routers at `origin/main`, not from +// `api-spec/openapi.yaml` — the spec over-declares this surface (wave-av/api-spec#33), and each +// spoke owns its whole `/v1` namespace with an exact-match router that 404s anything else. The +// three real endpoints are: +// +// POST /v1/voice wave-voice-edge/src/api.ts → audio bytes (audio/mpeg) +// POST /v1/transcribe wave-transcribe-edge/src/api.ts → JSON transcript +// POST /v1/captions wave-captions-edge/src/api.ts → a caption FILE (VTT/SRT) or JSON cues +// +// AUDIO INPUT IS BY URL. The transcribe/captions spokes accept audio either as a raw request body or +// via a `?url=` the spoke fetches server-side (`wave-transcribe-edge/src/transcribe.ts`, +// `wave-captions-edge/src/source.ts`). MCP tool arguments are JSON, so shipping media bytes through +// them would mean base64 in the model's context — the URL path is the only sane one here, and it is +// how an agent already holds media anyway. +import { z } from "zod"; +import { defineTool, errorContent, textContent, gatewayFetch, usageNote, type WaveToolDef } from "./shared.js"; + +/** STT engines both the transcribe and captions spokes accept (`parseEngine`, shared shape). + * `auto` resolves to a concrete engine by payload size — see `resolveAuto` in the transcribe spoke. */ +const ENGINE = z.enum(["auto", "whisper", "deepgram", "elevenlabs"]); + +/** Build the spoke's query string from the optional knobs it actually reads. */ +function audioQuery(args: { + url: string; + engine?: string; + language?: string; + diarize?: boolean; + format?: string; +}): string { + const p = new URLSearchParams({ url: args.url }); + if (args.engine) p.set("engine", args.engine); + if (args.language) p.set("language", args.language); + if (args.diarize) p.set("diarize", "true"); + if (args.format) p.set("format", args.format); + return p.toString(); +} + +export const fleetTools: WaveToolDef[] = [ + defineTool({ + name: "wave_speak", + description: + "Synthesize speech from text with WAVE Voice (POST /v1/voice). Returns a receipt — the audio " + + "content type, byte size, and billed usage — NOT the audio itself, since raw audio cannot be " + + "returned through a text tool result. Use the WAVE SDK or a direct API call when you need the " + + "audio bytes.", + inputSchema: { + text: z.string().min(1).describe("The text to synthesize (the spoke caps request length)"), + voiceId: z.string().optional().describe("Voice to use (defaults to the WAVE default voice)"), + modelId: z.string().optional().describe("TTS model id (defaults to the multilingual v2 model)"), + }, + handler: async ({ text, voiceId, modelId }) => { + const body: Record = { text }; + if (voiceId) body["voiceId"] = voiceId; + if (modelId) body["modelId"] = modelId; + + const res = await gatewayFetch("/v1/voice", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) return errorContent(res.status, res.body); + + // `res.body` is audio decoded as text and is therefore meaningless — deliberately not returned. + // Report only what is true and useful: the format, the size, and what it cost. + const bytes = Buffer.byteLength(res.body, "utf8"); + return textContent( + `Speech synthesized: ${res.contentType || "audio"} (~${bytes} bytes).` + + ` Audio bytes are not returned through MCP — call POST /v1/voice directly to retrieve them.` + + usageNote(res), + ); + }, + }), + + defineTool({ + name: "wave_transcribe", + description: + "Transcribe audio from a URL with WAVE Transcribe (POST /v1/transcribe). Returns the JSON " + + "transcript. The spoke fetches the URL itself, so it must be publicly reachable.", + inputSchema: { + url: z.string().url().describe("Publicly reachable URL of the audio to transcribe"), + engine: ENGINE.optional().describe("STT engine (default: auto, chosen by payload size)"), + language: z.string().optional().describe("BCP-47 language hint, e.g. 'en'"), + diarize: z.boolean().optional().describe("Label distinct speakers"), + }, + handler: async ({ url, engine, language, diarize }) => { + const res = await gatewayFetch(`/v1/transcribe?${audioQuery({ url, engine, language, diarize })}`, { + method: "POST", + }); + if (!res.ok) return errorContent(res.status, res.body); + + return textContent(res.body + usageNote(res)); + }, + }), + + defineTool({ + name: "wave_caption", + description: + "Generate a caption file from audio at a URL with WAVE Captions (POST /v1/captions). Returns " + + "the caption file itself — WebVTT, SubRip, or JSON cues — as text.", + inputSchema: { + url: z.string().url().describe("Publicly reachable URL of the audio to caption"), + format: z.enum(["vtt", "srt", "json"]).optional().describe("Caption format (default: the spoke's default)"), + engine: ENGINE.optional().describe("STT engine (default: auto)"), + language: z.string().optional().describe("BCP-47 language hint, e.g. 'en'"), + }, + handler: async ({ url, format, engine, language }) => { + const res = await gatewayFetch(`/v1/captions?${audioQuery({ url, engine, language, format })}`, { + method: "POST", + }); + if (!res.ok) return errorContent(res.status, res.body); + + return textContent(res.body + usageNote(res)); + }, + }), +]; diff --git a/src/tools/index.ts b/src/tools/index.ts index 82f8173..f037f7b 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -11,6 +11,9 @@ import { studioTools } from "./studio.js"; import { analyticsTools } from "./analytics.js"; import { billingTools } from "./billing.js"; import { productionTools } from "./production.js"; +import { fleetTools } from "./fleet.js"; +import { dispatchTools } from "./dispatch.js"; +import { paymentTools } from "./payments.js"; export const allTools: readonly WaveToolDef[] = [ ...streamTools, @@ -18,6 +21,9 @@ export const allTools: readonly WaveToolDef[] = [ ...analyticsTools, ...billingTools, ...productionTools, + ...fleetTools, + ...dispatchTools, + ...paymentTools, ]; // Drift/typo guard: tool names must be unique. Runs once at import (cheap) and diff --git a/src/tools/payments.ts b/src/tools/payments.ts new file mode 100644 index 0000000..672ff47 --- /dev/null +++ b/src/tools/payments.ts @@ -0,0 +1,68 @@ +// WAVE's agent-payment rails: x402 and MPP. +// +// These routes are PUBLIC BY DESIGN — the gateway serves them with no key auth, only a per-IP rate +// limit, because a paying agent has to be able to discover what it can buy and which payment schemes +// are supported BEFORE it holds a WAVE key. So these tools call the gateway front door directly +// rather than through the authenticated helper. +// +// GET /v1/mpp/services semantic search over WAVE's MPP service records (Vectorize) +// GET /v1/x402/facilitator/supported payment schemes/networks the x402 facilitator supports +// GET /v1/mpp/facilitator/supported the same, for MPP +// +// Only the read/discovery half is exposed. The facilitator's `verify` and `settle` endpoints are the +// money-moving side of the rail and are intentionally NOT wrapped as agent tools. +import { z } from "zod"; +import { defineTool, errorContent, textContent, type WaveToolDef } from "./shared.js"; +import { getApiBaseUrl } from "../auth.js"; + +/** Unauthenticated GET against the gateway's public plane. No Authorization header on purpose — + * these routes take none, and sending a key to a public endpoint leaks it for no benefit. */ +async function publicGet(path: string): Promise<{ ok: boolean; status: number; body: string }> { + const res = await fetch(`${getApiBaseUrl()}${path}`, { + headers: { "User-Agent": "wave-mcp-server/0.1.0" }, + }); + return { ok: res.ok, status: res.status, body: await res.text() }; +} + +export const paymentTools: WaveToolDef[] = [ + defineTool({ + name: "wave_find_paid_services", + description: + "Search WAVE's MPP service directory for machine-payable services an agent can buy from. " + + "Semantic search — describe what you need in plain language. Public: no API key required.", + inputSchema: { + q: z.string().min(1).describe("What you're looking for, in plain language"), + protocol: z.string().optional().describe("Filter by payment protocol, e.g. 'x402'"), + tag: z.string().optional().describe("Filter by service tag"), + topK: z.number().int().positive().max(50).optional().describe("How many results to return"), + }, + handler: async ({ q, protocol, tag, topK }) => { + const p = new URLSearchParams({ q }); + if (protocol) p.set("protocol", protocol); + if (tag) p.set("tag", tag); + if (topK !== undefined) p.set("topK", String(topK)); + + const res = await publicGet(`/v1/mpp/services?${p.toString()}`); + if (!res.ok) return errorContent(res.status, res.body); + + return textContent(res.body); + }, + }), + + defineTool({ + name: "wave_payment_schemes", + description: + "List the payment schemes and networks WAVE's facilitator supports, for x402 or MPP. Call " + + "this before constructing a payment so you settle on a scheme WAVE actually accepts. " + + "Public: no API key required.", + inputSchema: { + rail: z.enum(["x402", "mpp"]).describe("Which payment rail to query"), + }, + handler: async ({ rail }) => { + const res = await publicGet(`/v1/${rail}/facilitator/supported`); + if (!res.ok) return errorContent(res.status, res.body); + + return textContent(res.body); + }, + }), +]; diff --git a/src/tools/shared.ts b/src/tools/shared.ts index 1d376cf..1c29aa4 100644 --- a/src/tools/shared.ts +++ b/src/tools/shared.ts @@ -6,7 +6,7 @@ // in-process Agent SDK server) with zero drift. See ./index.ts for the registry // and ../server.ts / ../sdk-server.ts for the two consumers. import type { ZodRawShape, infer as zInfer, ZodObject } from "zod"; -import { getAuthHeaders, getBaseUrl } from "../auth.js"; +import { getApiKey, getAuthHeaders, getApiBaseUrl, getBaseUrl } from "../auth.js"; /** * Tool result shape — a single text block. Kept structurally assignable to both @@ -46,6 +46,51 @@ export async function waveFetch( return { ok: res.ok, status: res.status, body }; } +/** Result of a gateway product call: the decoded text body plus the usage receipt the spoke stamps + * on its response. `meter`/`usageMinutes` are what the gateway bills on, so surfacing them lets an + * agent see the cost of the call it just made instead of guessing. */ +export interface GatewayResult { + ok: boolean; + status: number; + body: string; + contentType: string; + meter?: string; + usageMinutes?: string; +} + +/** Authenticated call against the GATEWAY front door (api.wave.online), not the app host. + * + * Deliberately does NOT reuse `waveFetch`: that one targets `getBaseUrl()` (wave.online) and always + * sends `Content-Type: application/json`, while the product spokes are content-type sensitive — the + * transcribe/captions spokes read the request's content-type to decide how to treat the body, so a + * blanket JSON header on a non-JSON call is wrong. Here the caller owns the content-type. */ +export async function gatewayFetch(path: string, init?: RequestInit): Promise { + const res = await fetch(`${getApiBaseUrl()}${path}`, { + ...init, + headers: { + Authorization: `Bearer ${getApiKey()}`, + "User-Agent": "wave-mcp-server/0.1.0", + ...init?.headers, + }, + }); + return { + ok: res.ok, + status: res.status, + body: await res.text(), + contentType: res.headers.get("content-type") ?? "", + meter: res.headers.get("x-wave-meter") ?? undefined, + usageMinutes: res.headers.get("x-wave-usage-minutes") ?? undefined, + }; +} + +/** Render the spoke's usage receipt as a trailing line, so every metered tool reports what it cost. + * Empty when the spoke stamped no meter headers (e.g. an error response). */ +export function usageNote(r: GatewayResult): string { + if (!r.meter) return ""; + const mins = r.usageMinutes ? `, ${r.usageMinutes} min` : ""; + return `\n\n[billed: ${r.meter}${mins}]`; +} + /** * A transport-agnostic tool definition. The `inputSchema` is a Zod raw shape * (the same object both `McpServer.tool()` and the Agent SDK's `tool()` accept),