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