@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/AGENT_GUIDE.md +47 -0
- package/README.md +81 -65
- package/dist/client.js +94 -52
- package/dist/extra-tools.js +150 -0
- package/dist/generated-tools.js +1473 -0
- package/dist/hosted.js +2 -0
- package/dist/http.js +35 -62
- package/dist/index.js +61 -58
- package/dist/rpc.js +3 -3
- package/dist/server.js +93 -0
- package/dist/tools.js +527 -82
- package/dist/workflows.js +127 -0
- package/package.json +23 -8
- package/server.json +30 -0
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 {
|
|
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
|
|
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: "
|
|
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: {
|
|
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
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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: {
|
|
111
|
-
|
|
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
|
|
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: {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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"
|
|
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: {
|
|
161
|
+
audio_url: {
|
|
162
|
+
type: "string",
|
|
163
|
+
description: "https URL of the audio to transcribe.",
|
|
164
|
+
},
|
|
142
165
|
model: { type: "string" },
|
|
143
|
-
diarize: {
|
|
144
|
-
|
|
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: {
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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(
|
|
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
|
-
|
|
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 (
|
|
205
|
-
return toolError("
|
|
206
|
-
|
|
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.
|
|
212
|
-
|
|
213
|
-
|
|
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
|
|
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 {
|
|
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
|
}
|