@alfe.ai/mcp-tools 0.2.4 → 0.2.6

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/index.js CHANGED
@@ -1,5 +1,8 @@
1
1
  import { z } from "zod";
2
- import { isAbsolute, resolve } from "node:path";
2
+ import { TextDecoder } from "node:util";
3
+ import { constants } from "node:fs";
4
+ import { lstat, open, realpath, unlink } from "node:fs/promises";
5
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
6
  //#region src/types.ts
4
7
  function ok(data) {
5
8
  return { content: [{
@@ -19,16 +22,34 @@ function err(message) {
19
22
  //#endregion
20
23
  //#region src/integrations.ts
21
24
  const OAUTH_PROVIDERS = [
22
- "xero",
25
+ "atlassian",
26
+ "ctrader",
27
+ "discord",
28
+ "github",
23
29
  "google",
24
- "notion",
25
30
  "microsoft",
26
- "atlassian",
27
31
  "myob",
28
- "github",
29
- "discord",
30
- "slack"
32
+ "notion",
33
+ "salesforce",
34
+ "shopify",
35
+ "slack",
36
+ "x",
37
+ "xero"
31
38
  ];
39
+ function oauthStartUrl(ctx, provider, scopes, shop) {
40
+ if (!ctx.agentId || ctx.agentId.length > 256 || !ctx.tenantId || ctx.tenantId.length > 256) throw new Error("OAuth context is missing a valid agent or tenant identity.");
41
+ const url = new URL(ctx.apiUrl);
42
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) throw new Error("OAuth API URL must be an HTTP(S) URL without embedded credentials.");
43
+ url.hash = "";
44
+ url.search = "";
45
+ url.pathname = `${url.pathname.replace(/\/+$/u, "")}/connect/oauth/start`;
46
+ url.searchParams.set("provider", provider);
47
+ url.searchParams.set("tenantId", ctx.tenantId);
48
+ url.searchParams.set("scope", `agent:${ctx.agentId}`);
49
+ if (scopes) url.searchParams.set("scopes", scopes);
50
+ if (shop) url.searchParams.set("shop", shop);
51
+ return url.toString();
52
+ }
32
53
  /**
33
54
  * Thin-slice subset of the `services/mcp` `integrations` domain — only
34
55
  * the two subtools whose underlying calls already hit public
@@ -40,7 +61,7 @@ const OAUTH_PROVIDERS = [
40
61
  * - `integrations_browse_registry` — public `GET /integrations/registry`
41
62
  * via `AgentApiClient.getRegistry()`.
42
63
  * - `integrations_start_oauth` — generates the public
43
- * `GET /<provider>/oauth/start` URL for the user to open in a browser.
64
+ * universal `GET /connect/oauth/start` URL for the user to open in a browser.
44
65
  * Pure URL builder; no server-side call. Completion is confirmed out of
45
66
  * band (the dashboard, or the hosted MCP server's
46
67
  * `integrations_check_oauth_status`) — the local server intentionally has
@@ -61,24 +82,124 @@ const registerIntegrationsTools = (server, ctx) => {
61
82
  description: "Start an OAuth connection flow for an integration. Returns a URL the user must open in their browser to authorize the connection. After the user authorizes, the connection appears in the Alfe dashboard — this local server does not expose a status-check tool, so confirm completion there.",
62
83
  inputSchema: {
63
84
  provider: z.enum(OAUTH_PROVIDERS).describe("The OAuth provider to connect"),
64
- scopes: z.string().optional().describe("Comma-separated scope groups (e.g. 'gmail,drive' for Google)")
85
+ scopes: z.string().trim().min(1).max(1024).optional().describe("Comma-separated scope groups (e.g. 'gmail,drive' for Google)"),
86
+ shop: z.string().trim().min(1).max(512).optional().describe("Shopify store handle or myshopify.com domain (required for Shopify only)")
87
+ }
88
+ }, ({ provider, scopes, shop }) => {
89
+ try {
90
+ if (provider === "shopify" && !shop) return Promise.resolve(err("Shopify OAuth requires the shop handle or myshopify.com domain."));
91
+ if (provider !== "shopify" && shop) return Promise.resolve(err("The shop argument is only valid for Shopify OAuth."));
92
+ const url = oauthStartUrl(ctx, provider, scopes, shop);
93
+ return Promise.resolve(ok({
94
+ url,
95
+ provider,
96
+ message: `Open this URL to connect ${provider}: ${url}`
97
+ }));
98
+ } catch (error) {
99
+ return Promise.resolve(err(error instanceof Error ? error.message : "Failed to generate OAuth URL"));
65
100
  }
66
- }, ({ provider, scopes }) => {
67
- const params = new URLSearchParams({
68
- agentId: ctx.agentId,
69
- tenantId: ctx.tenantId
70
- });
71
- if (scopes) params.set("scopes", scopes);
72
- const url = `${ctx.apiUrl}/${provider}/oauth/start?${params.toString()}`;
73
- return Promise.resolve(ok({
74
- url,
75
- provider,
76
- message: `Open this URL to connect ${provider}: ${url}`
77
- }));
78
101
  });
79
102
  };
80
103
  //#endregion
104
+ //#region src/workspace-files.ts
105
+ const MAX_TOOL_PATH_LENGTH = 1024;
106
+ function isWithin(root, candidate) {
107
+ const rel = relative(root, candidate);
108
+ return rel === "" || !isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`);
109
+ }
110
+ function assertRelativeToolPath(input, label) {
111
+ if (input.length === 0) throw new Error(`${label} must not be empty.`);
112
+ if (input.length > MAX_TOOL_PATH_LENGTH) throw new Error(`${label} is too long (maximum ${String(MAX_TOOL_PATH_LENGTH)} characters).`);
113
+ if (input.includes("\0")) throw new Error(`${label} contains a null byte.`);
114
+ if (isAbsolute(input) || /^[A-Za-z]:[\\/]/u.test(input) || input.startsWith("\\\\")) throw new Error(`${label} must be relative to the workspace.`);
115
+ if (input.split(/[\\/]/u).includes("..")) throw new Error(`${label} must not contain parent-directory traversal.`);
116
+ }
117
+ async function workspaceRoot() {
118
+ return realpath(process.cwd());
119
+ }
120
+ /**
121
+ * Read a regular file that resolves inside the current workspace.
122
+ *
123
+ * The lexical check rejects obvious traversal, while `realpath` closes the
124
+ * symlink escape that a simple `resolve(...).startsWith(...)` check leaves
125
+ * open. The descriptor is opened with `O_NOFOLLOW`, then size-checked both
126
+ * before and after the read so a growing file cannot bypass the byte cap.
127
+ */
128
+ async function readWorkspaceFile(input, maxBytes) {
129
+ assertRelativeToolPath(input, "Path");
130
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) throw new Error("Maximum file size must be a positive safe integer.");
131
+ const root = await workspaceRoot();
132
+ const lexicalPath = resolve(root, input);
133
+ if (!isWithin(root, lexicalPath)) throw new Error("Path escapes the workspace.");
134
+ const absolutePath = await realpath(lexicalPath);
135
+ if (!isWithin(root, absolutePath)) throw new Error("Path resolves outside the workspace.");
136
+ const handle = await open(absolutePath, constants.O_RDONLY | constants.O_NOFOLLOW);
137
+ try {
138
+ const before = await handle.stat();
139
+ if (!before.isFile()) throw new Error("Path must refer to a regular file.");
140
+ if (before.size > maxBytes) throw new Error(`File is too large (maximum ${String(maxBytes)} bytes).`);
141
+ const data = await handle.readFile();
142
+ const after = await handle.stat();
143
+ if (data.length > maxBytes || after.size > maxBytes) throw new Error(`File is too large (maximum ${String(maxBytes)} bytes).`);
144
+ return {
145
+ absolutePath,
146
+ data
147
+ };
148
+ } finally {
149
+ await handle.close();
150
+ }
151
+ }
152
+ /**
153
+ * Write a new file beneath the current workspace without following a parent
154
+ * symlink outside it and without replacing an existing file.
155
+ */
156
+ async function resolveWorkspaceOutputPath(input) {
157
+ assertRelativeToolPath(input, "Output path");
158
+ const root = await workspaceRoot();
159
+ const lexicalPath = resolve(root, input);
160
+ if (!isWithin(root, lexicalPath)) throw new Error("Output path escapes the workspace.");
161
+ const parent = await realpath(dirname(lexicalPath));
162
+ if (!isWithin(root, parent)) throw new Error("Output path resolves outside the workspace.");
163
+ const absolutePath = join(parent, basename(lexicalPath));
164
+ try {
165
+ await lstat(absolutePath);
166
+ throw new Error(`Refusing to overwrite existing workspace file: ${input}`);
167
+ } catch (error) {
168
+ if (error.code !== "ENOENT") throw error;
169
+ }
170
+ return absolutePath;
171
+ }
172
+ async function writeWorkspaceFileExclusive(input, data) {
173
+ const absolutePath = await resolveWorkspaceOutputPath(input);
174
+ let handle;
175
+ try {
176
+ handle = await open(absolutePath, "wx", 384);
177
+ } catch (error) {
178
+ if (error.code === "EEXIST") throw new Error(`Refusing to overwrite existing workspace file: ${input}`);
179
+ throw error;
180
+ }
181
+ let completed = false;
182
+ try {
183
+ await handle.writeFile(data);
184
+ await handle.sync();
185
+ completed = true;
186
+ return absolutePath;
187
+ } finally {
188
+ await handle.close();
189
+ if (!completed) await unlink(absolutePath).catch(() => void 0);
190
+ }
191
+ }
192
+ //#endregion
81
193
  //#region src/memory.ts
194
+ const MEMORY_TAGS = [
195
+ "fact",
196
+ "decision",
197
+ "preference",
198
+ "event",
199
+ "discovery"
200
+ ];
201
+ const MAX_MEMORY_TEXT_CHARACTERS = 1e5;
202
+ const MAX_MEMORY_FILE_BYTES = MAX_MEMORY_TEXT_CHARACTERS * 4;
82
203
  /**
83
204
  * Memory tools — a runtime-agnostic mirror of the OpenClaw memory-cloud
84
205
  * plugin (`@alfe.ai/openclaw-memory-cloud`). All 7 tools (memory_recall,
@@ -103,10 +224,10 @@ const registerMemoryTools = (server, ctx) => {
103
224
  server.registerTool("memory_recall", {
104
225
  description: "Search conversation memory and knowledge graph. Returns structured facts and relevant conversation excerpts.",
105
226
  inputSchema: {
106
- query: z.string().describe("What to search for"),
107
- limit: z.coerce.number().optional().describe("Maximum results (default 10)"),
108
- topic: z.string().optional().describe("Filter by topic"),
109
- tag: z.string().optional().describe("Filter by tag (fact/decision/preference/event/discovery)")
227
+ query: z.string().trim().min(1).max(4e3).describe("What to search for"),
228
+ limit: z.coerce.number().int().min(1).max(50).optional().describe("Maximum results (default 10)"),
229
+ topic: z.string().trim().min(1).max(256).optional().describe("Filter by topic"),
230
+ tag: z.enum(MEMORY_TAGS).optional().describe("Filter by tag (fact/decision/preference/event/discovery)")
110
231
  }
111
232
  }, async (args) => {
112
233
  try {
@@ -122,10 +243,10 @@ const registerMemoryTools = (server, ctx) => {
122
243
  server.registerTool("memory_store", {
123
244
  description: "Explicitly save a piece of information to long-term memory.",
124
245
  inputSchema: {
125
- text: z.string().describe("The information to remember"),
126
- topic: z.string().optional().describe("Topic category (e.g., person name, project)"),
127
- tag: z.string().optional().describe("Memory type: fact, decision, preference, event, discovery"),
128
- importance: z.coerce.number().optional().describe("Importance 0-1 (default 0.7)")
246
+ text: z.string().trim().min(1).max(5e3).describe("The information to remember"),
247
+ topic: z.string().trim().min(1).max(128).optional().describe("Topic category (e.g., person name, project)"),
248
+ tag: z.enum(MEMORY_TAGS).optional().describe("Memory type: fact, decision, preference, event, discovery"),
249
+ importance: z.coerce.number().min(0).max(1).optional().describe("Importance 0-1 (default 0.7)")
129
250
  }
130
251
  }, async (args) => {
131
252
  try {
@@ -145,27 +266,24 @@ const registerMemoryTools = (server, ctx) => {
145
266
  server.registerTool("memory_learn", {
146
267
  description: "Save arbitrary content (a paragraph, a doc, a fact list) into long-term memory. The system auto-classifies and extracts entities. Use this when the user asks you to learn, remember, or study something — or when you read a document worth retaining. Pass the source text directly; do not paraphrase first.",
147
268
  inputSchema: {
148
- content: z.string().optional().describe("Inline text to ingest. Provide either content OR path, not both."),
149
- path: z.string().optional().describe("Workspace-relative file path to read and ingest. Provide either content OR path, not both."),
150
- source: z.string().optional().describe("Optional label describing where the content came from (e.g. doc title, url).")
269
+ content: z.string().min(1).max(MAX_MEMORY_TEXT_CHARACTERS).optional().describe("Inline text to ingest. Provide either content OR path, not both."),
270
+ path: z.string().min(1).max(1024).optional().describe("Workspace-relative file path to read and ingest. Provide either content OR path, not both."),
271
+ source: z.string().min(1).max(512).optional().describe("Optional label describing where the content came from (e.g. doc title, url).")
151
272
  }
152
273
  }, async (args) => {
153
274
  const content = typeof args.content === "string" ? args.content : void 0;
154
275
  const filePath = typeof args.path === "string" ? args.path : void 0;
155
276
  const source = typeof args.source === "string" ? args.source : void 0;
156
- if (!content && !filePath) return err("Error: provide either `content` or `path`.");
157
- if (content && filePath) return err("Error: provide either `content` or `path`, not both.");
277
+ if (content === void 0 && filePath === void 0) return err("Error: provide either `content` or `path`.");
278
+ if (content !== void 0 && filePath !== void 0) return err("Error: provide either `content` or `path`, not both.");
279
+ if (source !== void 0 && (source.length < 1 || source.length > 512)) return err("Error: source must contain 1–512 characters.");
158
280
  let text;
159
281
  let resolvedSource = source;
160
282
  let sourceType = "inline";
161
283
  if (filePath !== void 0) {
162
- const nodePath = await import("node:path");
163
- const fs = await import("node:fs/promises");
164
- const cwd = process.cwd();
165
- const resolved = nodePath.resolve(cwd, filePath);
166
- if (!resolved.startsWith(cwd + nodePath.sep) && resolved !== cwd) return err(`Error: path "${filePath}" escapes the workspace.`);
167
284
  try {
168
- text = await fs.readFile(resolved, "utf8");
285
+ const { data } = await readWorkspaceFile(filePath, MAX_MEMORY_FILE_BYTES);
286
+ text = new TextDecoder("utf-8", { fatal: true }).decode(data);
169
287
  } catch (readErr) {
170
288
  return err(`Error reading "${filePath}": ${readErr instanceof Error ? readErr.message : String(readErr)}`);
171
289
  }
@@ -173,6 +291,7 @@ const registerMemoryTools = (server, ctx) => {
173
291
  sourceType = "file";
174
292
  } else if (content !== void 0) text = content;
175
293
  else return err("Error: provide either `content` or `path`.");
294
+ if (text.trim().length === 0 || text.length > MAX_MEMORY_TEXT_CHARACTERS) return err(`Error: memory content must contain 1–${String(MAX_MEMORY_TEXT_CHARACTERS)} characters.`);
176
295
  try {
177
296
  const result = await ctx.client.memoryLearn({
178
297
  text,
@@ -193,7 +312,7 @@ const registerMemoryTools = (server, ctx) => {
193
312
  });
194
313
  server.registerTool("memory_forget", {
195
314
  description: "Search for and delete memories matching a query.",
196
- inputSchema: { query: z.string().describe("Search for memories to delete") }
315
+ inputSchema: { query: z.string().trim().min(1).max(4e3).describe("Search for memories to delete") }
197
316
  }, async (args) => {
198
317
  try {
199
318
  const results = await ctx.client.memorySearch(args.query, { limit: 5 });
@@ -217,7 +336,7 @@ const registerMemoryTools = (server, ctx) => {
217
336
  });
218
337
  server.registerTool("memory_graph", {
219
338
  description: "Look up what you know about a specific entity from the knowledge graph.",
220
- inputSchema: { entity: z.string().describe("The entity to look up (person, concept, system)") }
339
+ inputSchema: { entity: z.string().trim().min(1).max(512).describe("The entity to look up (person, concept, system)") }
221
340
  }, async ({ entity }) => {
222
341
  try {
223
342
  const result = await ctx.client.memoryLookupEntity(entity);
@@ -265,8 +384,26 @@ const registerMemoryTools = (server, ctx) => {
265
384
  };
266
385
  //#endregion
267
386
  //#region src/audio.ts
387
+ const MAX_WAV_PCM_BYTES = 4294967259;
388
+ const SUPPORTED_BIT_DEPTHS = new Set([
389
+ 8,
390
+ 16,
391
+ 24,
392
+ 32
393
+ ]);
394
+ function validatePcmFraming(framing, pcmLength) {
395
+ const { sampleRate, channels, bitDepth } = framing;
396
+ if (!Number.isInteger(sampleRate) || sampleRate < 8e3 || sampleRate > 384e3) throw new Error("WAV sample rate must be an integer between 8000 and 384000 Hz.");
397
+ if (!Number.isInteger(channels) || channels < 1 || channels > 32) throw new Error("WAV channel count must be an integer between 1 and 32.");
398
+ if (!SUPPORTED_BIT_DEPTHS.has(bitDepth)) throw new Error("WAV bit depth must be one of 8, 16, 24, or 32.");
399
+ const blockAlign = channels * (bitDepth / 8);
400
+ if (pcmLength % blockAlign !== 0) throw new Error("PCM byte length must contain a whole number of sample frames.");
401
+ if (pcmLength > MAX_WAV_PCM_BYTES) throw new Error("PCM payload is too large for a RIFF/WAV container.");
402
+ if (sampleRate * blockAlign > 4294967295) throw new Error("WAV byte rate exceeds the RIFF field limit.");
403
+ }
268
404
  /** Prepend a canonical 44-byte PCM WAV header to raw PCM samples. */
269
405
  function pcmToWav(pcm, framing) {
406
+ validatePcmFraming(framing, pcm.length);
270
407
  const { sampleRate, channels, bitDepth } = framing;
271
408
  const blockAlign = channels * (bitDepth / 8);
272
409
  const byteRate = sampleRate * blockAlign;
@@ -287,35 +424,59 @@ function pcmToWav(pcm, framing) {
287
424
  return Buffer.concat([header, pcm]);
288
425
  }
289
426
  /**
290
- * Parse a WAV buffer into its PCM payload + framing. Returns `null` when the
291
- * buffer is not a RIFF/WAVE file (caller should then treat the bytes as raw
292
- * PCM). Walks the chunk list so it tolerates `fmt `/`data` ordering, extra
293
- * chunks (e.g. `LIST`/`fact`), and word-alignment padding.
427
+ * Parse a WAV buffer into its PCM payload + framing. Returns `null` only when
428
+ * the buffer is not RIFF data (caller may then treat it as raw PCM). A RIFF
429
+ * buffer that claims to be WAV but is truncated, unsupported, or internally
430
+ * inconsistent throws instead of silently uploading container bytes as PCM.
294
431
  */
295
432
  function parseWav(buf) {
296
- if (buf.length < 44) return null;
297
- if (buf.toString("ascii", 0, 4) !== "RIFF") return null;
298
- if (buf.toString("ascii", 8, 12) !== "WAVE") return null;
433
+ if (buf.length < 4 || buf.toString("ascii", 0, 4) !== "RIFF") return null;
434
+ if (buf.length < 12) throw new Error("Malformed WAV: truncated RIFF header.");
435
+ if (buf.toString("ascii", 8, 12) !== "WAVE") throw new Error("Unsupported RIFF container: expected WAVE.");
436
+ const declaredEnd = buf.readUInt32LE(4) + 8;
437
+ if (declaredEnd !== buf.length) throw new Error("Malformed WAV: RIFF size does not match the file length.");
299
438
  let sampleRate = 0;
300
439
  let channels = 0;
301
440
  let bitDepth = 0;
302
441
  let pcm = null;
442
+ let blockAlign = 0;
443
+ let byteRate = 0;
444
+ let sawFmt = false;
445
+ let sawData = false;
303
446
  let offset = 12;
304
- while (offset + 8 <= buf.length) {
447
+ while (offset < declaredEnd) {
448
+ if (offset + 8 > declaredEnd) throw new Error("Malformed WAV: truncated chunk header.");
305
449
  const chunkId = buf.toString("ascii", offset, offset + 4);
306
450
  const chunkSize = buf.readUInt32LE(offset + 4);
307
451
  const bodyStart = offset + 8;
308
- if (chunkId === "fmt " && bodyStart + 16 <= buf.length) {
452
+ const bodyEnd = bodyStart + chunkSize;
453
+ const nextOffset = bodyEnd + chunkSize % 2;
454
+ if (bodyEnd > declaredEnd || nextOffset > declaredEnd) throw new Error(`Malformed WAV: truncated ${chunkId} chunk.`);
455
+ if (chunkId === "fmt ") {
456
+ if (sawFmt) throw new Error("Malformed WAV: duplicate fmt chunk.");
457
+ if (chunkSize < 16) throw new Error("Malformed WAV: fmt chunk is too short.");
458
+ if (buf.readUInt16LE(bodyStart) !== 1) throw new Error("Unsupported WAV encoding: only integer PCM is accepted.");
309
459
  channels = buf.readUInt16LE(bodyStart + 2);
310
460
  sampleRate = buf.readUInt32LE(bodyStart + 4);
461
+ byteRate = buf.readUInt32LE(bodyStart + 8);
462
+ blockAlign = buf.readUInt16LE(bodyStart + 12);
311
463
  bitDepth = buf.readUInt16LE(bodyStart + 14);
464
+ sawFmt = true;
312
465
  } else if (chunkId === "data") {
313
- const end = Math.min(bodyStart + chunkSize, buf.length);
314
- pcm = buf.subarray(bodyStart, end);
466
+ if (sawData) throw new Error("Malformed WAV: duplicate data chunk.");
467
+ pcm = buf.subarray(bodyStart, bodyEnd);
468
+ sawData = true;
315
469
  }
316
- offset = bodyStart + chunkSize + chunkSize % 2;
470
+ offset = nextOffset;
317
471
  }
318
- if (!pcm || sampleRate === 0 || channels === 0 || bitDepth === 0) return null;
472
+ if (!sawFmt || !sawData || pcm === null) throw new Error("Malformed WAV: both fmt and data chunks are required.");
473
+ validatePcmFraming({
474
+ sampleRate,
475
+ channels,
476
+ bitDepth
477
+ }, pcm.length);
478
+ const expectedBlockAlign = channels * (bitDepth / 8);
479
+ if (blockAlign !== expectedBlockAlign || byteRate !== sampleRate * expectedBlockAlign) throw new Error("Malformed WAV: byte rate or block alignment is inconsistent with its framing.");
319
480
  return {
320
481
  pcm,
321
482
  sampleRate,
@@ -325,6 +486,9 @@ function parseWav(buf) {
325
486
  }
326
487
  //#endregion
327
488
  //#region src/voice.ts
489
+ const MAX_AUDIO_FILE_BYTES = 10 * 1024 * 1024;
490
+ const MAX_TTS_RESPONSE_BYTES = 32 * 1024 * 1024;
491
+ const VOICE_ID_PATTERN = /^[A-Za-z0-9_-]{1,200}$/u;
328
492
  /**
329
493
  * Voice tools — a runtime-agnostic mirror of the one-shot TTS/STT tools in
330
494
  * the OpenClaw voice plugin (`@alfe.ai/openclaw-voice`). The channel-only
@@ -343,42 +507,48 @@ function parseWav(buf) {
343
507
  */
344
508
  const registerVoiceTools = (server, ctx) => {
345
509
  const voiceClient = ctx.voiceClient ?? ctx.client;
346
- const resolveWorkspacePath = (p) => isAbsolute(p) ? p : resolve(process.cwd(), p);
347
510
  server.registerTool("voice_tts", {
348
511
  description: "Convert text to speech using the Alfe voice service (ElevenLabs). Writes a playable audio file to the workspace and returns its path. Billed per character to your tenant credit pool.",
349
512
  inputSchema: {
350
- text: z.string().describe("Text to synthesize (1–5000 characters)."),
351
- outputPath: z.string().optional().describe("Where to write the audio file (absolute, or relative to the working directory). Defaults to alfe-tts-<timestamp>.wav in the working directory."),
513
+ text: z.string().trim().min(1).max(5e3).describe("Text to synthesize (1–5000 characters)."),
514
+ outputPath: z.string().min(1).max(1024).optional().describe("New workspace-relative audio file to create. Existing files are never overwritten. Defaults to alfe-tts-<timestamp>.wav."),
352
515
  format: z.enum(["wav", "pcm"]).optional().describe("Output container. 'wav' (default) is a playable file; 'pcm' is headerless 24kHz/mono/16-bit raw PCM."),
353
- voiceId: z.string().optional().describe("ElevenLabs voice ID. Platform default when omitted."),
516
+ voiceId: z.string().regex(VOICE_ID_PATTERN).optional().describe("ElevenLabs voice ID. Platform default when omitted."),
354
517
  model: z.enum(["eleven_turbo_v2_5", "eleven_multilingual_v2"]).optional().describe("TTS model. eleven_turbo_v2_5 (lower latency) when omitted.")
355
518
  }
356
519
  }, async (args) => {
357
520
  try {
358
- const { writeFile } = await import("node:fs/promises");
359
521
  const format = args.format ?? "wav";
522
+ const text = args.text.trim();
523
+ if (text.length < 1 || text.length > 5e3) return err("voice_tts text must contain 1–5000 characters.");
524
+ if (args.voiceId !== void 0 && !VOICE_ID_PATTERN.test(args.voiceId)) return err("voice_tts voiceId contains unsupported characters.");
525
+ const outputPath = args.outputPath ?? `alfe-tts-${String(Date.now())}.${format}`;
526
+ await resolveWorkspaceOutputPath(outputPath);
360
527
  const result = await voiceClient.tts({
361
- text: args.text,
528
+ text,
362
529
  voiceId: args.voiceId,
363
530
  model: args.model
364
531
  });
532
+ if (result.audio.length === 0 || result.audio.length > MAX_TTS_RESPONSE_BYTES) return err(`voice_tts returned an invalid audio payload size (${String(result.audio.length)} bytes).`);
533
+ validatePcmFraming({
534
+ sampleRate: result.sampleRate,
535
+ channels: result.channels,
536
+ bitDepth: result.bitDepth
537
+ }, result.audio.length);
365
538
  const bytes = format === "wav" ? pcmToWav(result.audio, {
366
539
  sampleRate: result.sampleRate,
367
540
  channels: result.channels,
368
541
  bitDepth: result.bitDepth
369
542
  }) : result.audio;
370
- const outputPath = args.outputPath ?? `alfe-tts-${String(Date.now())}.${format}`;
371
- const absolutePath = resolveWorkspacePath(outputPath);
372
- await writeFile(absolutePath, bytes);
373
543
  return ok({
374
544
  path: outputPath,
375
- absolutePath,
545
+ absolutePath: await writeWorkspaceFileExclusive(outputPath, bytes),
376
546
  format,
377
547
  sampleRate: result.sampleRate,
378
548
  channels: result.channels,
379
549
  bitDepth: result.bitDepth,
380
550
  bytes: bytes.length,
381
- characters: args.text.length
551
+ characters: text.length
382
552
  });
383
553
  } catch (error) {
384
554
  return err(error instanceof Error ? error.message : "voice_tts failed");
@@ -387,13 +557,12 @@ const registerVoiceTools = (server, ctx) => {
387
557
  server.registerTool("voice_stt", {
388
558
  description: "Transcribe an audio file to text using the Alfe voice service (Deepgram). Accepts a WAV file or headerless mono 16-bit PCM. Billed per audio-second to your tenant credit pool.",
389
559
  inputSchema: {
390
- path: z.string().describe("Path to the audio file (absolute, or relative to the working directory). WAV or raw linear16 mono PCM."),
391
- sampleRate: z.coerce.number().optional().describe("Sample rate in Hz (8000–48000). Only used for headerless PCM input; ignored for WAV (its header wins). Defaults to 24000.")
560
+ path: z.string().min(1).max(1024).describe("Workspace-relative path to a WAV or raw linear16 mono PCM file (maximum 10 MiB)."),
561
+ sampleRate: z.coerce.number().int().min(8e3).max(48e3).optional().describe("Sample rate in Hz (8000–48000). Only used for headerless PCM input; ignored for WAV (its header wins). Defaults to 24000.")
392
562
  }
393
563
  }, async (args) => {
394
564
  try {
395
- const { readFile } = await import("node:fs/promises");
396
- const raw = await readFile(resolveWorkspacePath(args.path));
565
+ const { data: raw } = await readWorkspaceFile(args.path, MAX_AUDIO_FILE_BYTES);
397
566
  const wav = parseWav(raw);
398
567
  let audio;
399
568
  let sampleRate;
@@ -405,6 +574,8 @@ const registerVoiceTools = (server, ctx) => {
405
574
  audio = raw;
406
575
  sampleRate = args.sampleRate ?? 24e3;
407
576
  }
577
+ if (!Number.isInteger(sampleRate) || sampleRate < 8e3 || sampleRate > 48e3) return err("voice_stt sampleRate must be an integer between 8000 and 48000 Hz.");
578
+ if (audio.length === 0 || audio.length % 2 !== 0) return err("voice_stt needs non-empty 16-bit PCM with an even byte length.");
408
579
  return ok(await voiceClient.stt({
409
580
  audio,
410
581
  sampleRate
@@ -430,8 +601,8 @@ const registerMessagingTools = (server, ctx) => {
430
601
  server.registerTool("send_text_message", {
431
602
  description: "Send an SMS text message to a phone number from your assigned phone number. The recipient is any E.164 number (e.g. +14155550123). You send from the single number assigned to you — you cannot choose the sender. Billed per message segment to your tenant credit pool. Fails if you have no active phone number.",
432
603
  inputSchema: {
433
- to: z.string().min(1).describe("Recipient phone number in E.164 format (e.g. +14155550123)."),
434
- body: z.string().min(1).describe("The text message body to send.")
604
+ to: z.string().regex(/^\+[1-9]\d{6,14}$/u).describe("Recipient phone number in E.164 format (e.g. +14155550123)."),
605
+ body: z.string().min(1).max(4400).describe("The text message body to send.")
435
606
  }
436
607
  }, async (args) => {
437
608
  try {