@pyai/mcp 0.2.0 → 0.3.1

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/dist/tools.js CHANGED
@@ -9,11 +9,39 @@
9
9
  * remaining tools require a key (env `PYAI_API_KEY` or one adopted from
10
10
  * `create_sandbox_key`).
11
11
  */
12
- import { writeFile } from "node:fs/promises";
12
+ import { writeFile, lstat, realpath } from "node:fs/promises";
13
+ import { resolve, relative, isAbsolute, dirname, basename } from "node:path";
14
+ import { Ajv } from "ajv";
15
+ import { API_TOOLS } from "./generated-tools.js";
16
+ import { EXTRA_TOOLS, EXTRA_SCOPES, extraTool } from "./extra-tools.js";
17
+ import { GUIDE, WORKFLOWS, SEARCH_TERMS } from "./workflows.js";
13
18
  import { PyAIError } from "./client.js";
19
+ export const localContext = {
20
+ mode: "stdio",
21
+ outputRoot: process.cwd(),
22
+ audio: async (bytes, mimeType) => {
23
+ if (bytes.length > 8 * 1024 * 1024)
24
+ return toolError("Audio exceeds the inline limit. Use output_path with Speak or download with the CLI.", "artifact_too_large");
25
+ return {
26
+ content: [
27
+ {
28
+ type: "audio",
29
+ data: Buffer.from(bytes).toString("base64"),
30
+ mimeType,
31
+ },
32
+ ],
33
+ structuredContent: { bytes: bytes.length, mime_type: mimeType },
34
+ };
35
+ },
36
+ };
14
37
  function text(value) {
15
38
  const s = typeof value === "string" ? value : JSON.stringify(value, null, 2);
16
- return { content: [{ type: "text", text: s }] };
39
+ return {
40
+ content: [{ type: "text", text: s }],
41
+ structuredContent: typeof value === "object" && value !== null
42
+ ? value
43
+ : { text: s },
44
+ };
17
45
  }
18
46
  /** Tools that need a configured key. create_sandbox_key + get_started do not. */
