@mgcrea/mcp-apple-core 1.14.0 → 1.16.0
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.d.ts +143 -21
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +816 -132
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,9 +1,309 @@
|
|
|
1
|
+
import { connect } from "node:net";
|
|
1
2
|
import { accessSync, constants, readFileSync, statSync } from "node:fs";
|
|
2
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
import { execFile } from "node:child_process";
|
|
5
6
|
import { createHash } from "node:crypto";
|
|
6
7
|
import { DatabaseSync } from "node:sqlite";
|
|
8
|
+
//#region src/errors.ts
|
|
9
|
+
/** Base class so `toFailure` can carry structured detail through in one branch. */
|
|
10
|
+
var AppleAutomationError = class extends Error {
|
|
11
|
+
name = "AppleAutomationError";
|
|
12
|
+
details;
|
|
13
|
+
constructor(message, details) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.details = details;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
/** The host process may not send Apple Events to the app (osascript -1743). */
|
|
19
|
+
var TccDeniedError = class extends AppleAutomationError {
|
|
20
|
+
name = "TccDeniedError";
|
|
21
|
+
constructor(surface) {
|
|
22
|
+
super(`Not authorized to control ${surface.appName}. Grant it in System Settings > Privacy & Security > Automation > (the app running this server) > ${surface.appName}, then restart the server. If no entry appears, the first attempt was denied before the prompt could be answered — run \`tccutil reset AppleEvents\` and try again.`);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
/** The app is not running and the operation refuses to launch it. */
|
|
26
|
+
var AppNotRunningError = class extends AppleAutomationError {
|
|
27
|
+
name = "AppNotRunningError";
|
|
28
|
+
constructor(surface) {
|
|
29
|
+
super(`${surface.appName} is not running. Read tools do not launch it, because launching it steals focus and can start a sync. Open ${surface.appName} and retry.`);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
/** The app was busy and refused the Apple Event (-1712). Retried once before surfacing. */
|
|
33
|
+
var AppBusyError = class extends AppleAutomationError {
|
|
34
|
+
name = "AppBusyError";
|
|
35
|
+
constructor(surface) {
|
|
36
|
+
super(`${surface.appName} is busy (probably syncing) and did not answer in time. Retry in a few seconds.`);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
/** osascript exceeded its budget and was killed. */
|
|
40
|
+
var OsascriptTimeoutError = class extends AppleAutomationError {
|
|
41
|
+
name = "OsascriptTimeoutError";
|
|
42
|
+
constructor(timeoutMs, surface) {
|
|
43
|
+
super(`${surface.appName} did not answer within ${timeoutMs}ms. It may be mid-sync, or a permission prompt may be waiting on screen. Raise ${surface.envPrefix}_OSASCRIPT_TIMEOUT_MS if this is routine at your data size.`);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* A write was attempted while writes are disabled. Under the house pattern write
|
|
48
|
+
* tools are not registered at all when `allowWrites` is off, so this is a
|
|
49
|
+
* belt-and-braces guard for the library surface, not a path tools can reach.
|
|
50
|
+
*/
|
|
51
|
+
var WritesDisabledError = class extends AppleAutomationError {
|
|
52
|
+
name = "WritesDisabledError";
|
|
53
|
+
constructor(surface) {
|
|
54
|
+
super(`Writes are disabled. Set ${surface.envPrefix}_ALLOW_WRITES=1 to enable the mutating tools.`);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
/** A read-only index could not be opened, so the file lane is unavailable. */
|
|
58
|
+
var IndexUnavailableError = class extends AppleAutomationError {
|
|
59
|
+
name = "IndexUnavailableError";
|
|
60
|
+
};
|
|
61
|
+
/** The store's schema is not the one we know how to read. */
|
|
62
|
+
var SchemaDriftError = class extends AppleAutomationError {
|
|
63
|
+
name = "SchemaDriftError";
|
|
64
|
+
};
|
|
65
|
+
/** The server is not running on macOS, or osascript is missing. */
|
|
66
|
+
var PlatformError = class extends AppleAutomationError {
|
|
67
|
+
name = "PlatformError";
|
|
68
|
+
};
|
|
69
|
+
/** osascript exited 0 but did not produce the JSON envelope we require. */
|
|
70
|
+
var ProtocolError = class extends AppleAutomationError {
|
|
71
|
+
name = "ProtocolError";
|
|
72
|
+
};
|
|
73
|
+
/** A local precondition failed before anything was sent to the app. */
|
|
74
|
+
var PreconditionError = class extends AppleAutomationError {
|
|
75
|
+
name = "PreconditionError";
|
|
76
|
+
};
|
|
77
|
+
//#endregion
|
|
78
|
+
//#region src/ax.ts
|
|
79
|
+
/**
|
|
80
|
+
* Borrowing the app's Accessibility driver.
|
|
81
|
+
*
|
|
82
|
+
* ## Why this exists at all
|
|
83
|
+
*
|
|
84
|
+
* `AXUIElementCopyAttributeValue` reads an attribute in **0.202 ms**. Reaching
|
|
85
|
+
* the same attribute through `osascript` + JXA + System Events costs **47.4 ms**
|
|
86
|
+
* — a 234x difference that is entirely transport, measured both ways on one
|
|
87
|
+
* machine in `docs/desktop.md`. Every Accessibility number this repo recorded
|
|
88
|
+
* before that document was taken through System Events, which is why the lane
|
|
89
|
+
* was closed three times on figures that were pricing the wrong thing.
|
|
90
|
+
*
|
|
91
|
+
* The native call cannot be made from here. Node has no binding for it, and
|
|
92
|
+
* more importantly the grant does not belong to node: TCC attaches
|
|
93
|
+
* Accessibility to the **responsible GUI ancestor**, so it is Cupertino.app
|
|
94
|
+
* that holds it. Hence a channel rather than a port — the app already runs the
|
|
95
|
+
* driver, and `ServerHost` will lend it for one bundle id.
|
|
96
|
+
*
|
|
97
|
+
* ## The second grant this removes, which matters more than the speed
|
|
98
|
+
*
|
|
99
|
+
* Driving a UI through System Events needs Automation-to-System-Events **on top
|
|
100
|
+
* of** Accessibility. Two grants, given in two different System Settings panes,
|
|
101
|
+
* to reach one window. The native driver needs the first and not the second.
|
|
102
|
+
*
|
|
103
|
+
* ## Absence is the fallback, not an error
|
|
104
|
+
*
|
|
105
|
+
* `CUPERTINO_AX_SOCKET` and `CUPERTINO_AX_FOR` are set by `ServerLocator` and by
|
|
106
|
+
* nothing else. A package installed from npm and run by hand has neither, and
|
|
107
|
+
* that is the supported case: these are published artifacts that must work with
|
|
108
|
+
* no app on the machine. `open()` returns null there, and the caller keeps
|
|
109
|
+
* whatever lane it had. It never throws to say "no app".
|
|
110
|
+
*
|
|
111
|
+
* ## Lazy, deliberately
|
|
112
|
+
*
|
|
113
|
+
* Nothing connects at construction. `SurfaceCatalog` probes servers to learn
|
|
114
|
+
* what they register, and a probe that opened a socket would make every capability
|
|
115
|
+
* scan depend on the host being ready to answer one.
|
|
116
|
+
*/
|
|
117
|
+
/** The handshake `ServerHost` expects, and the reply it sends back. */
|
|
118
|
+
const PROTOCOL = "cupertino/1";
|
|
119
|
+
const LENT_SURFACE = "desktop";
|
|
120
|
+
const OK = "ok";
|
|
121
|
+
/**
|
|
122
|
+
* How long one call may take.
|
|
123
|
+
*
|
|
124
|
+
* A walk carries its own five-second budget and the AX messaging timeout is two
|
|
125
|
+
* seconds per element, so the ceiling that matters is the app's, not this one's.
|
|
126
|
+
* This is only here so a host that stopped answering fails rather than hanging a
|
|
127
|
+
* tool call forever.
|
|
128
|
+
*/
|
|
129
|
+
const CALL_TIMEOUT_MS = 3e4;
|
|
130
|
+
/**
|
|
131
|
+
* The app is there and said no, or stopped answering.
|
|
132
|
+
*
|
|
133
|
+
* Distinct from the null `openAxChannel` returns, which means there is no app
|
|
134
|
+
* to ask — see the note there on why those two must not collapse.
|
|
135
|
+
*/
|
|
136
|
+
var AxChannelError = class extends AppleAutomationError {
|
|
137
|
+
name = "AxChannelError";
|
|
138
|
+
constructor(surface, message) {
|
|
139
|
+
super(`${surface.appName}: ${message}`, { surface: surface.appName });
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
/** What the host told us, when it refused the handshake. */
|
|
143
|
+
const refusal = (line) => line.startsWith("err ") ? line.slice(4) : `unexpected handshake reply '${line}'`;
|
|
144
|
+
/**
|
|
145
|
+
* Open the channel, or return null when this server is not hosted by the app.
|
|
146
|
+
*
|
|
147
|
+
* Null and a throw are different answers and the difference is the whole
|
|
148
|
+
* contract: null means "there is no app here, use your other lane", a throw
|
|
149
|
+
* means "there is an app and it said no", which a caller must report rather
|
|
150
|
+
* than paper over.
|
|
151
|
+
*/
|
|
152
|
+
const openAxChannel = (surface, env = process.env) => {
|
|
153
|
+
const socketPath = env.CUPERTINO_AX_SOCKET?.trim();
|
|
154
|
+
const identity = env.CUPERTINO_AX_FOR?.trim();
|
|
155
|
+
if (!socketPath || !identity) return null;
|
|
156
|
+
let socket = null;
|
|
157
|
+
let pending = Promise.resolve();
|
|
158
|
+
let nextId = 1;
|
|
159
|
+
const connectOnce = () => new Promise((resolve, reject) => {
|
|
160
|
+
const s = connect(socketPath);
|
|
161
|
+
let buffer = "";
|
|
162
|
+
const onError = (error) => {
|
|
163
|
+
s.destroy();
|
|
164
|
+
reject(new AxChannelError(surface, `cannot reach Cupertino: ${error.message}`));
|
|
165
|
+
};
|
|
166
|
+
s.once("error", onError);
|
|
167
|
+
s.setEncoding("utf8");
|
|
168
|
+
const onData = (chunk) => {
|
|
169
|
+
buffer += chunk;
|
|
170
|
+
const newline = buffer.indexOf("\n");
|
|
171
|
+
if (newline < 0) return;
|
|
172
|
+
const line = buffer.slice(0, newline).trim();
|
|
173
|
+
s.off("data", onData);
|
|
174
|
+
s.off("error", onError);
|
|
175
|
+
if (line !== OK) {
|
|
176
|
+
s.destroy();
|
|
177
|
+
reject(new AxChannelError(surface, refusal(line)));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
resolve(s);
|
|
181
|
+
};
|
|
182
|
+
s.on("data", onData);
|
|
183
|
+
s.write(`${PROTOCOL} ${LENT_SURFACE} for=${identity}\n`);
|
|
184
|
+
});
|
|
185
|
+
const request = async (payload) => {
|
|
186
|
+
socket ??= await connectOnce();
|
|
187
|
+
const live = socket;
|
|
188
|
+
const id = nextId++;
|
|
189
|
+
const line = `${JSON.stringify({
|
|
190
|
+
jsonrpc: "2.0",
|
|
191
|
+
id,
|
|
192
|
+
method: "tools/call",
|
|
193
|
+
params: {
|
|
194
|
+
name: payload.tool,
|
|
195
|
+
arguments: payload.args
|
|
196
|
+
}
|
|
197
|
+
})}\n`;
|
|
198
|
+
return new Promise((resolve, reject) => {
|
|
199
|
+
let buffer = "";
|
|
200
|
+
const timer = setTimeout(() => {
|
|
201
|
+
cleanup();
|
|
202
|
+
reject(new AxChannelError(surface, `${payload.tool} did not answer in ${CALL_TIMEOUT_MS}ms`));
|
|
203
|
+
}, CALL_TIMEOUT_MS);
|
|
204
|
+
const cleanup = () => {
|
|
205
|
+
clearTimeout(timer);
|
|
206
|
+
live.off("data", onData);
|
|
207
|
+
live.off("error", onError);
|
|
208
|
+
live.off("close", onClose);
|
|
209
|
+
};
|
|
210
|
+
const onError = (error) => {
|
|
211
|
+
cleanup();
|
|
212
|
+
reject(new AxChannelError(surface, `${payload.tool} failed: ${error.message}`));
|
|
213
|
+
};
|
|
214
|
+
const onClose = () => {
|
|
215
|
+
cleanup();
|
|
216
|
+
socket = null;
|
|
217
|
+
reject(new AxChannelError(surface, `Cupertino closed the connection during ${payload.tool}`));
|
|
218
|
+
};
|
|
219
|
+
const onData = (chunk) => {
|
|
220
|
+
buffer += chunk;
|
|
221
|
+
const newline = buffer.indexOf("\n");
|
|
222
|
+
if (newline < 0) return;
|
|
223
|
+
const raw = buffer.slice(0, newline);
|
|
224
|
+
cleanup();
|
|
225
|
+
let message;
|
|
226
|
+
try {
|
|
227
|
+
message = JSON.parse(raw);
|
|
228
|
+
} catch {
|
|
229
|
+
reject(new AxChannelError(surface, `unparseable reply to ${payload.tool}`));
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (message.error) {
|
|
233
|
+
reject(new AxChannelError(surface, message.error.message ?? "unknown error"));
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const text = message.result?.content?.[0]?.text;
|
|
237
|
+
if (text === void 0) {
|
|
238
|
+
reject(new AxChannelError(surface, `empty reply to ${payload.tool}`));
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (message.result?.isError === true) {
|
|
242
|
+
reject(new AxChannelError(surface, `${payload.tool}: ${text}`));
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
resolve(JSON.parse(text));
|
|
247
|
+
} catch {
|
|
248
|
+
resolve(text);
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
live.on("data", onData);
|
|
252
|
+
live.once("error", onError);
|
|
253
|
+
live.once("close", onClose);
|
|
254
|
+
live.write(line);
|
|
255
|
+
});
|
|
256
|
+
};
|
|
257
|
+
return {
|
|
258
|
+
call(payload) {
|
|
259
|
+
const result = pending.then(() => request(payload));
|
|
260
|
+
pending = result.then(() => void 0, () => void 0);
|
|
261
|
+
return result;
|
|
262
|
+
},
|
|
263
|
+
close() {
|
|
264
|
+
socket?.destroy();
|
|
265
|
+
socket = null;
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
};
|
|
269
|
+
/**
|
|
270
|
+
* Start watching. `check()` answers for the span since this call.
|
|
271
|
+
*
|
|
272
|
+
* Returns null from `check()` when the question could not be asked at all,
|
|
273
|
+
* which is not the same as "undisturbed" and must not be reported as it.
|
|
274
|
+
*/
|
|
275
|
+
const watchInterference = (channel) => {
|
|
276
|
+
const started = Date.now();
|
|
277
|
+
return { async check() {
|
|
278
|
+
const elapsedSeconds = (Date.now() - started) / 1e3;
|
|
279
|
+
try {
|
|
280
|
+
const secondsSinceInput = (await channel.call({
|
|
281
|
+
tool: "apple_desktop_user_activity",
|
|
282
|
+
args: {}
|
|
283
|
+
})).secondsSinceInput;
|
|
284
|
+
if (typeof secondsSinceInput !== "number") return null;
|
|
285
|
+
return {
|
|
286
|
+
disturbed: secondsSinceInput < elapsedSeconds,
|
|
287
|
+
secondsSinceInput,
|
|
288
|
+
elapsedSeconds
|
|
289
|
+
};
|
|
290
|
+
} catch {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
} };
|
|
294
|
+
};
|
|
295
|
+
/**
|
|
296
|
+
* The sentence to append to a failure, or "" when nothing useful can be said.
|
|
297
|
+
*
|
|
298
|
+
* Deliberately says nothing when the machine was quiet: a failure that reads
|
|
299
|
+
* "nobody touched it" invites the reader to stop looking, and the point of this
|
|
300
|
+
* is to send them to the RIGHT place rather than to reassure them.
|
|
301
|
+
*/
|
|
302
|
+
const interferenceNote = (found) => {
|
|
303
|
+
if (!found?.disturbed) return "";
|
|
304
|
+
return ` Someone used this Mac ${found.secondsSinceInput.toFixed(1)}s ago, during the ${found.elapsedSeconds.toFixed(1)}s this took — a keystroke or click lands wherever the focus is, so that alone can explain this. Retry with the machine idle before looking further.`;
|
|
305
|
+
};
|
|
306
|
+
//#endregion
|
|
7
307
|
//#region src/build-info.ts
|
|
8
308
|
/**
|
|
9
309
|
* Read a package's own name and version at startup, so they are always accurate
|
|
@@ -22,6 +322,113 @@ const readPackageIdentity = (packageJsonUrl, fallback) => {
|
|
|
22
322
|
}
|
|
23
323
|
};
|
|
24
324
|
//#endregion
|
|
325
|
+
//#region src/listing.ts
|
|
326
|
+
/**
|
|
327
|
+
* Trimming the SDK's own boilerplate out of `tools/list`.
|
|
328
|
+
*
|
|
329
|
+
* ## What this drops
|
|
330
|
+
*
|
|
331
|
+
* The SDK builds each tool's `inputSchema` from its zod shape at listing time,
|
|
332
|
+
* and the generator stamps every one with
|
|
333
|
+
* `"$schema": "http://json-schema.org/draft-07/schema#"`. Measured across the
|
|
334
|
+
* eight servers with writes on, that one constant is 4,836 B of a 106,157 B
|
|
335
|
+
* listing — 4.6%, paid by every client on every connect, to name a JSON Schema
|
|
336
|
+
* draft the client already has to assume in order to read the rest of the
|
|
337
|
+
* document. Nothing in the protocol reads it and no client needs it, so it is
|
|
338
|
+
* the rare cut that is free rather than a trade.
|
|
339
|
+
*
|
|
340
|
+
* ## What this deliberately does NOT drop
|
|
341
|
+
*
|
|
342
|
+
* `"execution": {"taskSupport": "forbidden"}` is another 3,720 B (3.5%) of
|
|
343
|
+
* identical constant — `registerTool` hardcodes it on every tool — and it looks
|
|
344
|
+
* like the same kind of waste. It is not, and the difference is worth the
|
|
345
|
+
* paragraph so nobody "finishes the job" later.
|
|
346
|
+
*
|
|
347
|
+
* Server-side the two spellings are the same: the SDK's `tools/call` path
|
|
348
|
+
* branches only on `'required'` and `'optional'`, so an absent `execution` and
|
|
349
|
+
* an explicit `'forbidden'` both fall through to the normal handler. Client-side
|
|
350
|
+
* they are not. `taskSupport` is declared `.optional()` with no default, so
|
|
351
|
+
* absence means "unspecified" rather than "forbidden", and a task-capable client
|
|
352
|
+
* reading a listing with no `execution` is entitled to try task augmentation on
|
|
353
|
+
* a tool that was registered without a task handler. `'forbidden'` is the value
|
|
354
|
+
* that tells it not to. Dropping it would trade 930 tokens for a behavioural
|
|
355
|
+
* change on a path nothing here tests.
|
|
356
|
+
*
|
|
357
|
+
* ## Why it is done to the outgoing frame
|
|
358
|
+
*
|
|
359
|
+
* The alternative seams are worse. The schema is generated inside the SDK, so
|
|
360
|
+
* there is no option to pass; overriding the `tools/list` request handler means
|
|
361
|
+
* reaching into `Server._requestHandlers`, a private field, and re-implementing
|
|
362
|
+
* the listing it already builds. Wrapping `Transport.send` is public API, is
|
|
363
|
+
* indifferent to how the listing was produced, and costs nothing on the frames
|
|
364
|
+
* it does not match — every non-listing message is returned by identity below.
|
|
365
|
+
*/
|
|
366
|
+
/** The one key removed, spelled once. */
|
|
367
|
+
const GENERATED_SCHEMA_KEY = "$schema";
|
|
368
|
+
/**
|
|
369
|
+
* A schema object without its `$schema` stamp, or the value unchanged.
|
|
370
|
+
*
|
|
371
|
+
* Returns the ORIGINAL reference when there is nothing to do, which is what
|
|
372
|
+
* lets `trimToolListing` decide by identity whether it needs to rebuild
|
|
373
|
+
* anything at all.
|
|
374
|
+
*/
|
|
375
|
+
const withoutSchemaKey = (schema) => {
|
|
376
|
+
if (schema === null || typeof schema !== "object" || Array.isArray(schema)) return schema;
|
|
377
|
+
if (!(GENERATED_SCHEMA_KEY in schema)) return schema;
|
|
378
|
+
const rest = { ...schema };
|
|
379
|
+
delete rest[GENERATED_SCHEMA_KEY];
|
|
380
|
+
return rest;
|
|
381
|
+
};
|
|
382
|
+
/**
|
|
383
|
+
* Strip generated boilerplate from a `tools/list` reply, passing every other
|
|
384
|
+
* message through untouched.
|
|
385
|
+
*
|
|
386
|
+
* Copies rather than mutates. The SDK hands out the registered tool's own
|
|
387
|
+
* schema object, and deleting a key from it would edit the server's state from
|
|
388
|
+
* a function whose job is to shape one reply.
|
|
389
|
+
*/
|
|
390
|
+
const trimToolListing = (message) => {
|
|
391
|
+
if (!("result" in message)) return message;
|
|
392
|
+
const result = message.result;
|
|
393
|
+
const tools = result?.tools;
|
|
394
|
+
if (!Array.isArray(tools)) return message;
|
|
395
|
+
let changed = false;
|
|
396
|
+
const trimmed = tools.map((tool) => {
|
|
397
|
+
if (tool === null || typeof tool !== "object") return tool;
|
|
398
|
+
const entry = tool;
|
|
399
|
+
const inputSchema = withoutSchemaKey(entry["inputSchema"]);
|
|
400
|
+
const outputSchema = withoutSchemaKey(entry["outputSchema"]);
|
|
401
|
+
if (inputSchema === entry["inputSchema"] && outputSchema === entry["outputSchema"]) return tool;
|
|
402
|
+
changed = true;
|
|
403
|
+
return {
|
|
404
|
+
...entry,
|
|
405
|
+
...entry["inputSchema"] === void 0 ? {} : { inputSchema },
|
|
406
|
+
...entry["outputSchema"] === void 0 ? {} : { outputSchema }
|
|
407
|
+
};
|
|
408
|
+
});
|
|
409
|
+
if (!changed) return message;
|
|
410
|
+
return {
|
|
411
|
+
...message,
|
|
412
|
+
result: {
|
|
413
|
+
...result,
|
|
414
|
+
tools: trimmed
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
};
|
|
418
|
+
/**
|
|
419
|
+
* Wrap a transport so every listing it sends is trimmed on the way out.
|
|
420
|
+
*
|
|
421
|
+
* Mutates and returns the transport it was given rather than proxying it: the
|
|
422
|
+
* SDK's `connect` reaches for `onmessage`, `onclose` and `onerror` on the very
|
|
423
|
+
* object it was handed, and a Proxy or a subclass would have to keep those in
|
|
424
|
+
* sync for no gain.
|
|
425
|
+
*/
|
|
426
|
+
const withTrimmedListing = (transport) => {
|
|
427
|
+
const send = transport.send.bind(transport);
|
|
428
|
+
transport.send = (message, options) => send(trimToolListing(message), options);
|
|
429
|
+
return transport;
|
|
430
|
+
};
|
|
431
|
+
//#endregion
|
|
25
432
|
//#region src/cli.ts
|
|
26
433
|
/**
|
|
27
434
|
* Boot a server on stdio.
|
|
@@ -47,7 +454,7 @@ const runStdioServer = async (opts) => {
|
|
|
47
454
|
process.exit(1);
|
|
48
455
|
}
|
|
49
456
|
const { server, banner } = await opts.start(logger);
|
|
50
|
-
await server.connect(new StdioServerTransport());
|
|
457
|
+
await server.connect(withTrimmedListing(new StdioServerTransport()));
|
|
51
458
|
logger.warn(`${logPrefix} connected (${banner})`);
|
|
52
459
|
const shutdown = (signal) => {
|
|
53
460
|
logger.warn(`received ${signal}, shutting down`);
|
|
@@ -373,6 +780,26 @@ const BaseConfigSchema = z.object({
|
|
|
373
780
|
* nothing serves would be a dangling reference by configuration.
|
|
374
781
|
*/
|
|
375
782
|
exposePrompts: z.boolean().default(true),
|
|
783
|
+
/**
|
|
784
|
+
* Serve a searchable index and a dispatcher instead of the full tool list.
|
|
785
|
+
*
|
|
786
|
+
* OFF by default, and a COST knob like `exposePrompts` above rather than a
|
|
787
|
+
* safety gate — but unlike that one it is a knob that TRADES. See
|
|
788
|
+
* `facade.ts` for the mechanism; the trade is that a host's permission rule
|
|
789
|
+
* stops naming the individual tool and starts naming a direction: one rule
|
|
790
|
+
* for this surface's reads, one for its writes.
|
|
791
|
+
*
|
|
792
|
+
* What it buys, measured with writes on: ~26.5k tokens of tool definitions
|
|
793
|
+
* across the eight servers becomes a handful per surface. What it costs
|
|
794
|
+
* besides the permission granularity is a round trip — a model must search
|
|
795
|
+
* before it can call.
|
|
796
|
+
*
|
|
797
|
+
* Worth switching on only for a client that does not already defer tool
|
|
798
|
+
* schemas itself. Claude Code and Claude Desktop do, and gain nothing here
|
|
799
|
+
* while paying both costs, which is why the app declines to write the flag
|
|
800
|
+
* into their config files at all.
|
|
801
|
+
*/
|
|
802
|
+
lazyTools: z.boolean().default(false),
|
|
376
803
|
debug: z.boolean().default(false),
|
|
377
804
|
osascriptPath: z.string().default("/usr/bin/osascript"),
|
|
378
805
|
osascriptTimeoutMs: z.number().int().min(1e3).max(6e5).default(3e4),
|
|
@@ -394,74 +821,409 @@ const parseConfig = (schema, raw) => {
|
|
|
394
821
|
return parsed.data;
|
|
395
822
|
};
|
|
396
823
|
//#endregion
|
|
397
|
-
//#region src/
|
|
398
|
-
/**
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
824
|
+
//#region src/tools.ts
|
|
825
|
+
/**
|
|
826
|
+
* Compact, not pretty-printed.
|
|
827
|
+
*
|
|
828
|
+
* A model does not need the indentation, and it is not free: measured against
|
|
829
|
+
* rows matching these servers' own types, `null, 2` adds 25-41% depending on how
|
|
830
|
+
* many short keys a row carries - worst on the widest lists, which are exactly
|
|
831
|
+
* the responses already big enough to matter. Every tool in every surface
|
|
832
|
+
* returns through here, so this is the one place it is paid.
|
|
833
|
+
*/
|
|
834
|
+
const ok = (data) => ({ content: [{
|
|
835
|
+
type: "text",
|
|
836
|
+
text: JSON.stringify(data ?? { ok: true })
|
|
837
|
+
}] });
|
|
838
|
+
/**
|
|
839
|
+
* Return text as-is. `ok()` JSON-stringifies, which turns a message body into
|
|
840
|
+
* one escaped "Hi,\n\n…" line that no one can read.
|
|
841
|
+
*/
|
|
842
|
+
const okText = (text) => ({ content: [{
|
|
843
|
+
type: "text",
|
|
844
|
+
text
|
|
845
|
+
}] });
|
|
846
|
+
const fail = (message, extra) => ({
|
|
847
|
+
content: [{
|
|
848
|
+
type: "text",
|
|
849
|
+
text: JSON.stringify({
|
|
850
|
+
error: message,
|
|
851
|
+
...extra ? { details: extra } : {}
|
|
852
|
+
})
|
|
853
|
+
}],
|
|
854
|
+
isError: true
|
|
855
|
+
});
|
|
856
|
+
/** Render a thrown value as a tool error, preserving whatever detail it carried. */
|
|
857
|
+
const toFailure = (err) => {
|
|
858
|
+
if (err instanceof AppleAutomationError) return fail(err.message, {
|
|
859
|
+
kind: err.name,
|
|
860
|
+
...err.details
|
|
861
|
+
});
|
|
862
|
+
if (err instanceof Error) {
|
|
863
|
+
const details = err.details;
|
|
864
|
+
return fail(err.message, details);
|
|
405
865
|
}
|
|
866
|
+
return fail("Unknown error", err);
|
|
406
867
|
};
|
|
407
|
-
/**
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
868
|
+
/** Run a tool body, JSON-formatting the result and turning errors into a tool error. */
|
|
869
|
+
const wrap = async (fn) => {
|
|
870
|
+
try {
|
|
871
|
+
return ok(await fn());
|
|
872
|
+
} catch (err) {
|
|
873
|
+
return toFailure(err);
|
|
412
874
|
}
|
|
413
875
|
};
|
|
414
|
-
/**
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
876
|
+
/** Like `wrap`, but the body chooses its own result shape (e.g. a raw body). */
|
|
877
|
+
const wrapResult = async (fn) => {
|
|
878
|
+
try {
|
|
879
|
+
return await fn();
|
|
880
|
+
} catch (err) {
|
|
881
|
+
return toFailure(err);
|
|
419
882
|
}
|
|
420
883
|
};
|
|
421
|
-
/**
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
884
|
+
/** Drop undefined values so we never send `{mailbox: undefined}` down a lane. */
|
|
885
|
+
const compact = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== void 0));
|
|
886
|
+
const limitArg = z.number().int().min(1).max(200).optional().describe("Maximum number of results. Each tool states its own default; `maxResults` is the ceiling either way.");
|
|
887
|
+
/**
|
|
888
|
+
* Settle a caller's `limit` against the tool's default and the config ceiling.
|
|
889
|
+
*
|
|
890
|
+
* Written out by hand at twenty-odd call sites before this existed, in five
|
|
891
|
+
* different spellings - and three surfaces spelled it `limit ?? maxResults`,
|
|
892
|
+
* with no `Math.min` at all. That made their real default 200 while `limitArg`
|
|
893
|
+
* told every model it was 25, so a model that trusted the description and
|
|
894
|
+
* omitted the argument got eight times the rows it asked for.
|
|
895
|
+
*
|
|
896
|
+
* `fallback` is the tool's own documented default, not a global one: a mailbox
|
|
897
|
+
* listing and a day of events do not want the same number.
|
|
898
|
+
*/
|
|
899
|
+
const resolveLimit = (limit, maxResults, fallback = 25) => Math.min(limit ?? fallback, maxResults);
|
|
900
|
+
const confirmArg = z.literal(true).describe("Must be true. This action changes data and is not undoable from here.");
|
|
901
|
+
//#endregion
|
|
902
|
+
//#region src/facade.ts
|
|
903
|
+
/**
|
|
904
|
+
* A stand-in server that records what would have been registered.
|
|
905
|
+
*
|
|
906
|
+
* Typed as a whole `McpServer` and cast once, here, rather than narrowed to a
|
|
907
|
+
* `Pick<…, "registerTool">` that every surface's `registerTools` would then
|
|
908
|
+
* have to widen its parameter to accept — eight signatures and the ~95
|
|
909
|
+
* functions beneath them, changed to describe a fact that is already true.
|
|
910
|
+
*
|
|
911
|
+
* The cast is safe by inspection, and the inspection is the point: all 95
|
|
912
|
+
* `registerTool` call sites across the eight surfaces call this one method and
|
|
913
|
+
* nothing else, and not one uses its return value. If a registrar ever reaches
|
|
914
|
+
* for `registerPrompt` or `server.server`, it will fail here at runtime rather
|
|
915
|
+
* than quietly registering into a void — which is why this returns a bare
|
|
916
|
+
* object instead of a Proxy that would forward the difference to a real server
|
|
917
|
+
* and half-register a surface.
|
|
918
|
+
*/
|
|
919
|
+
const recorder = (into) => ({ registerTool: (name, config, handler) => {
|
|
920
|
+
into.push({
|
|
921
|
+
name,
|
|
922
|
+
config,
|
|
923
|
+
handler
|
|
924
|
+
});
|
|
925
|
+
} });
|
|
926
|
+
/** Terms shorter than this are dropped: they match everything and rank nothing. */
|
|
927
|
+
const SHORTEST_TERM = 3;
|
|
928
|
+
/** How much of a description is printed per row. The whole of it is searched. */
|
|
929
|
+
const SUMMARY_LIMIT = 160;
|
|
930
|
+
const SEARCH_LIMIT = 25;
|
|
931
|
+
/** Partial matches are low precision by construction, so fewer of them. */
|
|
932
|
+
const PARTIAL_LIMIT = 10;
|
|
933
|
+
const INDEX_LIMIT = 200;
|
|
934
|
+
/**
|
|
935
|
+
* Rank tiers, best first, SUMMED across the query's terms.
|
|
936
|
+
*
|
|
937
|
+
* Summing rather than taking the best single term is deliberate: a two-word
|
|
938
|
+
* query scored on its luckiest word ranks a tool that matched one term above a
|
|
939
|
+
* tool that matched both.
|
|
940
|
+
*/
|
|
941
|
+
const RANK = {
|
|
942
|
+
exactName: 0,
|
|
943
|
+
namePrefix: 1,
|
|
944
|
+
nameSubstring: 2,
|
|
945
|
+
summary: 3,
|
|
946
|
+
tail: 4,
|
|
947
|
+
missing: 5
|
|
427
948
|
};
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
949
|
+
const summarize = (description) => {
|
|
950
|
+
const flat = description.replace(/\s+/g, " ").trim();
|
|
951
|
+
return flat.length <= SUMMARY_LIMIT ? flat : `${flat.slice(0, 159).trimEnd()}…`;
|
|
952
|
+
};
|
|
953
|
+
const index = (decl) => {
|
|
954
|
+
const description = decl.config.description ?? "";
|
|
955
|
+
const summary = summarize(description);
|
|
956
|
+
return {
|
|
957
|
+
name: decl.name,
|
|
958
|
+
summary,
|
|
959
|
+
head: `${decl.name} ${summary}`.toLowerCase(),
|
|
960
|
+
whole: `${decl.name} ${description}`.toLowerCase()
|
|
961
|
+
};
|
|
434
962
|
};
|
|
435
963
|
/**
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
*
|
|
964
|
+
* Regular plural fold, guarded.
|
|
965
|
+
*
|
|
966
|
+
* The guards are the whole point: without them `status` searches for `statu`,
|
|
967
|
+
* `class` for `clas` and `focus` for `focu`, and a three-letter term like `ios`
|
|
968
|
+
* loses a third of itself.
|
|
439
969
|
*/
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
970
|
+
const singular = (term) => {
|
|
971
|
+
if (term.length <= SHORTEST_TERM) return term;
|
|
972
|
+
if (term.endsWith("ss") || term.endsWith("us")) return term;
|
|
973
|
+
return term.endsWith("s") ? term.slice(0, -1) : term;
|
|
974
|
+
};
|
|
975
|
+
const queryTerms = (query) => {
|
|
976
|
+
const seen = /* @__PURE__ */ new Set();
|
|
977
|
+
const out = [];
|
|
978
|
+
for (const raw of query.toLowerCase().split(/\s+/)) {
|
|
979
|
+
const term = raw.trim();
|
|
980
|
+
if (term.length < SHORTEST_TERM || seen.has(term)) continue;
|
|
981
|
+
seen.add(term);
|
|
982
|
+
out.push(term);
|
|
444
983
|
}
|
|
984
|
+
return out;
|
|
445
985
|
};
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
986
|
+
const rankTerm = (term, entry) => {
|
|
987
|
+
const name = entry.name.toLowerCase();
|
|
988
|
+
const folded = singular(term);
|
|
989
|
+
const hit = (haystack) => haystack.includes(term) || haystack.includes(folded);
|
|
990
|
+
if (name === term) return RANK.exactName;
|
|
991
|
+
if (name.startsWith(term)) return RANK.namePrefix;
|
|
992
|
+
if (hit(name)) return RANK.nameSubstring;
|
|
993
|
+
if (hit(entry.head)) return RANK.summary;
|
|
994
|
+
if (hit(entry.whole)) return RANK.tail;
|
|
995
|
+
return RANK.missing;
|
|
449
996
|
};
|
|
450
|
-
/**
|
|
451
|
-
|
|
452
|
-
|
|
997
|
+
/**
|
|
998
|
+
* Find tools for a query.
|
|
999
|
+
*
|
|
1000
|
+
* An empty query returns the server's OWN order rather than an alphabetised
|
|
1001
|
+
* one: the registrars group related tools together, and sorting throws that
|
|
1002
|
+
* grouping away for no gain.
|
|
1003
|
+
*
|
|
1004
|
+
* Nothing may cache this keyed on the query — a row's tier is a property of the
|
|
1005
|
+
* query AND of the whole catalog it was ranked against.
|
|
1006
|
+
*/
|
|
1007
|
+
const find = (entries, query) => {
|
|
1008
|
+
const terms = queryTerms(query);
|
|
1009
|
+
if (terms.length === 0) return {
|
|
1010
|
+
rows: entries.slice(0, INDEX_LIMIT),
|
|
1011
|
+
matched: entries.length,
|
|
1012
|
+
missed: [],
|
|
1013
|
+
partial: false
|
|
1014
|
+
};
|
|
1015
|
+
const scored = entries.map((entry) => {
|
|
1016
|
+
let total = 0;
|
|
1017
|
+
let matched = 0;
|
|
1018
|
+
const missed = [];
|
|
1019
|
+
for (const term of terms) {
|
|
1020
|
+
const rank = rankTerm(term, entry);
|
|
1021
|
+
total += rank;
|
|
1022
|
+
if (rank === RANK.missing) missed.push(term);
|
|
1023
|
+
else matched += 1;
|
|
1024
|
+
}
|
|
1025
|
+
return {
|
|
1026
|
+
entry,
|
|
1027
|
+
total,
|
|
1028
|
+
matched,
|
|
1029
|
+
missed
|
|
1030
|
+
};
|
|
1031
|
+
});
|
|
1032
|
+
const best = scored.reduce((acc, s) => Math.max(acc, s.matched), 0);
|
|
1033
|
+
if (best === 0) return {
|
|
1034
|
+
rows: [],
|
|
1035
|
+
matched: 0,
|
|
1036
|
+
missed: terms,
|
|
1037
|
+
partial: false
|
|
1038
|
+
};
|
|
1039
|
+
const group = scored.filter((s) => s.matched === best).toSorted((a, b) => a.total - b.total || a.entry.name.localeCompare(b.entry.name));
|
|
1040
|
+
const partial = best < terms.length;
|
|
1041
|
+
const missed = partial ? terms.filter((t) => group.every((g) => g.missed.includes(t))) : [];
|
|
1042
|
+
return {
|
|
1043
|
+
rows: group.slice(0, partial ? PARTIAL_LIMIT : SEARCH_LIMIT).map((g) => g.entry),
|
|
1044
|
+
matched: group.length,
|
|
1045
|
+
missed,
|
|
1046
|
+
partial
|
|
1047
|
+
};
|
|
453
1048
|
};
|
|
454
|
-
/**
|
|
455
|
-
|
|
456
|
-
|
|
1049
|
+
/**
|
|
1050
|
+
* Suggestions for a name that is not in the catalog.
|
|
1051
|
+
*
|
|
1052
|
+
* Substring search cannot find a string that appears nowhere, so a typo needs
|
|
1053
|
+
* its own answer: shared underscore-separated words first, then the longest
|
|
1054
|
+
* common prefix.
|
|
1055
|
+
*
|
|
1056
|
+
* Both comparisons run on the name with `apple_<surface>_` REMOVED. Every tool
|
|
1057
|
+
* on a surface shares that prefix, so comparing whole names makes every tool
|
|
1058
|
+
* share two words with every other and "did you mean" answers with the first
|
|
1059
|
+
* few tools in the catalog — worse than saying nothing, because it reads like a
|
|
1060
|
+
* real suggestion. Measured before the fix: `apple_mail_send_messge` suggested
|
|
1061
|
+
* `apple_mail_list_accounts`.
|
|
1062
|
+
*/
|
|
1063
|
+
const nearest = (names, wanted, prefix) => {
|
|
1064
|
+
const strip = (n) => n.toLowerCase().startsWith(`${prefix}_`) ? n.slice(prefix.length + 1) : n;
|
|
1065
|
+
const target = strip(wanted);
|
|
1066
|
+
const words = new Set(target.toLowerCase().split("_").filter((w) => w.length >= SHORTEST_TERM));
|
|
1067
|
+
const shared = names.filter((n) => strip(n).toLowerCase().split("_").some((w) => words.has(w)));
|
|
1068
|
+
if (shared.length > 0) return shared.slice(0, 5);
|
|
1069
|
+
const common = (n) => {
|
|
1070
|
+
const candidate = strip(n).toLowerCase();
|
|
1071
|
+
const lower = target.toLowerCase();
|
|
1072
|
+
let i = 0;
|
|
1073
|
+
while (i < candidate.length && i < lower.length && candidate[i] === lower[i]) i += 1;
|
|
1074
|
+
return i;
|
|
1075
|
+
};
|
|
1076
|
+
const ranked = names.toSorted((a, b) => common(b) - common(a) || a.localeCompare(b));
|
|
1077
|
+
const bestName = ranked[0];
|
|
1078
|
+
if (bestName === void 0 || common(bestName) < SHORTEST_TERM) return [];
|
|
1079
|
+
return ranked.filter((n) => common(n) >= SHORTEST_TERM).slice(0, 3);
|
|
457
1080
|
};
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
1081
|
+
const renderSearch = (result, total, names) => {
|
|
1082
|
+
if (result.rows.length === 0) return `No tool matches. ${total} tools are available — call ${names.search} with no query to list them all.`;
|
|
1083
|
+
const rows = result.rows.map((r) => `${r.name} — ${r.summary}`).join("\n");
|
|
1084
|
+
let notice = "";
|
|
1085
|
+
if (result.partial) notice = `No tool matched every word${result.missed.length > 0 ? ` (no match for ${result.missed.join(", ")})` : ""}. Closest:\n`;
|
|
1086
|
+
const footer = `${result.rows.length} of ${total} tools. Read a schema with ${names.describe}, then run it with ${names.call}.`;
|
|
1087
|
+
return `${notice}${rows}\n\n${footer}`;
|
|
461
1088
|
};
|
|
462
|
-
/**
|
|
463
|
-
|
|
464
|
-
|
|
1089
|
+
/**
|
|
1090
|
+
* A tool's declaration as a client would have received it.
|
|
1091
|
+
*
|
|
1092
|
+
* Built from the recorded zod shape rather than re-derived by hand, so
|
|
1093
|
+
* `describe` and a non-lazy listing cannot drift. `$schema` is dropped for the
|
|
1094
|
+
* same reason `listing.ts` drops it from `tools/list`.
|
|
1095
|
+
*/
|
|
1096
|
+
const describe = (decl) => {
|
|
1097
|
+
const shape = decl.config.inputSchema;
|
|
1098
|
+
const schema = shape ? z.toJSONSchema(z.object(shape)) : void 0;
|
|
1099
|
+
if (schema) delete schema["$schema"];
|
|
1100
|
+
return {
|
|
1101
|
+
name: decl.name,
|
|
1102
|
+
...decl.config.description === void 0 ? {} : { description: decl.config.description },
|
|
1103
|
+
...schema === void 0 ? {} : { inputSchema: schema },
|
|
1104
|
+
...decl.config.annotations === void 0 ? {} : { annotations: decl.config.annotations }
|
|
1105
|
+
};
|
|
1106
|
+
};
|
|
1107
|
+
/**
|
|
1108
|
+
* Run a recorded tool, reproducing the validation the SDK would have done.
|
|
1109
|
+
*
|
|
1110
|
+
* Under a facade the SDK never sees the real call, so it never parses the real
|
|
1111
|
+
* arguments. Skipping this would hand every handler unvalidated input — the one
|
|
1112
|
+
* way a facade can be actively less safe than the listing it replaced.
|
|
1113
|
+
*/
|
|
1114
|
+
const invoke = async (decl, args, extra) => {
|
|
1115
|
+
const shape = decl.config.inputSchema;
|
|
1116
|
+
if (!shape) return decl.handler(extra);
|
|
1117
|
+
const parsed = await z.object(shape).safeParseAsync(args ?? {});
|
|
1118
|
+
if (!parsed.success) {
|
|
1119
|
+
const why = parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
|
|
1120
|
+
return fail(`Invalid arguments for ${decl.name}: ${why}`);
|
|
1121
|
+
}
|
|
1122
|
+
return decl.handler(parsed.data, extra);
|
|
1123
|
+
};
|
|
1124
|
+
/**
|
|
1125
|
+
* Register a surface's tools, either directly or behind the facade.
|
|
1126
|
+
*
|
|
1127
|
+
* The callback takes `allowWrites` rather than closing over it so the facade
|
|
1128
|
+
* can run it a second time with the gate forced shut and learn which tools are
|
|
1129
|
+
* writes. Everything else it needs — the client, the config — it closes over as
|
|
1130
|
+
* before.
|
|
1131
|
+
*/
|
|
1132
|
+
const withLazyTools = (server, opts, register) => {
|
|
1133
|
+
if (!opts.lazy) {
|
|
1134
|
+
register(server, opts.allowWrites);
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
const all = [];
|
|
1138
|
+
register(recorder(all), opts.allowWrites);
|
|
1139
|
+
let writeNames = /* @__PURE__ */ new Set();
|
|
1140
|
+
if (opts.allowWrites) {
|
|
1141
|
+
const readsOnly = [];
|
|
1142
|
+
register(recorder(readsOnly), false);
|
|
1143
|
+
const readNames = new Set(readsOnly.map((d) => d.name));
|
|
1144
|
+
writeNames = new Set(all.filter((d) => !readNames.has(d.name)).map((d) => d.name));
|
|
1145
|
+
}
|
|
1146
|
+
const prefix = `apple_${opts.surface}`;
|
|
1147
|
+
const eagerName = `${prefix}_diagnostics`;
|
|
1148
|
+
const eager = all.filter((d) => d.name === eagerName);
|
|
1149
|
+
for (const decl of eager) server.registerTool(decl.name, decl.config, decl.handler);
|
|
1150
|
+
const lazy = all.filter((d) => d.name !== eagerName);
|
|
1151
|
+
const reads = lazy.filter((d) => !writeNames.has(d.name));
|
|
1152
|
+
const writes = lazy.filter((d) => writeNames.has(d.name));
|
|
1153
|
+
const byName = new Map(lazy.map((d) => [d.name, d]));
|
|
1154
|
+
const readIndex = reads.map(index);
|
|
1155
|
+
const writeIndex = writes.map(index);
|
|
1156
|
+
const allIndex = [...readIndex, ...writeIndex];
|
|
1157
|
+
const searchName = `${prefix}_search_tools`;
|
|
1158
|
+
const describeName = `${prefix}_describe_tool`;
|
|
1159
|
+
const callName = `${prefix}_call_tool`;
|
|
1160
|
+
const callWriteName = `${prefix}_call_write_tool`;
|
|
1161
|
+
server.registerTool(searchName, {
|
|
1162
|
+
description: `Find ${opts.displayName} tools by what you want to do. This server loads its ${allIndex.length} tools on demand: they are not listed up front, and this is how you reach them. Returns matching tool names with a one-line summary each. Call with no query to list everything. Read a schema with ${describeName}, then run it with ${callName}` + (writes.length > 0 ? ` or ${callWriteName}` : "") + `.`,
|
|
1163
|
+
inputSchema: { query: z.string().optional().describe("What you want to do, e.g. 'search messages' or 'unread'. Omit to list all.") },
|
|
1164
|
+
annotations: {
|
|
1165
|
+
readOnlyHint: true,
|
|
1166
|
+
idempotentHint: true
|
|
1167
|
+
}
|
|
1168
|
+
}, ({ query }) => okText(renderSearch(find(allIndex, query ?? ""), allIndex.length, {
|
|
1169
|
+
search: searchName,
|
|
1170
|
+
describe: describeName,
|
|
1171
|
+
call: callName
|
|
1172
|
+
})));
|
|
1173
|
+
server.registerTool(describeName, {
|
|
1174
|
+
description: `Get the full description and input schema of one ${opts.displayName} tool, as it would have appeared in a normal tool listing. Find names with ${searchName} first.`,
|
|
1175
|
+
inputSchema: { name: z.string().describe(`Exact tool name, e.g. ${lazy[0]?.name ?? callName}.`) },
|
|
1176
|
+
annotations: {
|
|
1177
|
+
readOnlyHint: true,
|
|
1178
|
+
idempotentHint: true
|
|
1179
|
+
}
|
|
1180
|
+
}, ({ name }) => {
|
|
1181
|
+
const decl = byName.get(name);
|
|
1182
|
+
if (!decl) {
|
|
1183
|
+
const suggestions = nearest([...byName.keys()], name, prefix);
|
|
1184
|
+
return fail(`No tool named ${name}.` + (suggestions.length > 0 ? ` Did you mean: ${suggestions.join(", ")}?` : ""));
|
|
1185
|
+
}
|
|
1186
|
+
const runner = writeNames.has(name) ? callWriteName : callName;
|
|
1187
|
+
return okText(`${JSON.stringify(describe(decl))}\n\nCall it with ${runner}: {"name": ${JSON.stringify(name)}, "arguments": {…}}.`);
|
|
1188
|
+
});
|
|
1189
|
+
server.registerTool(callName, {
|
|
1190
|
+
description: `Run one of this server's read-only ${opts.displayName} tools. Find a name with ${searchName} and its arguments with ${describeName}. Reads only: it cannot reach ` + (writes.length > 0 ? `anything that changes ${opts.displayName} — those go through ${callWriteName}.` : `anything that changes ${opts.displayName}, and this server has writes turned off.`),
|
|
1191
|
+
inputSchema: {
|
|
1192
|
+
name: z.string().describe("Exact tool name to run."),
|
|
1193
|
+
arguments: z.record(z.string(), z.unknown()).optional().describe("That tool's arguments.")
|
|
1194
|
+
},
|
|
1195
|
+
annotations: { readOnlyHint: true }
|
|
1196
|
+
}, async ({ name, arguments: args }, extra) => {
|
|
1197
|
+
const decl = byName.get(name);
|
|
1198
|
+
if (!decl) {
|
|
1199
|
+
const suggestions = nearest([...byName.keys()], name, prefix);
|
|
1200
|
+
return fail(`No tool named ${name}.` + (suggestions.length > 0 ? ` Did you mean: ${suggestions.join(", ")}?` : ""));
|
|
1201
|
+
}
|
|
1202
|
+
if (writeNames.has(name)) return fail(`${name} changes ${opts.displayName}, so it cannot be run through ${callName}. Use ${callWriteName}.`);
|
|
1203
|
+
return await invoke(decl, args, extra);
|
|
1204
|
+
});
|
|
1205
|
+
if (writes.length > 0) {
|
|
1206
|
+
const writeList = writes.map((d) => d.name.slice(prefix.length + 1)).join(", ");
|
|
1207
|
+
server.registerTool(callWriteName, {
|
|
1208
|
+
description: `Run one of this server's ${writes.length} ${opts.displayName} tools that CHANGE data — ${writeList}. Find arguments with ${describeName}. Read-only tools go through ${callName} instead.`,
|
|
1209
|
+
inputSchema: {
|
|
1210
|
+
name: z.string().describe("Exact tool name to run."),
|
|
1211
|
+
arguments: z.record(z.string(), z.unknown()).optional().describe("That tool's arguments.")
|
|
1212
|
+
},
|
|
1213
|
+
annotations: {
|
|
1214
|
+
readOnlyHint: false,
|
|
1215
|
+
destructiveHint: true
|
|
1216
|
+
}
|
|
1217
|
+
}, async ({ name, arguments: args }, extra) => {
|
|
1218
|
+
const decl = byName.get(name);
|
|
1219
|
+
if (!decl) {
|
|
1220
|
+
const suggestions = nearest([...writeNames], name, prefix);
|
|
1221
|
+
return fail(`No tool named ${name}.` + (suggestions.length > 0 ? ` Did you mean: ${suggestions.join(", ")}?` : ""));
|
|
1222
|
+
}
|
|
1223
|
+
if (!writeNames.has(name)) return fail(`${name} is read-only. Use ${callName}.`);
|
|
1224
|
+
return await invoke(decl, args, extra);
|
|
1225
|
+
});
|
|
1226
|
+
}
|
|
465
1227
|
};
|
|
466
1228
|
//#endregion
|
|
467
1229
|
//#region src/fs.ts
|
|
@@ -966,84 +1728,6 @@ const openReadOnly = (path, mode, opts = {}) => {
|
|
|
966
1728
|
throw new IndexUnavailableError(`Could not open ${opts.label ?? path} at ${path}: ${message}.${opts.hint ? ` ${opts.hint}` : ""}`);
|
|
967
1729
|
};
|
|
968
1730
|
//#endregion
|
|
969
|
-
|
|
970
|
-
/**
|
|
971
|
-
* Compact, not pretty-printed.
|
|
972
|
-
*
|
|
973
|
-
* A model does not need the indentation, and it is not free: measured against
|
|
974
|
-
* rows matching these servers' own types, `null, 2` adds 25-41% depending on how
|
|
975
|
-
* many short keys a row carries - worst on the widest lists, which are exactly
|
|
976
|
-
* the responses already big enough to matter. Every tool in every surface
|
|
977
|
-
* returns through here, so this is the one place it is paid.
|
|
978
|
-
*/
|
|
979
|
-
const ok = (data) => ({ content: [{
|
|
980
|
-
type: "text",
|
|
981
|
-
text: JSON.stringify(data ?? { ok: true })
|
|
982
|
-
}] });
|
|
983
|
-
/**
|
|
984
|
-
* Return text as-is. `ok()` JSON-stringifies, which turns a message body into
|
|
985
|
-
* one escaped "Hi,\n\n…" line that no one can read.
|
|
986
|
-
*/
|
|
987
|
-
const okText = (text) => ({ content: [{
|
|
988
|
-
type: "text",
|
|
989
|
-
text
|
|
990
|
-
}] });
|
|
991
|
-
const fail = (message, extra) => ({
|
|
992
|
-
content: [{
|
|
993
|
-
type: "text",
|
|
994
|
-
text: JSON.stringify({
|
|
995
|
-
error: message,
|
|
996
|
-
...extra ? { details: extra } : {}
|
|
997
|
-
})
|
|
998
|
-
}],
|
|
999
|
-
isError: true
|
|
1000
|
-
});
|
|
1001
|
-
/** Render a thrown value as a tool error, preserving whatever detail it carried. */
|
|
1002
|
-
const toFailure = (err) => {
|
|
1003
|
-
if (err instanceof AppleAutomationError) return fail(err.message, {
|
|
1004
|
-
kind: err.name,
|
|
1005
|
-
...err.details
|
|
1006
|
-
});
|
|
1007
|
-
if (err instanceof Error) {
|
|
1008
|
-
const details = err.details;
|
|
1009
|
-
return fail(err.message, details);
|
|
1010
|
-
}
|
|
1011
|
-
return fail("Unknown error", err);
|
|
1012
|
-
};
|
|
1013
|
-
/** Run a tool body, JSON-formatting the result and turning errors into a tool error. */
|
|
1014
|
-
const wrap = async (fn) => {
|
|
1015
|
-
try {
|
|
1016
|
-
return ok(await fn());
|
|
1017
|
-
} catch (err) {
|
|
1018
|
-
return toFailure(err);
|
|
1019
|
-
}
|
|
1020
|
-
};
|
|
1021
|
-
/** Like `wrap`, but the body chooses its own result shape (e.g. a raw body). */
|
|
1022
|
-
const wrapResult = async (fn) => {
|
|
1023
|
-
try {
|
|
1024
|
-
return await fn();
|
|
1025
|
-
} catch (err) {
|
|
1026
|
-
return toFailure(err);
|
|
1027
|
-
}
|
|
1028
|
-
};
|
|
1029
|
-
/** Drop undefined values so we never send `{mailbox: undefined}` down a lane. */
|
|
1030
|
-
const compact = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== void 0));
|
|
1031
|
-
const limitArg = z.number().int().min(1).max(200).optional().describe("Maximum number of results. Each tool states its own default; `maxResults` is the ceiling either way.");
|
|
1032
|
-
/**
|
|
1033
|
-
* Settle a caller's `limit` against the tool's default and the config ceiling.
|
|
1034
|
-
*
|
|
1035
|
-
* Written out by hand at twenty-odd call sites before this existed, in five
|
|
1036
|
-
* different spellings - and three surfaces spelled it `limit ?? maxResults`,
|
|
1037
|
-
* with no `Math.min` at all. That made their real default 200 while `limitArg`
|
|
1038
|
-
* told every model it was 25, so a model that trusted the description and
|
|
1039
|
-
* omitted the argument got eight times the rows it asked for.
|
|
1040
|
-
*
|
|
1041
|
-
* `fallback` is the tool's own documented default, not a global one: a mailbox
|
|
1042
|
-
* listing and a day of events do not want the same number.
|
|
1043
|
-
*/
|
|
1044
|
-
const resolveLimit = (limit, maxResults, fallback = 25) => Math.min(limit ?? fallback, maxResults);
|
|
1045
|
-
const confirmArg = z.literal(true).describe("Must be true. This action changes data and is not undoable from here.");
|
|
1046
|
-
//#endregion
|
|
1047
|
-
export { AppBusyError, AppNotRunningError, AppleAutomationError, BaseConfigSchema, CORE_DATA_EPOCH_OFFSET, IndexUnavailableError, OsascriptTimeoutError, PlatformError, PreconditionError, ProtocolError, RESOURCE_SCHEME, SchemaDriftError, TccDeniedError, WritesDisabledError, assertStaticScript, columnsOf, compact, confirmArg, createOsascriptRunner, describeAggregation, describeStore, detectEpoch, escapeLike, extractCode, fail, fingerprintSchema, groupByArg, inspectFile, limitArg, mapOsaError, ok, okText, openReadOnly, parseBool, parseConfig, parseIntOpt, parseList, project, promptArg, readPackageIdentity, registerSurfaceResources, registerWorkflowPrompt, requiredPromptArg, resolveLimit, runStdioServer, selectArg, surfaceUri, tableMap, toFailure, toFileUri, trimmed, withBusyRetry, wrap, wrapResult };
|
|
1731
|
+
export { AppBusyError, AppNotRunningError, AppleAutomationError, AxChannelError, BaseConfigSchema, CORE_DATA_EPOCH_OFFSET, IndexUnavailableError, OsascriptTimeoutError, PlatformError, PreconditionError, ProtocolError, RESOURCE_SCHEME, SchemaDriftError, TccDeniedError, WritesDisabledError, assertStaticScript, columnsOf, compact, confirmArg, createOsascriptRunner, describeAggregation, describeStore, detectEpoch, escapeLike, extractCode, fail, fingerprintSchema, groupByArg, inspectFile, interferenceNote, limitArg, mapOsaError, ok, okText, openAxChannel, openReadOnly, parseBool, parseConfig, parseIntOpt, parseList, project, promptArg, readPackageIdentity, registerSurfaceResources, registerWorkflowPrompt, requiredPromptArg, resolveLimit, runStdioServer, selectArg, surfaceUri, tableMap, toFailure, toFileUri, trimToolListing, trimmed, watchInterference, withBusyRetry, withLazyTools, withTrimmedListing, wrap, wrapResult };
|
|
1048
1732
|
|
|
1049
1733
|
//# sourceMappingURL=index.js.map
|