@pyai/mcp 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,95 @@
1
+ # @pyai/mcp — PyAI Model Context Protocol server
2
+
3
+ Exposes the PyAI voice API as [MCP](https://modelcontextprotocol.io) tools so AI
4
+ coding agents (Cursor, Claude Code, Codex) can **go from zero to a working PyAI
5
+ call with no human steps** — the agent can mint its own free sandbox key and then
6
+ call PyAI directly instead of guessing endpoints. Zero runtime dependencies.
7
+
8
+ ## Tools
9
+
10
+ | Tool | Needs a key? | Maps to |
11
+ |------|--------------|---------|
12
+ | `create_sandbox_key` | No | `POST /v1/sandbox/keys` — mint a free `pyai_test_` key (no email/card), adopted for the session |
13
+ | `get_started` | No | Curated quickstart (auth, SDK install, TTS/STT/realtime snippets) — no network/credits |
14
+ | `whoami` | Yes | `GET /v1/me` (scopes, env, credit posture) |
15
+ | `list_models` | Yes | `GET /v1/models` |
16
+ | `list_voices` | Yes | `GET /v1/voices` (optional `gender`, `region`) |
17
+ | `synthesize_speech` | Yes | `POST /v1/audio/speech` → writes audio to `output_path` |
18
+ | `create_transcription_job` | Yes | `POST /v1/transcription/jobs` (async STT for an `audio_url`) |
19
+ | `get_transcription_job` | Yes | `GET /v1/transcription/jobs/{id}` |
20
+
21
+ The contract is `https://api.pyai.com/openapi.json`; these tools wrap a curated
22
+ subset. **No key is required to start** — if `PYAI_API_KEY` is unset, an agent
23
+ calls `create_sandbox_key` first and the server adopts the minted key for the
24
+ rest of the session. Errors (402 credit, 429 rate limit, etc.) surface as MCP
25
+ tool errors with the stable `code`.
26
+
27
+ ## Install
28
+
29
+ The server runs straight from npm with `npx` — no clone, no build.
30
+
31
+ ### Cursor — one click
32
+
33
+ [![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](cursor://anysphere.cursor-deeplink/mcp/install?name=pyai&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBweWFpL21jcCJdfQ==)
34
+
35
+ Click the button (Cursor decodes the install config and prompts you), or add
36
+ this to `.cursor/mcp.json` (project) or `~/.cursor/mcp.json` (global):
37
+
38
+ ```json
39
+ {
40
+ "mcpServers": {
41
+ "pyai": {
42
+ "command": "npx",
43
+ "args": ["-y", "@pyai/mcp"]
44
+ }
45
+ }
46
+ }
47
+ ```
48
+
49
+ Add `"env": { "PYAI_API_KEY": "pyai_test_..." }` to pin a key, or leave it out
50
+ and let the agent call `create_sandbox_key`.
51
+
52
+ ### Claude Code
53
+
54
+ ```bash
55
+ claude mcp add pyai -- npx -y @pyai/mcp
56
+ # or pin a key:
57
+ claude mcp add pyai --env PYAI_API_KEY=pyai_test_... -- npx -y @pyai/mcp
58
+ ```
59
+
60
+ ### Codex / other MCP hosts
61
+
62
+ Point the host at the stdio command `npx -y @pyai/mcp`, optionally with
63
+ `PYAI_API_KEY` in the environment. The transport is newline-delimited
64
+ JSON-RPC 2.0 over stdio.
65
+
66
+ ## Config (env)
67
+
68
+ - `PYAI_API_KEY` — *optional*. A `pyai_live_`/`pyai_test_` key. Omit it to let
69
+ the agent mint a sandbox key.
70
+ - `PYAI_BASE_URL` — optional, defaults to `https://api.pyai.com`.
71
+ - `PORT` — optional, HTTP transport only (default `8080`).
72
+
73
+ ## HTTP transport (experimental)
74
+
75
+ `npx @pyai/mcp --http` starts a minimal HTTP JSON-RPC endpoint (`POST /`,
76
+ `GET /health`) — the basis for a hosted remote server at `mcp.pyai.com`. A
77
+ per-request `Authorization: Bearer <key>` overrides the env key. Each HTTP
78
+ request gets an isolated client, so a key minted by `create_sandbox_key` is
79
+ returned to that caller but is not retained globally for later callers. This is
80
+ **not yet** a full MCP Streamable HTTP server (SSE, session ids, and OAuth are
81
+ deploy-time follow-ups); the supported install today is `npx` over stdio.
82
+
83
+ ## Develop
84
+
85
+ ```bash
86
+ npm install # dev-only: typescript + @types/node
87
+ npm test # node --test (no network; fetch is injected)
88
+ npm run typecheck
89
+ npm run build # emit dist/ (tsc rewrites .ts imports → .js)
90
+ ```
91
+
92
+ `src/rpc.ts` (`dispatch`) is transport-pure and unit-tested; `src/tools.ts`
93
+ holds the tool definitions + operation logic; `src/client.ts` is the fetch
94
+ wrapper; `src/http.ts` is the experimental HTTP transport. Add a tool by
95
+ extending `TOOLS` + the `callTool` switch.
package/dist/client.js ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Thin PyAI API client used by the MCP tools. Wraps fetch with the bearer key
3
+ * and surfaces errors in a shape the tool layer can turn into MCP tool errors.
4
+ * No external deps — runs on Node 20+'s global fetch.
5
+ *
6
+ * The key is OPTIONAL: the server can start without one so an agent can call the
7
+ * `create_sandbox_key` tool first, which mints a sandbox key and adopts it on
8
+ * this client for the rest of the session (`setApiKey`).
9
+ */
10
+ export class PyAIError extends Error {
11
+ status;
12
+ code;
13
+ constructor(status, message, code) {
14
+ super(message);
15
+ this.name = "PyAIError";
16
+ this.status = status;
17
+ this.code = code;
18
+ }
19
+ }
20
+ export class PyAIClient {
21
+ apiKey;
22
+ baseUrl;
23
+ fetchImpl;
24
+ constructor(opts = {}) {
25
+ this.apiKey = opts.apiKey && opts.apiKey.length > 0 ? opts.apiKey : undefined;
26
+ this.baseUrl = (opts.baseUrl ?? "https://api.pyai.com").replace(/\/$/, "");
27
+ this.fetchImpl = opts.fetchImpl ?? fetch;
28
+ }
29
+ /** Whether a key is configured (env or adopted from create_sandbox_key). */
30
+ get hasKey() {
31
+ return this.apiKey !== undefined;
32
+ }
33
+ /** Adopt a key for the rest of the session (used after create_sandbox_key). */
34
+ setApiKey(key) {
35
+ this.apiKey = key && key.length > 0 ? key : undefined;
36
+ }
37
+ headers(extra = {}) {
38
+ return this.apiKey ? { Authorization: `Bearer ${this.apiKey}`, ...extra } : { ...extra };
39
+ }
40
+ /** Parse an error body (OpenAI envelope OR RFC 7807 problem) into a PyAIError. */
41
+ async toError(res) {
42
+ let body;
43
+ try {
44
+ body = await res.json();
45
+ }
46
+ catch {
47
+ return new PyAIError(res.status, `HTTP ${res.status}`);
48
+ }
49
+ const b = body;
50
+ if (b.error)
51
+ return new PyAIError(res.status, b.error.message ?? `HTTP ${res.status}`, b.error.code);
52
+ const code = typeof b.type === "string" ? b.type.split("/").pop() : undefined;
53
+ return new PyAIError(res.status, b.detail ?? b.title ?? `HTTP ${res.status}`, code);
54
+ }
55
+ async getJson(path) {
56
+ const res = await this.fetchImpl(`${this.baseUrl}${path}`, { headers: this.headers() });
57
+ if (!res.ok)
58
+ throw await this.toError(res);
59
+ return (await res.json());
60
+ }
61
+ async postJson(path, body) {
62
+ const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
63
+ method: "POST",
64
+ headers: this.headers({ "Content-Type": "application/json" }),
65
+ body: JSON.stringify(body),
66
+ });
67
+ if (!res.ok)
68
+ throw await this.toError(res);
69
+ return (await res.json());
70
+ }
71
+ /** POST that returns binary audio (text-to-speech). Returns the raw bytes. */
72
+ async postForBytes(path, body) {
73
+ const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
74
+ method: "POST",
75
+ headers: this.headers({ "Content-Type": "application/json" }),
76
+ body: JSON.stringify(body),
77
+ });
78
+ if (!res.ok)
79
+ throw await this.toError(res);
80
+ return new Uint8Array(await res.arrayBuffer());
81
+ }
82
+ }
package/dist/http.js ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Experimental HTTP transport for hosting pyai-mcp remotely (the basis for a
3
+ * one-click remote MCP at mcp.pyai.com).
4
+ *
5
+ * This is a minimal single-response JSON-RPC endpoint: POST a JSON-RPC message,
6
+ * get the JSON-RPC response back. It is intentionally small and dependency-free.
7
+ * NOT YET a full MCP Streamable HTTP server — SSE streaming, session ids
8
+ * (`Mcp-Session-Id`), and OAuth are deploy-time follow-ups before public
9
+ * hosting. The supported install today is `npx @pyai/mcp` over stdio.
10
+ *
11
+ * A per-request `Authorization: Bearer <key>` overrides the env key, so a hosted
12
+ * instance can serve multiple tenants. Each request gets its own client instance:
13
+ * if `create_sandbox_key` adopts a key, that mutation is confined to the single
14
+ * request and cannot leak into the next caller. Full remote MCP session support
15
+ * still needs Streamable HTTP session ids before public hosting.
16
+ */
17
+ import { createServer } from "node:http";
18
+ import { PyAIClient } from "./client.js";
19
+ import { dispatch } from "./rpc.js";
20
+ const MAX_BODY_BYTES = 1_000_000;
21
+ export function startHttpServer(opts) {
22
+ const server = createServer((req, res) => {
23
+ if (req.method === "GET" && (req.url === "/health" || req.url === "/healthz")) {
24
+ res.writeHead(200, { "content-type": "application/json" });
25
+ res.end(JSON.stringify({ status: "ok", server: "pyai-mcp" }));
26
+ return;
27
+ }
28
+ if (req.method !== "POST") {
29
+ res.writeHead(405, { "content-type": "application/json", allow: "POST" });
30
+ res.end(JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32600, message: "Use POST for JSON-RPC" } }));
31
+ return;
32
+ }
33
+ const auth = req.headers["authorization"];
34
+ const bearer = typeof auth === "string" && auth.startsWith("Bearer ") ? auth.slice(7).trim() : "";
35
+ const client = new PyAIClient({ apiKey: bearer || opts.apiKey, baseUrl: opts.baseUrl });
36
+ let raw = "";
37
+ let aborted = false;
38
+ req.on("data", (chunk) => {
39
+ raw += chunk;
40
+ if (raw.length > MAX_BODY_BYTES) {
41
+ aborted = true;
42
+ res.writeHead(413, { "content-type": "application/json" });
43
+ res.end(JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32600, message: "Payload too large" } }));
44
+ req.destroy();
45
+ }
46
+ });
47
+ req.on("end", () => {
48
+ if (aborted)
49
+ return;
50
+ let msg;
51
+ try {
52
+ msg = JSON.parse(raw);
53
+ }
54
+ catch {
55
+ res.writeHead(400, { "content-type": "application/json" });
56
+ res.end(JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } }));
57
+ return;
58
+ }
59
+ dispatch(msg, { client })
60
+ .then((out) => {
61
+ res.writeHead(200, { "content-type": "application/json" });
62
+ // Notifications (no id) get an empty 200 ack.
63
+ res.end(out ? JSON.stringify(out) : "");
64
+ })
65
+ .catch(() => {
66
+ res.writeHead(500, { "content-type": "application/json" });
67
+ res.end(JSON.stringify({ jsonrpc: "2.0", id: msg.id ?? null, error: { code: -32603, message: "Internal error" } }));
68
+ });
69
+ });
70
+ });
71
+ server.listen(opts.port, opts.host ?? "0.0.0.0", () => {
72
+ process.stderr.write(`pyai-mcp: HTTP transport listening on ${opts.host ?? "0.0.0.0"}:${opts.port}\n`);
73
+ });
74
+ }
package/dist/index.js ADDED
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * pyai-mcp — Model Context Protocol server for the PyAI API.
4
+ *
5
+ * Exposes PyAI operations (sandbox key minting, models, voices, TTS, async
6
+ * transcription) as MCP tools so AI coding agents (Cursor, Claude Code, Codex)
7
+ * can call them directly. Default transport is stdio: reads newline-delimited
8
+ * JSON-RPC from stdin, writes responses to stdout. All diagnostics go to stderr
9
+ * so they never corrupt the protocol. Pass `--http` for the experimental HTTP
10
+ * transport (for hosting a remote server).
11
+ *
12
+ * Config (env):
13
+ * PYAI_API_KEY (OPTIONAL) — a pyai_live_ or pyai_test_ key. If absent, the
14
+ * server still starts; call the `create_sandbox_key` tool to
15
+ * mint one with no human steps.
16
+ * PYAI_BASE_URL (optional) — defaults to https://api.pyai.com.
17
+ * PORT (optional, --http only) — defaults to 8080.
18
+ */
19
+ import { createInterface } from "node:readline";
20
+ import { PyAIClient } from "./client.js";
21
+ import { dispatch } from "./rpc.js";
22
+ import { startHttpServer } from "./http.js";
23
+ function startStdio(client) {
24
+ const rl = createInterface({ input: process.stdin });
25
+ const write = (obj) => process.stdout.write(`${JSON.stringify(obj)}\n`);
26
+ rl.on("line", (line) => {
27
+ const trimmed = line.trim();
28
+ if (!trimmed)
29
+ return;
30
+ let msg;
31
+ try {
32
+ msg = JSON.parse(trimmed);
33
+ }
34
+ catch {
35
+ write({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
36
+ return;
37
+ }
38
+ // Process sequentially; each line is an independent JSON-RPC message.
39
+ dispatch(msg, { client })
40
+ .then((res) => {
41
+ if (res)
42
+ write(res);
43
+ })
44
+ .catch((err) => {
45
+ process.stderr.write(`pyai-mcp: dispatch error: ${err.message}\n`);
46
+ if (msg.id !== undefined && msg.id !== null) {
47
+ write({ jsonrpc: "2.0", id: msg.id, error: { code: -32603, message: "Internal error" } });
48
+ }
49
+ });
50
+ });
51
+ rl.on("close", () => process.exit(0));
52
+ process.stderr.write("pyai-mcp: ready on stdio\n");
53
+ }
54
+ function main() {
55
+ const apiKey = process.env.PYAI_API_KEY;
56
+ const baseUrl = process.env.PYAI_BASE_URL;
57
+ if (!apiKey) {
58
+ process.stderr.write("pyai-mcp: no PYAI_API_KEY set — call the create_sandbox_key tool to mint a free key.\n");
59
+ }
60
+ if (process.argv.includes("--http")) {
61
+ const port = Number(process.env.PORT ?? 8080);
62
+ startHttpServer({ apiKey, baseUrl, port });
63
+ return;
64
+ }
65
+ startStdio(new PyAIClient({ apiKey, baseUrl }));
66
+ }
67
+ main();
package/dist/rpc.js ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Minimal MCP (Model Context Protocol) JSON-RPC 2.0 dispatcher over stdio.
3
+ *
4
+ * Implements just the methods an MCP host (Cursor, Claude Code, Codex) needs to
5
+ * discover and call tools: `initialize`, `notifications/initialized`, `ping`,
6
+ * `tools/list`, `tools/call`. Zero deps so it runs on plain Node 22.
7
+ *
8
+ * The stdio transport is newline-delimited JSON-RPC: one JSON object per line.
9
+ * dispatch() is pure w.r.t. transport (takes a parsed message, returns a parsed
10
+ * response or null for notifications) so it unit-tests without spawning a process.
11
+ */
12
+ import { callTool, TOOLS } from "./tools.js";
13
+ export const PROTOCOL_VERSION = "2024-11-05";
14
+ export const SERVER_INFO = { name: "pyai-mcp", version: "0.2.0" };
15
+ function ok(id, result) {
16
+ return { jsonrpc: "2.0", id: id ?? null, result };
17
+ }
18
+ function fail(id, code, message) {
19
+ return { jsonrpc: "2.0", id: id ?? null, error: { code, message } };
20
+ }
21
+ /**
22
+ * Handle one JSON-RPC message. Returns the response to write, or null for
23
+ * notifications (no id) which must not be answered.
24
+ */
25
+ export async function dispatch(msg, deps) {
26
+ switch (msg.method) {
27
+ case "initialize":
28
+ return ok(msg.id, {
29
+ protocolVersion: PROTOCOL_VERSION,
30
+ capabilities: { tools: {} },
31
+ serverInfo: SERVER_INFO,
32
+ });
33
+ case "notifications/initialized":
34
+ return null; // notification — no response
35
+ case "ping":
36
+ return ok(msg.id, {});
37
+ case "tools/list":
38
+ return ok(msg.id, { tools: TOOLS });
39
+ case "tools/call": {
40
+ const params = (msg.params ?? {});
41
+ if (!params.name)
42
+ return fail(msg.id, -32602, "Missing tool name");
43
+ const result = await callTool(params.name, params.arguments ?? {}, deps.client);
44
+ return ok(msg.id, result);
45
+ }
46
+ default:
47
+ // Notifications we don't handle: ignore. Requests: method-not-found.
48
+ if (msg.id === undefined || msg.id === null)
49
+ return null;
50
+ return fail(msg.id, -32601, `Method not found: ${msg.method}`);
51
+ }
52
+ }
package/dist/tools.js ADDED
@@ -0,0 +1,253 @@
1
+ /**
2
+ * PyAI MCP tools. Each tool maps to a public OpenAPI operation
3
+ * (https://api.pyai.com/openapi.json) or returns curated, dependency-free
4
+ * context. callTool runs the operation against a PyAIClient and returns MCP
5
+ * tool-result content.
6
+ *
7
+ * Onboarding-first ordering: `create_sandbox_key` and `get_started` need NO key,
8
+ * so an agent can go from zero to a working PyAI call with no human steps. The
9
+ * remaining tools require a key (env `PYAI_API_KEY` or one adopted from
10
+ * `create_sandbox_key`).
11
+ */
12
+ import { writeFile } from "node:fs/promises";
13
+ import { PyAIError } from "./client.js";
14
+ function text(value) {
15
+ const s = typeof value === "string" ? value : JSON.stringify(value, null, 2);
16
+ return { content: [{ type: "text", text: s }] };
17
+ }
18
+ /** Tools that need a configured key. create_sandbox_key + get_started do not. */
19
+ const KEY_REQUIRED = new Set([
20
+ "list_models",
21
+ "list_voices",
22
+ "synthesize_speech",
23
+ "create_transcription_job",
24
+ "get_transcription_job",
25
+ "whoami",
26
+ ]);
27
+ const GET_STARTED = [
28
+ "# Build with the PyAI voice API",
29
+ "",
30
+ "Source of truth (fetch it; never invent endpoints):",
31
+ "- OpenAPI spec: https://api.pyai.com/openapi.json",
32
+ "- Curated agent index: https://api.pyai.com/llms.txt",
33
+ "- Docs: https://docs.pyai.com",
34
+ "",
35
+ "## 1. Get a key (zero steps)",
36
+ "Call the `create_sandbox_key` MCP tool — it returns a free `pyai_test_...` key",
37
+ "that works on the first call (no card, no 402). Store it in `PYAI_API_KEY`.",
38
+ "For production, create a live key at https://console.pyai.com.",
39
+ "",
40
+ "## 2. Install the SDK (preferred over hand-rolled fetch)",
41
+ "- TypeScript/JS: `npm install @pyai/sdk` then `import PyAI from \"@pyai/sdk\"`",
42
+ "- Python: `pip install pyai-sdk` then `from pyai import PyAI`",
43
+ "Keys are opaque (<=512 chars): never parse/split/decode/log them; read from env.",
44
+ "",
45
+ "## 3. Text-to-speech (Speak)",
46
+ "```ts",
47
+ "import PyAI from \"@pyai/sdk\";",
48
+ "const pyai = new PyAI({ apiKey: process.env.PYAI_API_KEY! });",
49
+ "const audio = await pyai.audio.speech({ input: \"Hello from PyAI\", response_format: \"mp3\" });",
50
+ "// `audio` is an ArrayBuffer of MP3 bytes — write it to a file or stream it.",
51
+ "```",
52
+ "",
53
+ "## 4. Speech-to-text (Hear)",
54
+ "```ts",
55
+ "const res = await pyai.audio.transcriptions.create({ file: blob, filename: \"audio.wav\" });",
56
+ "console.log(res.text);",
57
+ "```",
58
+ "",
59
+ "## 5. Realtime voice agent (Omni)",
60
+ "Open a WebSocket to `wss://api.pyai.com/v1/omni?format=pcm16&rate=24000`,",
61
+ "authenticate with the subprotocol `pyai-key.<key>` (browser-safe) or `?api_key=<key>`",
62
+ "(server). Omni is zero-state — send a `{type:\"configure\"}` frame after connect.",
63
+ "In the browser, mint a short-lived token server-side with `pyai.omni.createSession(...)`",
64
+ "instead of shipping a secret key.",
65
+ "",
66
+ "## 6. Errors — branch on the stable `error.code`",
67
+ "`402 credit_exhausted` (add credit / use a sandbox key), `403 forbidden` (missing scope),",
68
+ "`429 rate_limit_exceeded` (back off, honor `Retry-After`). Don't retry 4xx except 429.",
69
+ ].join("\n");
70
+ export const TOOLS = [
71
+ {
72
+ name: "create_sandbox_key",
73
+ description: "Get a FREE PyAI sandbox key with no human steps (no email, card, or signup). " +
74
+ "Returns a pyai_test_ key that works on the first call and is adopted for the rest " +
75
+ "of this session, so the other tools work immediately. Call this FIRST if no " +
76
+ "PYAI_API_KEY is set. The returned key should be stored in the app's PYAI_API_KEY env var.",
77
+ inputSchema: {
78
+ type: "object",
79
+ properties: {
80
+ label: { type: "string", description: "Optional label (e.g. your app name) shown in the PyAI console." },
81
+ },
82
+ additionalProperties: false,
83
+ },
84
+ },
85
+ {
86
+ name: "get_started",
87
+ description: "Return a concise, correct quickstart for building with PyAI (auth, SDK install, " +
88
+ "TTS/STT/realtime snippets, error handling). No key or network needed — read this " +
89
+ "before writing PyAI code so you use the right endpoints and the official SDK.",
90
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
91
+ },
92
+ {
93
+ name: "whoami",
94
+ description: "Introspect the active key (GET /v1/me): org, environment (test/live), granted scopes, " +
95
+ "and rate-limit/credit posture. Use it to self-diagnose a 401/403/402.",
96
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
97
+ },
98
+ {
99
+ name: "list_models",
100
+ description: "List the available PyAI models (catalog). No arguments.",
101
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
102
+ },
103
+ {
104
+ name: "list_voices",
105
+ description: "List PyAI stock + cloned voices. Optionally filter by gender or region. " +
106
+ "Use a returned voice id as the `voice` for synthesize_speech.",
107
+ inputSchema: {
108
+ type: "object",
109
+ properties: {
110
+ gender: { type: "string", description: "Filter by voice gender (e.g. female, male)." },
111
+ region: { type: "string", description: "Filter by accent/region (e.g. en_us, en_gb)." },
112
+ },
113
+ additionalProperties: false,
114
+ },
115
+ },
116
+ {
117
+ name: "synthesize_speech",
118
+ description: "Text-to-speech. Synthesizes `input` with `voice` and writes the audio to " +
119
+ "`output_path`. Returns the file path and byte count. Note: consumes sandbox " +
120
+ "usage quota — for writing app code, prefer get_started over running this.",
121
+ inputSchema: {
122
+ type: "object",
123
+ properties: {
124
+ input: { type: "string", description: "Text to synthesize." },
125
+ voice: { type: "string", description: "Voice id from list_voices. Omit for the account default." },
126
+ output_path: { type: "string", description: "Absolute file path to write the audio to." },
127
+ model: { type: "string", description: "Defaults to pyai-voice." },
128
+ response_format: { type: "string", enum: ["wav", "mp3", "opus", "pcm"], description: "Defaults to wav." },
129
+ },
130
+ required: ["input", "output_path"],
131
+ additionalProperties: false,
132
+ },
133
+ },
134
+ {
135
+ name: "create_transcription_job",
136
+ description: "Submit an async speech-to-text job for an audio URL (https). Returns a " +
137
+ "job_id; poll with get_transcription_job. Supports diarization.",
138
+ inputSchema: {
139
+ type: "object",
140
+ properties: {
141
+ audio_url: { type: "string", description: "https URL of the audio to transcribe." },
142
+ model: { type: "string" },
143
+ diarize: { type: "boolean", description: "Single-track speaker diarization." },
144
+ channel: { type: "boolean", description: "Dual-channel (stereo) per-channel speakers." },
145
+ output_formats: {
146
+ type: "array",
147
+ items: { type: "string", enum: ["json", "srt", "vtt"] },
148
+ description: "Defaults to [json].",
149
+ },
150
+ },
151
+ required: ["audio_url"],
152
+ additionalProperties: false,
153
+ },
154
+ },
155
+ {
156
+ name: "get_transcription_job",
157
+ description: "Get the status + result of a transcription job by id.",
158
+ inputSchema: {
159
+ type: "object",
160
+ properties: { job_id: { type: "string", description: "The job_id from create_transcription_job." } },
161
+ required: ["job_id"],
162
+ additionalProperties: false,
163
+ },
164
+ },
165
+ ];
166
+ function str(args, key) {
167
+ const v = args[key];
168
+ return typeof v === "string" && v.length > 0 ? v : undefined;
169
+ }
170
+ export async function callTool(name, args, client) {
171
+ if (KEY_REQUIRED.has(name) && !client.hasKey) {
172
+ return toolError("No PyAI key configured. Call the `create_sandbox_key` tool first to get a free key with no setup, " +
173
+ "or set the PYAI_API_KEY env var to a pyai_test_/pyai_live_ key.");
174
+ }
175
+ try {
176
+ switch (name) {
177
+ case "get_started":
178
+ return text(GET_STARTED);
179
+ case "create_sandbox_key": {
180
+ const label = str(args, "label");
181
+ const result = await client.postJson("/v1/sandbox/keys", label ? { label } : {});
182
+ if (result && typeof result.api_key === "string")
183
+ client.setApiKey(result.api_key);
184
+ return text(result);
185
+ }
186
+ case "whoami":
187
+ return text(await client.getJson("/v1/me"));
188
+ case "list_models":
189
+ return text(await client.getJson("/v1/models"));
190
+ case "list_voices": {
191
+ const q = new URLSearchParams();
192
+ if (str(args, "gender"))
193
+ q.set("gender", str(args, "gender"));
194
+ if (str(args, "region"))
195
+ q.set("region", str(args, "region"));
196
+ const qs = q.toString();
197
+ return text(await client.getJson(`/v1/voices${qs ? `?${qs}` : ""}`));
198
+ }
199
+ case "synthesize_speech": {
200
+ const input = str(args, "input");
201
+ const outputPath = str(args, "output_path");
202
+ if (!input)
203
+ return toolError("`input` is required.");
204
+ if (!outputPath)
205
+ return toolError("`output_path` is required.");
206
+ const body = { model: str(args, "model") ?? "pyai-voice", input };
207
+ if (str(args, "voice"))
208
+ body.voice = str(args, "voice");
209
+ if (str(args, "response_format"))
210
+ body.response_format = str(args, "response_format");
211
+ const bytes = await client.postForBytes("/v1/audio/speech", body);
212
+ await writeFile(outputPath, bytes);
213
+ return text({ output_path: outputPath, bytes: bytes.byteLength, status: "ok" });
214
+ }
215
+ case "create_transcription_job": {
216
+ const audioUrl = str(args, "audio_url");
217
+ if (!audioUrl)
218
+ return toolError("`audio_url` is required.");
219
+ const body = { audio_url: audioUrl };
220
+ if (str(args, "model"))
221
+ body.model = str(args, "model");
222
+ if (typeof args.diarize === "boolean")
223
+ body.diarize = args.diarize;
224
+ if (typeof args.channel === "boolean")
225
+ body.channel = args.channel;
226
+ if (Array.isArray(args.output_formats))
227
+ body.output_formats = args.output_formats;
228
+ return text(await client.postJson("/v1/transcription/jobs", body));
229
+ }
230
+ case "get_transcription_job": {
231
+ const jobId = str(args, "job_id");
232
+ if (!jobId)
233
+ return toolError("`job_id` is required.");
234
+ return text(await client.getJson(`/v1/transcription/jobs/${encodeURIComponent(jobId)}`));
235
+ }
236
+ default:
237
+ return toolError(`Unknown tool: ${name}`);
238
+ }
239
+ }
240
+ catch (err) {
241
+ if (err instanceof PyAIError) {
242
+ if (name === "create_sandbox_key" && err.status === 404) {
243
+ return toolError("Sandbox-key minting is not enabled on this deployment. Create a free key at " +
244
+ "https://console.pyai.com and set it as PYAI_API_KEY.");
245
+ }
246
+ return toolError(`PyAI API error ${err.status}${err.code ? ` (${err.code})` : ""}: ${err.message}`);
247
+ }
248
+ return toolError(`Tool failed: ${err.message}`);
249
+ }
250
+ }
251
+ function toolError(message) {
252
+ return { content: [{ type: "text", text: message }], isError: true };
253
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@pyai/mcp",
3
+ "version": "0.2.0",
4
+ "description": "Model Context Protocol server for the PyAI voice API — lets AI coding agents (Cursor, Claude Code, Codex) get a sandbox key and call PyAI (TTS, STT, voices) directly instead of guessing endpoints.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "homepage": "https://docs.pyai.com",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/pyai/pyai-platform-backend.git",
11
+ "directory": "mcp"
12
+ },
13
+ "keywords": [
14
+ "mcp",
15
+ "model-context-protocol",
16
+ "pyai",
17
+ "voice",
18
+ "tts",
19
+ "stt",
20
+ "cursor",
21
+ "claude",
22
+ "codex"
23
+ ],
24
+ "bin": {
25
+ "pyai-mcp": "dist/index.js"
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "README.md"
30
+ ],
31
+ "scripts": {
32
+ "build": "tsc -p tsconfig.build.json",
33
+ "start": "node src/index.ts",
34
+ "start:http": "node src/index.ts --http",
35
+ "test": "node --test test/**/*.test.ts",
36
+ "typecheck": "tsc --noEmit",
37
+ "prepublishOnly": "npm run build"
38
+ },
39
+ "engines": {
40
+ "node": ">=20"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "devDependencies": {
46
+ "@types/node": "^22.0.0",
47
+ "typescript": "^5.7.0"
48
+ }
49
+ }