@alfe.ai/mcp-tools 0.2.5 → 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 +14 -1
- package/dist/index.cjs +242 -71
- package/dist/index.d.cts +1 -1
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +243 -72
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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
|
|
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
|
-
"
|
|
26
|
+
"atlassian",
|
|
27
|
+
"ctrader",
|
|
28
|
+
"discord",
|
|
29
|
+
"github",
|
|
24
30
|
"google",
|
|
25
|
-
"notion",
|
|
26
31
|
"microsoft",
|
|
27
|
-
"atlassian",
|
|
28
32
|
"myob",
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"
|
|
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
|
|
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.
|
|
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.
|
|
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 (
|
|
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
|
-
|
|
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
|
|
292
|
-
* buffer is not
|
|
293
|
-
*
|
|
294
|
-
*
|
|
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 <
|
|
298
|
-
if (buf.
|
|
299
|
-
if (buf.toString("ascii", 8, 12) !== "WAVE")
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
315
|
-
pcm = buf.subarray(bodyStart,
|
|
467
|
+
if (sawData) throw new Error("Malformed WAV: duplicate data chunk.");
|
|
468
|
+
pcm = buf.subarray(bodyStart, bodyEnd);
|
|
469
|
+
sawData = true;
|
|
316
470
|
}
|
|
317
|
-
offset =
|
|
471
|
+
offset = nextOffset;
|
|
318
472
|
}
|
|
319
|
-
if (!
|
|
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("
|
|
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
|
|
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:
|
|
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("
|
|
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 {
|
|
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().
|
|
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
|
|
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
|
package/dist/index.d.cts.map
CHANGED
|
@@ -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;;;;
|
|
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
|
|
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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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;;;;
|
|
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"}
|