19
47
  const KEY_REQUIRED = new Set([
@@ -23,61 +51,25 @@ const KEY_REQUIRED = new Set([
23
51
  "create_transcription_job",
24
52
  "get_transcription_job",
25
53
  "whoami",
54
+ "configure_amd",
55
+ "get_amd_call",
56
+ "list_amd_calls",
57
+ "enable_recap",
58
+ "get_recap",
59
+ "list_recap_calls",
60
+ "create_recap",
26
61
  ]);
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 = [
62
+ const LEGACY_TOOLS = [
71
63
  {
72
64
  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.",
65
+ description: "Create a NEW isolated sandbox only when the user asks to try PyAI without an account. Local stdio only. Adopts the key privately for this session; never returns the secret. Prefer pyai login to use an existing workspace.",
77
66
  inputSchema: {
78
67
  type: "object",
79
68
  properties: {
80
- label: { type: "string", description: "Optional label (e.g. your app name) shown in the PyAI console." },
69
+ label: {
70
+ type: "string",
71
+ description: "Optional label (e.g. your app name) shown in the PyAI console.",
72
+ },
81
73
  },
82
74
  additionalProperties: false,
83
75
  },
@@ -85,20 +77,32 @@ export const TOOLS = [
85
77
  {
86
78
  name: "get_started",
87
79
  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 " +
80
+ "TTS/STT/realtime snippets, error handling). No key or network needed, read this " +
89
81
  "before writing PyAI code so you use the right endpoints and the official SDK.",
90
- inputSchema: { type: "object", properties: {}, additionalProperties: false },
82
+ inputSchema: {
83
+ type: "object",
84
+ properties: {},
85
+ additionalProperties: false,
86
+ },
91
87
  },
92
88
  {
93
89
  name: "whoami",
94
90
  description: "Introspect the active key (GET /v1/me): org, environment (test/live), granted scopes, " +
95
91
  "and rate-limit/credit posture. Use it to self-diagnose a 401/403/402.",
96
- inputSchema: { type: "object", properties: {}, additionalProperties: false },
92
+ inputSchema: {
93
+ type: "object",
94
+ properties: {},
95
+ additionalProperties: false,
96
+ },
97
97
  },
98
98
  {
99
99
  name: "list_models",
100
100
  description: "List the available PyAI models (catalog). No arguments.",
101
- inputSchema: { type: "object", properties: {}, additionalProperties: false },
101
+ inputSchema: {
102
+ type: "object",
103
+ properties: {},
104
+ additionalProperties: false,
105
+ },
102
106
  },
103
107
  {
104
108
  name: "list_voices",
@@ -107,8 +111,14 @@ export const TOOLS = [
107
111
  inputSchema: {
108
112
  type: "object",
109
113
  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)." },
114
+ gender: {
115
+ type: "string",
116
+ description: "Filter by voice gender (e.g. female, male).",
117
+ },
118
+ region: {
119
+ type: "string",
120
+ description: "Filter by accent/region (e.g. en_us, en_gb).",
121
+ },
112
122
  },
113
123
  additionalProperties: false,
114
124
  },
@@ -117,17 +127,27 @@ export const TOOLS = [
117
127
  name: "synthesize_speech",
118
128
  description: "Text-to-speech. Synthesizes `input` with `voice` and writes the audio to " +
119
129
  "`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.",
130
+ "usage quota, for writing app code, prefer get_started over running this.",
121
131
  inputSchema: {
122
132
  type: "object",
123
133
  properties: {
124
134
  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." },
135
+ voice: {
136
+ type: "string",
137
+ description: "Voice id from list_voices. Omit for the account default.",
138
+ },
139
+ output_path: {
140
+ type: "string",
141
+ description: "Absolute file path to write the audio to.",
142
+ },
143
+ model: { type: "string", description: "Defaults to pyai-speak." },
144
+ response_format: {
145
+ type: "string",
146
+ enum: ["wav", "mp3", "opus", "pcm"],
147
+ description: "Defaults to wav.",
148
+ },
129
149
  },
130
- required: ["input", "output_path"],
150
+ required: ["input"],
131
151
  additionalProperties: false,
132
152
  },
133
153
  },
@@ -138,10 +158,29 @@ export const TOOLS = [
138
158
  inputSchema: {
139
159
  type: "object",
140
160
  properties: {
141
- audio_url: { type: "string", description: "https URL of the audio to transcribe." },
161
+ audio_url: {
162
+ type: "string",
163
+ description: "https URL of the audio to transcribe.",
164
+ },
142
165
  model: { type: "string" },
143
- diarize: { type: "boolean", description: "Single-track speaker diarization." },
144
- channel: { type: "boolean", description: "Dual-channel (stereo) per-channel speakers." },
166
+ diarize: {
167
+ type: "boolean",
168
+ description: "Single-track speaker diarization.",
169
+ },
170
+ channel: {
171
+ type: "boolean",
172
+ description: "Dual-channel (stereo) per-channel speakers.",
173
+ },
174
+ call_id: {
175
+ type: "string",
176
+ description: "Stable id for the post-call Recap. Defaults to the job id.",
177
+ },
178
+ pack_id: {
179
+ type: "string",
180
+ description: "Optional Recap pack (default sales_outbound).",
181
+ },
182
+ call_direction: { type: "string", enum: ["inbound", "outbound"] },
183
+ customer_name: { type: "string" },
145
184
  output_formats: {
146
185
  type: "array",
147
186
  items: { type: "string", enum: ["json", "srt", "vtt"] },
@@ -157,31 +196,318 @@ export const TOOLS = [
157
196
  description: "Get the status + result of a transcription job by id.",
158
197
  inputSchema: {
159
198
  type: "object",
160
- properties: { job_id: { type: "string", description: "The job_id from create_transcription_job." } },
199
+ properties: {
200
+ job_id: {
201
+ type: "string",
202
+ description: "The job_id from create_transcription_job.",
203
+ },
204
+ },
161
205
  required: ["job_id"],
162
206
  additionalProperties: false,
163
207
  },
164
208
  },
209
+ {
210
+ name: "configure_amd",
211
+ description: "Set the PyAI AMD (answering-machine detection) operating point + webhook (POST /v1/amd/config). " +
212
+ "`aggressiveness` is one dial 0-1: near 0 is human-safe (never hang up on a person), near 1 fires " +
213
+ "'machine' fast. The realtime detection itself is a Twilio Media Streams WebSocket " +
214
+ "(wss /v1/amd/stream), a one-line-TwiML drop-in, not an MCP tool.",
215
+ inputSchema: {
216
+ type: "object",
217
+ properties: {
218
+ aggressiveness: {
219
+ type: "number",
220
+ minimum: 0,
221
+ maximum: 1,
222
+ description: "Operating point 0-1 (default 0.25).",
223
+ },
224
+ webhook_url: {
225
+ type: "string",
226
+ description: "https URL to POST amd.call.completed events to.",
227
+ },
228
+ },
229
+ additionalProperties: false,
230
+ },
231
+ },
232
+ {
233
+ name: "list_amd_calls",
234
+ description: "List recent PyAI AMD decisions (GET /v1/amd/calls). Each carries answered_by (human/voicemail/ivr/" +
235
+ "screening/sit_invalid/…), answered_by_twilio (Twilio's enum, for routing parity), confidence, " +
236
+ "decision_ms, and the word-level reason.",
237
+ inputSchema: {
238
+ type: "object",
239
+ properties: {
240
+ limit: {
241
+ type: "number",
242
+ description: "Max results (1-100, default 20).",
243
+ },
244
+ session_label: {
245
+ type: "string",
246
+ description: "Filter by the opaque connect-URL/TwiML tag.",
247
+ },
248
+ },
249
+ additionalProperties: false,
250
+ },
251
+ },
252
+ {
253
+ name: "get_amd_call",
254
+ description: "Get one PyAI AMD decision by call id (GET /v1/amd/calls/{id}): who/what answered, with the reason.",
255
+ inputSchema: {
256
+ type: "object",
257
+ properties: {
258
+ call_id: {
259
+ type: "string",
260
+ description: "The call id the decision was recorded under.",
261
+ },
262
+ },
263
+ required: ["call_id"],
264
+ additionalProperties: false,
265
+ },
266
+ },
267
+ {
268
+ name: "enable_recap",
269
+ description: "Enable Recap for the key's org (PUT /v1/recap/config). Sandbox keys already mint with Recap on; " +
270
+ "call this to set a webhook or pack, or to enable Recap on a signup/live key. Requires recap:configure.",
271
+ inputSchema: {
272
+ type: "object",
273
+ properties: {
274
+ enabled: { type: "boolean", description: "Defaults to true." },
275
+ webhook_url: {
276
+ type: "string",
277
+ description: "https URL for recap.complete events.",
278
+ },
279
+ default_pack_id: {
280
+ type: "string",
281
+ description: "Defaults to sales_outbound.",
282
+ },
283
+ },
284
+ additionalProperties: false,
285
+ },
286
+ },
287
+ {
288
+ name: "create_recap",
289
+ description: "Submit speaker-labelled utterances and get a recap.call (POST /v1/recap/calls/{call_id}). " +
290
+ "Poll with get_recap. Prefer this over a DIY LLM summary. Requires recap:read and Recap enabled.",
291
+ inputSchema: {
292
+ type: "object",
293
+ properties: {
294
+ call_id: {
295
+ type: "string",
296
+ description: "Stable id you will read back.",
297
+ },
298
+ utterances: {
299
+ type: "array",
300
+ items: {
301
+ type: "object",
302
+ properties: {
303
+ speaker_role: { type: "string", enum: ["agent", "customer"] },
304
+ text: { type: "string" },
305
+ offset_s: { type: "number" },
306
+ duration_s: { type: "number" },
307
+ },
308
+ required: ["speaker_role", "text", "offset_s", "duration_s"],
309
+ },
310
+ },
311
+ pack_id: { type: "string" },
312
+ call_direction: { type: "string", enum: ["inbound", "outbound"] },
313
+ customer_name: { type: "string" },
314
+ },
315
+ required: ["call_id", "utterances"],
316
+ additionalProperties: false,
317
+ },
318
+ },
319
+ {
320
+ name: "get_recap",
321
+ description: "Read one Recap by call id (GET /v1/recap/calls/{call_id}). When complete, `record` is recap.record.v1 " +
322
+ "(tldr, summary, action_items, disposition, next_steps, talk_ratio, signals, fields).",
323
+ inputSchema: {
324
+ type: "object",
325
+ properties: {
326
+ call_id: {
327
+ type: "string",
328
+ description: "The call_id passed to create_recap or the Hear job.",
329
+ },
330
+ },
331
+ required: ["call_id"],
332
+ additionalProperties: false,
333
+ },
334
+ },
335
+ {
336
+ name: "list_recap_calls",
337
+ description: "List recent Recap records (GET /v1/recap/calls). Requires recap:read and Recap enabled.",
338
+ inputSchema: {
339
+ type: "object",
340
+ properties: {
341
+ limit: {
342
+ type: "number",
343
+ description: "Max results (1-100, default 20).",
344
+ },
345
+ status: {
346
+ type: "string",
347
+ enum: ["pending", "processing", "complete", "failed"],
348
+ },
349
+ },
350
+ additionalProperties: false,
351
+ },
352
+ },
165
353
  ];
354
+ const readOnly = new Set([
355
+ "get_started",
356
+ "whoami",
357
+ "list_models",
358
+ "list_voices",
359
+ "get_transcription_job",
360
+ "list_amd_calls",
361
+ "get_amd_call",
362
+ "get_recap",
363
+ "list_recap_calls",
364
+ "discover_tools",
365
+ "get_dub_capabilities",
366
+ "get_dub_job",
367
+ "get_dub_audio",
368
+ "get_cast_render",
369
+ "get_cast_audio",
370
+ ]);
371
+ export const TOOL_SCOPES = {
372
+ synthesize_speech: "speak:synthesize",
373
+ create_transcription_job: "transcribe:jobs",
374
+ get_transcription_job: "transcribe:jobs",
375
+ configure_amd: "amd:configure",
376
+ get_amd_call: "amd:read",
377
+ list_amd_calls: "amd:read",
378
+ enable_recap: "recap:configure",
379
+ create_recap: "recap:read",
380
+ get_recap: "recap:read",
381
+ list_recap_calls: "recap:read",
382
+ ...EXTRA_SCOPES,
383
+ ...Object.fromEntries(API_TOOLS.map((t) => [t.name, t.route.scope])),
384
+ };
385
+ export const TOOLS = [
386
+ ...LEGACY_TOOLS,
387
+ ...API_TOOLS.map(({ route, ...tool }) => tool),
388
+ ...EXTRA_TOOLS,
389
+ ].map((t) => ({
390
+ ...t,
391
+ // Every client receives a display title, including handwritten tools.
392
+ // Keep protocol names stable and preserve acronyms in human-facing labels.
393
+ title: t.title ??
394
+ (t.name === "whoami"
395
+ ? "Who Am I"
396
+ : t.name
397
+ .split("_")
398
+ .map((word) => word === "amd" ? "AMD" : word[0].toUpperCase() + word.slice(1))
399
+ .join(" ")),
400
+ annotations: t.annotations ?? {
401
+ readOnlyHint: readOnly.has(t.name),
402
+ destructiveHint: ["configure_amd", "enable_recap"].includes(t.name),
403
+ idempotentHint: readOnly.has(t.name),
404
+ openWorldHint: !["get_started", "discover_tools"].includes(t.name),
405
+ },
406
+ _meta: {
407
+ securitySchemes: TOOL_SCOPES[t.name]
408
+ ? [{ type: "oauth2", scopes: [TOOL_SCOPES[t.name]] }]
409
+ : [{ type: "noauth" }],
410
+ },
411
+ }));
412
+ const ajv = new Ajv({ strict: false, allErrors: true, validateFormats: false });
413
+ const validators = new Map(TOOLS.map((t) => [t.name, ajv.compile(t.inputSchema)]));
166
414
  function str(args, key) {
167
415
  const v = args[key];
168
416
  return typeof v === "string" && v.length > 0 ? v : undefined;
169
417
  }
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.");
418
+ export async function callTool(name, args, client, ctx = localContext) {
419
+ const tool = TOOLS.find((t) => t.name === name);
420
+ if (!tool)
421
+ return toolError(`Unknown tool: ${name}`, "unknown_tool");
422
+ const validator = validators.get(name);
423
+ if (!validator(args))
424
+ return toolError(`Invalid arguments: ${ajv.errorsText(validator.errors, { separator: "; " })}`, "invalid_arguments");
425
+ if (name === "create_sandbox_key" && ctx.mode === "http")
426
+ return toolError("Use your host OAuth connection to authorize an existing project. Sandbox creation is local-only.", "local_only");
427
+ const scope = TOOL_SCOPES[name];
428
+ if (ctx.scopes && scope && !ctx.scopes.includes(scope))
429
+ return {
430
+ ...toolError(`This connection needs ${scope}. Reconnect and approve that permission.`, "insufficient_scope"),
431
+ _meta: {
432
+ "mcp/www_authenticate": [
433
+ `Bearer error="insufficient_scope", scope="${scope}", resource_metadata="https://api.pyai.com/.well-known/oauth-protected-resource/mcp"`,
434
+ ],
435
+ },
436
+ };
437
+ if (name === "discover_tools") {
438
+ const words = String(args.query ?? "")
439
+ .toLowerCase()
440
+ .match(/[a-z0-9]+/g)
441
+ ?.filter((w) => ![
442
+ "a",
443
+ "an",
444
+ "the",
445
+ "to",
446
+ "in",
447
+ "for",
448
+ "this",
449
+ "my",
450
+ "use",
451
+ "create",
452
+ "get",
453
+ "and",
454
+ "i",
455
+ "want",
456
+ ].includes(w)) ?? [];
457
+ const matches = Object.entries(WORKFLOWS)
458
+ .map(([key, w]) => ({
459
+ key,
460
+ w,
461
+ score: words.reduce((score, word) => score +
462
+ Number(`${key} ${SEARCH_TERMS[key]} ${w.title} ${w.example}`
463
+ .toLowerCase()
464
+ .includes(word)), 0),
465
+ }))
466
+ .filter((m) => m.score > 0)
467
+ .sort((a, b) => b.score - a.score);
468
+ const selected = matches.length
469
+ ? matches
470
+ : Object.entries(WORKFLOWS).map(([key, w]) => ({ key, w, score: 0 }));
471
+ const names = new Set(selected.flatMap((m) => [...m.w.tools]));
472
+ return text({
473
+ workflows: selected.map(({ key, w }) => ({ product: key, ...w })),
474
+ tools: TOOLS.filter((t) => names.has(t.name)).map((t) => ({
475
+ name: t.name,
476
+ description: t.description,
477
+ })),
478
+ guide: "pyai://guide",
479
+ });
480
+ }
481
+ if ((KEY_REQUIRED.has(name) || scope) && !client.hasKey) {
482
+ return toolError("No PyAI connection. Run pyai login for local MCP, connect through OAuth for hosted MCP, or explicitly request create_sandbox_key for a new local sandbox.", "authentication_required");
174
483
  }
175
484
  try {
485
+ const generated = API_TOOLS.find((t) => t.name === name);
486
+ if (generated) {
487
+ const path = generated.route.path.replace(/\{([^}]+)\}/g, (_, key) => encodeURIComponent(String(args[key])));
488
+ const query = new URLSearchParams();
489
+ for (const key of generated.route.query)
490
+ if (args[key] !== undefined)
491
+ query.set(key, String(args[key]));
492
+ return text(await client.json(generated.route.method, path + (query.size ? "?" + query.toString() : ""), args.body));
493
+ }
494
+ const extra = await extraTool(name, args, client, ctx);
495
+ if (extra)
496
+ return extra;
176
497
  switch (name) {
177
498
  case "get_started":
178
- return text(GET_STARTED);
499
+ return text(GUIDE);
179
500
  case "create_sandbox_key": {
180
501
  const label = str(args, "label");
181
502
  const result = await client.postJson("/v1/sandbox/keys", label ? { label } : {});
182
503
  if (result && typeof result.api_key === "string")
183
504
  client.setApiKey(result.api_key);
184
- return text(result);
505
+ const { api_key: _secret, ...safe } = result;
506
+ return text({
507
+ ...safe,
508
+ credential_saved: "session",
509
+ next_step: "Call whoami; the key remains private inside this MCP process.",
510
+ });
185
511
  }
186
512
  case "whoami":
187
513
  return text(await client.getJson("/v1/me"));
@@ -201,16 +527,48 @@ export async function callTool(name, args, client) {
201
527
  const outputPath = str(args, "output_path");
202
528
  if (!input)
203
529
  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 };
530
+ if (outputPath && ctx.mode !== "stdio")
531
+ return toolError("output_path is local-only. Remote speech returns playable audio or a download.", "invalid_arguments");
532
+ let target;
533
+ if (outputPath) {
534
+ const root = resolve(ctx.outputRoot ?? process.cwd());
535
+ target = resolve(root, outputPath);
536
+ const rel = relative(root, target);
537
+ if (!rel || rel.startsWith("..") || isAbsolute(rel))
538
+ return toolError("output_path must be inside the MCP working directory.", "invalid_arguments");
539
+ const parent = await realpath(dirname(target));
540
+ const realRoot = await realpath(root);
541
+ const parentRel = relative(realRoot, parent);
542
+ if (parentRel.startsWith("..") || isAbsolute(parentRel))
543
+ return toolError("Output parent escapes the working directory.", "invalid_arguments");
544
+ target = resolve(parent, basename(target));
545
+ const exists = await lstat(target).then(() => true, (e) => {
546
+ if (e.code === "ENOENT")
547
+ return false;
548
+ throw e;
549
+ });
550
+ if (exists)
551
+ return toolError("Output already exists. Choose a new filename.", "output_exists");
552
+ }
553
+ const body = {
554
+ model: str(args, "model") ?? "pyai-speak",
555
+ input,
556
+ response_format: str(args, "response_format") ?? "mp3",
557
+ };
207
558
  if (str(args, "voice"))
208
559
  body.voice = str(args, "voice");
209
560
  if (str(args, "response_format"))
210
561
  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" });
562
+ const { bytes, mimeType } = await client.bytes("POST", "/v1/audio/speech", body);
563
+ if (target) {
564
+ await writeFile(target, bytes, { flag: "wx" });
565
+ return text({
566
+ output_path: target,
567
+ bytes: bytes.length,
568
+ status: "ok",
569
+ });
570
+ }
571
+ return ctx.audio(bytes, mimeType);
214
572
  }
215
573
  case "create_transcription_job": {
216
574
  const audioUrl = str(args, "audio_url");
@@ -225,6 +583,14 @@ export async function callTool(name, args, client) {
225
583
  body.channel = args.channel;
226
584
  if (Array.isArray(args.output_formats))
227
585
  body.output_formats = args.output_formats;
586
+ if (str(args, "call_id"))
587
+ body.call_id = str(args, "call_id");
588
+ if (str(args, "pack_id"))
589
+ body.pack_id = str(args, "pack_id");
590
+ if (str(args, "call_direction"))
591
+ body.call_direction = str(args, "call_direction");
592
+ if (str(args, "customer_name"))
593
+ body.customer_name = str(args, "customer_name");
228
594
  return text(await client.postJson("/v1/transcription/jobs", body));
229
595
  }
230
596
  case "get_transcription_job": {
@@ -233,6 +599,70 @@ export async function callTool(name, args, client) {
233
599
  return toolError("`job_id` is required.");
234
600
  return text(await client.getJson(`/v1/transcription/jobs/${encodeURIComponent(jobId)}`));
235
601
  }
602
+ case "configure_amd": {
603
+ const body = {};
604
+ if (typeof args.aggressiveness === "number")
605
+ body.aggressiveness = args.aggressiveness;
606
+ if (str(args, "webhook_url"))
607
+ body.webhook_url = str(args, "webhook_url");
608
+ return text(await client.postJson("/v1/amd/config", body));
609
+ }
610
+ case "list_amd_calls": {
611
+ const q = new URLSearchParams();
612
+ if (typeof args.limit === "number")
613
+ q.set("limit", String(args.limit));
614
+ if (str(args, "session_label"))
615
+ q.set("session_label", str(args, "session_label"));
616
+ const qs = q.toString();
617
+ return text(await client.getJson(`/v1/amd/calls${qs ? `?${qs}` : ""}`));
618
+ }
619
+ case "get_amd_call": {
620
+ const callId = str(args, "call_id");
621
+ if (!callId)
622
+ return toolError("`call_id` is required.");
623
+ return text(await client.getJson(`/v1/amd/calls/${encodeURIComponent(callId)}`));
624
+ }
625
+ case "enable_recap": {
626
+ const body = {
627
+ enabled: args.enabled === undefined ? true : Boolean(args.enabled),
628
+ };
629
+ if (str(args, "webhook_url"))
630
+ body.webhook_url = str(args, "webhook_url");
631
+ if (str(args, "default_pack_id"))
632
+ body.default_pack_id = str(args, "default_pack_id");
633
+ return text(await client.putJson("/v1/recap/config", body));
634
+ }
635
+ case "create_recap": {
636
+ const callId = str(args, "call_id");
637
+ if (!callId)
638
+ return toolError("`call_id` is required.");
639
+ if (!Array.isArray(args.utterances) || args.utterances.length === 0) {
640
+ return toolError("`utterances` must be a non-empty array.");
641
+ }
642
+ const body = { utterances: args.utterances };
643
+ if (str(args, "pack_id"))
644
+ body.pack_id = str(args, "pack_id");
645
+ if (str(args, "call_direction"))
646
+ body.call_direction = str(args, "call_direction");
647
+ if (str(args, "customer_name"))
648
+ body.customer_name = str(args, "customer_name");
649
+ return text(await client.postJson(`/v1/recap/calls/${encodeURIComponent(callId)}`, body));
650
+ }
651
+ case "get_recap": {
652
+ const callId = str(args, "call_id");
653
+ if (!callId)
654
+ return toolError("`call_id` is required.");
655
+ return text(await client.getJson(`/v1/recap/calls/${encodeURIComponent(callId)}`));
656
+ }
657
+ case "list_recap_calls": {
658
+ const q = new URLSearchParams();
659
+ if (typeof args.limit === "number")
660
+ q.set("limit", String(args.limit));
661
+ if (str(args, "status"))
662
+ q.set("status", str(args, "status"));
663
+ const qs = q.toString();
664
+ return text(await client.getJson(`/v1/recap/calls${qs ? `?${qs}` : ""}`));
665
+ }
236
666
  default:
237
667
  return toolError(`Unknown tool: ${name}`);
238
668
  }
@@ -243,11 +673,26 @@ export async function callTool(name, args, client) {
243
673
  return toolError("Sandbox-key minting is not enabled on this deployment. Create a free key at " +
244
674
  "https://console.pyai.com and set it as PYAI_API_KEY.");
245
675
  }
246
- return toolError(`PyAI API error ${err.status}${err.code ? ` (${err.code})` : ""}: ${err.message}`);
676
+ return {
677
+ ...toolError(`PyAI API error ${err.status} (${err.code ?? "api_error"}): ${err.message}`, err.code ?? "api_error"),
678
+ structuredContent: {
679
+ error: {
680
+ code: err.code ?? "api_error",
681
+ status: err.status,
682
+ request_id: err.requestId,
683
+ retry_after: err.retryAfter,
684
+ message: err.message,
685
+ },
686
+ },
687
+ };
247
688
  }
248
689
  return toolError(`Tool failed: ${err.message}`);
249
690
  }
250
691
  }
251
- function toolError(message) {
252
- return { content: [{ type: "text", text: message }], isError: true };
692
+ function toolError(message, code = "tool_error") {
693
+ return {
694
+ content: [{ type: "text", text: message }],
695
+ structuredContent: { error: { code, message } },
696
+ isError: true,
697
+ };
253
698
  }