apple-tools-mcp 2.1.5 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -2
- package/bin/apple-tools-http-proxy.js +15 -0
- package/bin/apple-tools-http.js +16 -0
- package/examples/com.apple-tools-http.plist +35 -0
- package/examples/config.json.example +5 -0
- package/examples/mcp-client.md +98 -0
- package/index.js +535 -469
- package/indexer.js +2 -2
- package/lib/calendarWrite.js +83 -27
- package/lib/config.js +29 -1
- package/lib/httpAuth.js +106 -0
- package/lib/httpStdioProxy.js +45 -0
- package/lib/httpTransport.js +71 -0
- package/lib/mailWrite.js +99 -21
- package/lib/processMode.js +42 -1
- package/package.json +16 -8
package/indexer.js
CHANGED
|
@@ -85,10 +85,10 @@ let db = null;
|
|
|
85
85
|
async function getEmbedder() {
|
|
86
86
|
if (!embeddingPipeline) {
|
|
87
87
|
console.error("Loading embedding model (first time may take a minute)...");
|
|
88
|
-
// Lazy import: @
|
|
88
|
+
// Lazy import: @huggingface/transformers loads sharp at import time. A static
|
|
89
89
|
// import crashes Linux (and any host without the platform sharp native)
|
|
90
90
|
// before search.js formatters / pronoun helpers can load.
|
|
91
|
-
const { pipeline } = await import("@
|
|
91
|
+
const { pipeline } = await import("@huggingface/transformers");
|
|
92
92
|
embeddingPipeline = await pipeline("feature-extraction", "Xenova/all-MiniLM-L6-v2");
|
|
93
93
|
console.error("Embedding model loaded.");
|
|
94
94
|
}
|
package/lib/calendarWrite.js
CHANGED
|
@@ -44,6 +44,17 @@ export const RECURRENCE_FREQUENCIES = ["daily", "weekly", "monthly", "yearly"];
|
|
|
44
44
|
export const RECURRENCE_DAYS = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"];
|
|
45
45
|
export const RSVP_RESPONSES = ["accept", "decline", "tentative"];
|
|
46
46
|
|
|
47
|
+
// Regression (2026-09-23): calendar_edit's classic-AppleScript fallback and
|
|
48
|
+
// calendar_rsvp both ran atmFindEvent (see findEventHandler below) under a
|
|
49
|
+
// flat 60000ms runAppleScript timeout -- exactly matching the MCP client
|
|
50
|
+
// SDK's own default 60s CallTool timeout, so the client could give up with
|
|
51
|
+
// a raw "Request timed out" before the server ever got to answer, even
|
|
52
|
+
// though the underlying operation kept running and generally succeeded
|
|
53
|
+
// (same class of bug already fixed for mail_send/reply/forward). Trimmed
|
|
54
|
+
// under that ceiling to leave real margin, including for atmFindEvent's own
|
|
55
|
+
// retry loop (~8s worst case).
|
|
56
|
+
export const CALENDAR_FIND_TIMEOUT_MS = 45000;
|
|
57
|
+
|
|
47
58
|
const RSVP_STATUS = {
|
|
48
59
|
accept: "accepted",
|
|
49
60
|
decline: "declined",
|
|
@@ -163,16 +174,36 @@ export function defaultEndParts(start, allDay) {
|
|
|
163
174
|
};
|
|
164
175
|
}
|
|
165
176
|
|
|
177
|
+
/**
|
|
178
|
+
* calendar_edit / calendar_rsvp's classic-AppleScript fallback both resolve
|
|
179
|
+
* an event by uid through this handler. Regression (2026-09-23): RSVPing to
|
|
180
|
+
* an event created moments earlier through the daemon's separate EventKit
|
|
181
|
+
* session ("no event with that id was found") failed on the first lookup
|
|
182
|
+
* even though the event genuinely existed -- Calendar.app's classic
|
|
183
|
+
* AppleScript object model can lag behind a write made through EventKit.
|
|
184
|
+
* An EventKit-based fix isn't available here: writeOnly EventKit can only
|
|
185
|
+
* re-query an event via the exact in-memory EKEvent object from the
|
|
186
|
+
* session that created it (see describeEventKitWriteFailure below), which
|
|
187
|
+
* doesn't exist for a real invitation synced in from elsewhere. Retrying
|
|
188
|
+
* the classic lookup a few times gives Calendar.app's own model a chance
|
|
189
|
+
* to catch up, well inside callers' existing 60s budget.
|
|
190
|
+
*/
|
|
166
191
|
function findEventHandler() {
|
|
167
192
|
return `on atmFindEvent(theUid)
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
193
|
+
set attemptsLeft to 5
|
|
194
|
+
repeat
|
|
195
|
+
tell application "Calendar"
|
|
196
|
+
repeat with cal in calendars
|
|
197
|
+
try
|
|
198
|
+
set hits to (every event of cal whose uid is theUid)
|
|
199
|
+
if (count of hits) > 0 then return item 1 of hits
|
|
200
|
+
end try
|
|
201
|
+
end repeat
|
|
202
|
+
end tell
|
|
203
|
+
set attemptsLeft to attemptsLeft - 1
|
|
204
|
+
if attemptsLeft is 0 then exit repeat
|
|
205
|
+
delay 2
|
|
206
|
+
end repeat
|
|
176
207
|
error "EVENT_NOT_FOUND"
|
|
177
208
|
end atmFindEvent`;
|
|
178
209
|
}
|
|
@@ -503,11 +534,19 @@ function addRow(c) {
|
|
|
503
534
|
var local = type === 0 ? "yes" : "no";
|
|
504
535
|
rows.push(name + "<<>>" + writable + "<<>>" + local + "<<>>" + type + "<<>>" + srcName);
|
|
505
536
|
}
|
|
537
|
+
var listError = "";
|
|
506
538
|
try {
|
|
507
539
|
var cals = store.calendarsForEntityType($.EKEntityTypeEvent);
|
|
508
540
|
for (var i = 0; i < cals.count; i++) addRow(cals.objectAtIndex(i));
|
|
509
|
-
} catch (e) {
|
|
510
|
-
|
|
541
|
+
} catch (e) {
|
|
542
|
+
listError = String(e);
|
|
543
|
+
}
|
|
544
|
+
try { addRow(store.defaultCalendarForNewEvents); } catch (e) {
|
|
545
|
+
if (!listError) listError = String(e);
|
|
546
|
+
}
|
|
547
|
+
if (rows.length === 0 && listError) {
|
|
548
|
+
throw new Error("EVENTKIT_LIST_FAILED: " + listError);
|
|
549
|
+
}
|
|
511
550
|
rows.join("|||");`;
|
|
512
551
|
}
|
|
513
552
|
|
|
@@ -659,26 +698,37 @@ set AppleScript's text item delimiters to "|||"
|
|
|
659
698
|
return outputList as string`;
|
|
660
699
|
|
|
661
700
|
const result = runAppleScript(script, { timeout: 30000, appName: "Calendar" });
|
|
662
|
-
if (!result.ok) {
|
|
663
|
-
return { ok: false, message: failure(action, "list calendars", result) };
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
let calendars = result.output
|
|
667
|
-
.split("|||")
|
|
668
|
-
.map((entry) => entry.trim())
|
|
669
|
-
.filter((entry) => entry.length > 0)
|
|
670
|
-
.map((entry) => {
|
|
671
|
-
const [name, writable] = entry.split("<<>>");
|
|
672
|
-
return { name: name || "", writable: writable !== "no" };
|
|
673
|
-
});
|
|
674
701
|
|
|
702
|
+
// EventKit does not require Calendar.app to be running, so a real EventKit
|
|
703
|
+
// list can stand in when the classic AppleScript path fails (app closed).
|
|
704
|
+
// An empty EventKit string is not that list: the JXA helper used to swallow
|
|
705
|
+
// every error and still exit 0, which hid TCC / timeout / not-running.
|
|
675
706
|
const ekList = runAppleScript(buildEventKitListCalendarsScript(), {
|
|
676
707
|
timeout: 15000,
|
|
677
708
|
appName: "Calendar",
|
|
678
709
|
language: "JavaScript"
|
|
679
710
|
});
|
|
680
|
-
|
|
681
|
-
|
|
711
|
+
const ekCalendars = ekList.ok ? parseEventKitCalendarList(ekList.output) : [];
|
|
712
|
+
|
|
713
|
+
if (!result.ok && ekCalendars.length === 0) {
|
|
714
|
+
return { ok: false, message: failure(action, "list calendars", result) };
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
let calendars = result.ok
|
|
718
|
+
? result.output
|
|
719
|
+
.split("|||")
|
|
720
|
+
.map((entry) => entry.trim())
|
|
721
|
+
.filter((entry) => entry.length > 0)
|
|
722
|
+
.map((entry) => {
|
|
723
|
+
const [name, writable] = entry.split("<<>>");
|
|
724
|
+
return { name: name || "", writable: writable !== "no" };
|
|
725
|
+
})
|
|
726
|
+
: [];
|
|
727
|
+
|
|
728
|
+
if (ekCalendars.length > 0) {
|
|
729
|
+
calendars = result.ok
|
|
730
|
+
? mergeCalendarSources(calendars, ekCalendars)
|
|
731
|
+
: ekCalendars;
|
|
682
732
|
}
|
|
683
733
|
|
|
684
734
|
if (calendars.length === 0) {
|
|
@@ -1115,7 +1165,7 @@ export function calendarEdit(args = {}) {
|
|
|
1115
1165
|
return { ok: false, message: `${action} refused: ${e.message}` };
|
|
1116
1166
|
}
|
|
1117
1167
|
|
|
1118
|
-
const result = runAppleScript(script, { timeout:
|
|
1168
|
+
const result = runAppleScript(script, { timeout: CALENDAR_FIND_TIMEOUT_MS, appName: "Calendar" });
|
|
1119
1169
|
if (!result.ok) return { ok: false, message: failure(action, summary, result, [updates.description || ""]) };
|
|
1120
1170
|
|
|
1121
1171
|
return {
|
|
@@ -1284,10 +1334,16 @@ export function calendarRemove(args = {}) {
|
|
|
1284
1334
|
}
|
|
1285
1335
|
|
|
1286
1336
|
export function buildRsvpScript({ eventId, status, attendeeEmail }) {
|
|
1337
|
+
// Calendar.app's AppleScript dictionary defines `participation status` as
|
|
1338
|
+
// one of unknown/accepted/declined/tentative (confirmed via `sdef`/aete
|
|
1339
|
+
// dump) -- there is no "needs action" term, so that bare two-word phrase
|
|
1340
|
+
// was a syntax error (AppleScript parsed "needs" as an identifier and
|
|
1341
|
+
// choked on "action" where it expected "then"). `unknown` is the correct
|
|
1342
|
+
// term for "no answer yet".
|
|
1287
1343
|
const match = attendeeEmail
|
|
1288
1344
|
? ` if (email of att) is ${asString(attendeeEmail)} then set theAttendee to att`
|
|
1289
1345
|
: ` try
|
|
1290
|
-
if (participation status of att) is
|
|
1346
|
+
if (participation status of att) is unknown then set theAttendee to att
|
|
1291
1347
|
end try`;
|
|
1292
1348
|
|
|
1293
1349
|
return `${findEventHandler()}
|
|
@@ -1344,7 +1400,7 @@ export function calendarRsvp(args = {}) {
|
|
|
1344
1400
|
|
|
1345
1401
|
const result = runAppleScript(
|
|
1346
1402
|
buildRsvpScript({ eventId, status: RSVP_STATUS[responseRaw], attendeeEmail }),
|
|
1347
|
-
{ timeout:
|
|
1403
|
+
{ timeout: CALENDAR_FIND_TIMEOUT_MS, appName: "Calendar" }
|
|
1348
1404
|
);
|
|
1349
1405
|
|
|
1350
1406
|
if (!result.ok) {
|
package/lib/config.js
CHANGED
|
@@ -32,7 +32,11 @@ export const MAX_INDEX_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
|
32
32
|
/** Recommended Mini always-on value (set in config.json, not the product default). */
|
|
33
33
|
export const MINI_RECOMMENDED_INDEX_INTERVAL_MS = 60 * 1000;
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
/** --transport=http bind address/port defaults. Network exposure is opt-in. */
|
|
36
|
+
export const DEFAULT_HTTP_HOST = "127.0.0.1";
|
|
37
|
+
export const DEFAULT_HTTP_PORT = 8421;
|
|
38
|
+
|
|
39
|
+
const KNOWN_CONFIG_KEYS = new Set(["indexInterval", "indexIntervalMs", "httpHost", "httpPort"]);
|
|
36
40
|
|
|
37
41
|
const MAX_DURATION_STRING_LENGTH = 32;
|
|
38
42
|
|
|
@@ -310,3 +314,27 @@ export function logResolvedInterval(resolved, options = {}) {
|
|
|
310
314
|
const clampedNote = resolved.clamped ? ", clamped" : "";
|
|
311
315
|
log(`Effective index refresh interval: ${resolved.human} (${resolved.ms} ms) [source=${resolved.source}${clampedNote}]`);
|
|
312
316
|
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Resolve --transport=http bind host/port.
|
|
320
|
+
* Precedence (highest wins): env vars > config.json > product default.
|
|
321
|
+
*
|
|
322
|
+
* @param {{ env?: NodeJS.ProcessEnv, configPath?: string, fileData?: Record<string, unknown>, warn?: (msg: string) => void }} [options]
|
|
323
|
+
* @returns {{ host: string, port: number }}
|
|
324
|
+
*/
|
|
325
|
+
export function resolveHttpServerConfig(options = {}) {
|
|
326
|
+
const env = options.env || process.env;
|
|
327
|
+
const warn = options.warn || defaultWarn;
|
|
328
|
+
const data =
|
|
329
|
+
options.fileData !== undefined
|
|
330
|
+
? options.fileData
|
|
331
|
+
: loadConfigFile({ configPath: options.configPath, env, warn }).data;
|
|
332
|
+
|
|
333
|
+
const host = env.APPLE_TOOLS_HTTP_HOST || (typeof data.httpHost === "string" ? data.httpHost : undefined) || DEFAULT_HTTP_HOST;
|
|
334
|
+
|
|
335
|
+
const rawPort = env.APPLE_TOOLS_HTTP_PORT !== undefined ? env.APPLE_TOOLS_HTTP_PORT : data.httpPort;
|
|
336
|
+
const parsedPort = typeof rawPort === "number" ? rawPort : Number(rawPort);
|
|
337
|
+
const port = Number.isInteger(parsedPort) && parsedPort > 0 && parsedPort < 65536 ? parsedPort : DEFAULT_HTTP_PORT;
|
|
338
|
+
|
|
339
|
+
return { host, port };
|
|
340
|
+
}
|
package/lib/httpAuth.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared-secret auth for the HTTP transport (--transport=http).
|
|
3
|
+
*
|
|
4
|
+
* stdio mode needs no auth — a locally spawned child process is already
|
|
5
|
+
* trusted by whoever spawned it. HTTP mode is reachable over the LAN and,
|
|
6
|
+
* via Tailscale, from outside it, and this server exposes write tools
|
|
7
|
+
* (send iMessage, edit contacts/calendar), so every HTTP request must carry
|
|
8
|
+
* a bearer token.
|
|
9
|
+
*
|
|
10
|
+
* The token is a random secret generated once and stored in the macOS
|
|
11
|
+
* Keychain — never in a config file, the repo, or logs after the first
|
|
12
|
+
* (one-time, clearly labeled) print. There is no external account, no
|
|
13
|
+
* OAuth flow, and no cost: it's a self-issued password, like an SSH key.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import crypto from "crypto";
|
|
17
|
+
import { execFileSync as defaultExecFileSync } from "child_process";
|
|
18
|
+
|
|
19
|
+
export const KEYCHAIN_SERVICE = "apple-tools-mcp-http";
|
|
20
|
+
export const KEYCHAIN_ACCOUNT = "http-auth-token";
|
|
21
|
+
|
|
22
|
+
function readFromKeychain(exec) {
|
|
23
|
+
try {
|
|
24
|
+
const out = exec(
|
|
25
|
+
"/usr/bin/security",
|
|
26
|
+
["find-generic-password", "-s", KEYCHAIN_SERVICE, "-a", KEYCHAIN_ACCOUNT, "-w"],
|
|
27
|
+
{ stdio: ["ignore", "pipe", "ignore"] }
|
|
28
|
+
);
|
|
29
|
+
const token = out.toString("utf8").trim();
|
|
30
|
+
return token.length > 0 ? token : null;
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function writeToKeychain(exec, token) {
|
|
37
|
+
exec(
|
|
38
|
+
"/usr/bin/security",
|
|
39
|
+
["add-generic-password", "-s", KEYCHAIN_SERVICE, "-a", KEYCHAIN_ACCOUNT, "-w", token, "-U"],
|
|
40
|
+
{ stdio: ["ignore", "ignore", "pipe"] }
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function generateToken(randomBytes) {
|
|
45
|
+
return randomBytes(32).toString("hex");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Loads the HTTP auth token from Keychain, generating and storing one the
|
|
50
|
+
* first time this runs on a given machine. Returns the token either way.
|
|
51
|
+
*
|
|
52
|
+
* @param {{
|
|
53
|
+
* log?: (msg: string) => void,
|
|
54
|
+
* forceNew?: boolean,
|
|
55
|
+
* execFileSync?: typeof defaultExecFileSync,
|
|
56
|
+
* randomBytes?: typeof crypto.randomBytes
|
|
57
|
+
* }} [options]
|
|
58
|
+
* @returns {{ token: string, generated: boolean }}
|
|
59
|
+
*/
|
|
60
|
+
export function loadOrCreateHttpAuthToken(options = {}) {
|
|
61
|
+
const log = options.log || ((msg) => console.error(msg));
|
|
62
|
+
const exec = options.execFileSync || defaultExecFileSync;
|
|
63
|
+
const randomBytes = options.randomBytes || crypto.randomBytes;
|
|
64
|
+
|
|
65
|
+
if (!options.forceNew) {
|
|
66
|
+
const existing = readFromKeychain(exec);
|
|
67
|
+
if (existing) {
|
|
68
|
+
return { token: existing, generated: false };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const token = generateToken(randomBytes);
|
|
73
|
+
writeToKeychain(exec, token);
|
|
74
|
+
log("============================================================");
|
|
75
|
+
log("Generated a new HTTP auth token (shown once, stored in Keychain):");
|
|
76
|
+
log(token);
|
|
77
|
+
log("Use this as a Bearer token when configuring remote MCP clients,");
|
|
78
|
+
log("e.g. claude mcp add ... -H \"Authorization: Bearer " + token + "\"");
|
|
79
|
+
log("Retrieve it again later with: apple-tools-mcp http-token");
|
|
80
|
+
log("============================================================");
|
|
81
|
+
return { token, generated: true };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Constant-time check of an incoming `Authorization: Bearer <token>` header
|
|
86
|
+
* against the expected token. Never throws.
|
|
87
|
+
*
|
|
88
|
+
* @param {string | undefined | null} headerValue
|
|
89
|
+
* @param {string} expectedToken
|
|
90
|
+
* @returns {boolean}
|
|
91
|
+
*/
|
|
92
|
+
export function verifyAuthHeader(headerValue, expectedToken) {
|
|
93
|
+
if (typeof headerValue !== "string") {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
const match = /^Bearer\s+(.+)$/i.exec(headerValue.trim());
|
|
97
|
+
if (!match) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
const provided = Buffer.from(match[1], "utf8");
|
|
101
|
+
const expected = Buffer.from(expectedToken, "utf8");
|
|
102
|
+
if (provided.length !== expected.length) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
return crypto.timingSafeEqual(provided, expected);
|
|
106
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
3
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
6
|
+
|
|
7
|
+
/** Forward the tool interface from an HTTP MCP server to a local stdio client. */
|
|
8
|
+
export function createProxyServer(remoteClient) {
|
|
9
|
+
const server = new Server(
|
|
10
|
+
{ name: "apple-tools-http-proxy", version: "3.0.0" },
|
|
11
|
+
{ capabilities: { tools: {} } },
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
server.setRequestHandler(ListToolsRequestSchema, (request) =>
|
|
15
|
+
remoteClient.listTools(request.params));
|
|
16
|
+
server.setRequestHandler(CallToolRequestSchema, (request) =>
|
|
17
|
+
remoteClient.callTool(request.params));
|
|
18
|
+
|
|
19
|
+
return server;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function runHttpStdioProxy({ url, token, input = process.stdin, output = process.stdout }) {
|
|
23
|
+
if (!token) throw new Error("APPLE_TOOLS_MCP_TOKEN is required");
|
|
24
|
+
const endpoint = new URL(url);
|
|
25
|
+
if (endpoint.protocol !== "http:" && endpoint.protocol !== "https:") {
|
|
26
|
+
throw new Error("MCP URL must use http or https");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const remoteClient = new Client({ name: "apple-tools-http-proxy", version: "3.0.0" });
|
|
30
|
+
const remoteTransport = new StreamableHTTPClientTransport(endpoint, {
|
|
31
|
+
requestInit: { headers: { Authorization: `Bearer ${token}` } },
|
|
32
|
+
});
|
|
33
|
+
await remoteClient.connect(remoteTransport);
|
|
34
|
+
|
|
35
|
+
const server = createProxyServer(remoteClient);
|
|
36
|
+
try {
|
|
37
|
+
await server.connect(new StdioServerTransport(input, output));
|
|
38
|
+
} catch (error) {
|
|
39
|
+
await remoteClient.close();
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
server.onclose = () => { void remoteClient.close(); };
|
|
44
|
+
return server;
|
|
45
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authenticated request handler for the Streamable HTTP transport.
|
|
3
|
+
*
|
|
4
|
+
* Keep this separate from index.js so request parsing and authorization can
|
|
5
|
+
* be exercised without starting the MCP server or touching the Keychain.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {{
|
|
10
|
+
* token: string,
|
|
11
|
+
* verifyAuthHeader: (header: string | string[] | undefined, token: string) => boolean,
|
|
12
|
+
* createServer: () => { connect: (transport: unknown) => Promise<void>, close: () => Promise<void> },
|
|
13
|
+
* StreamableHTTPServerTransport: new (options: { sessionIdGenerator: undefined }) => { handleRequest: (req: unknown, res: unknown) => Promise<void>, close: () => Promise<void> },
|
|
14
|
+
* packageVersion: string,
|
|
15
|
+
* log?: (message: string) => void
|
|
16
|
+
* }} options
|
|
17
|
+
*/
|
|
18
|
+
export function createHttpRequestHandler(options) {
|
|
19
|
+
const { token, verifyAuthHeader, createServer, StreamableHTTPServerTransport, packageVersion } = options;
|
|
20
|
+
const log = options.log || ((message) => console.error(message));
|
|
21
|
+
|
|
22
|
+
return (req, res) => {
|
|
23
|
+
if (!verifyAuthHeader(req.headers["authorization"], token)) {
|
|
24
|
+
res.writeHead(401, { "Content-Type": "application/json", "WWW-Authenticate": "Bearer" });
|
|
25
|
+
res.end(JSON.stringify({ error: "Unauthorized: missing or invalid bearer token" }));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let url;
|
|
30
|
+
try {
|
|
31
|
+
// The request target is relative. Do not derive a URL base from the
|
|
32
|
+
// user-controlled Host header, which can itself be malformed.
|
|
33
|
+
url = new URL(req.url || "/", "http://localhost");
|
|
34
|
+
} catch {
|
|
35
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
36
|
+
res.end(JSON.stringify({ error: "bad request" }));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (url.pathname === "/health") {
|
|
41
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
42
|
+
res.end(JSON.stringify({ ok: true, version: packageVersion }));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (url.pathname !== "/mcp" && url.pathname !== "/") {
|
|
47
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
48
|
+
res.end(JSON.stringify({ error: "not found" }));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
void (async () => {
|
|
53
|
+
const requestServer = createServer();
|
|
54
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
55
|
+
try {
|
|
56
|
+
await requestServer.connect(transport);
|
|
57
|
+
await transport.handleRequest(req, res);
|
|
58
|
+
res.on("close", () => {
|
|
59
|
+
void transport.close();
|
|
60
|
+
void requestServer.close();
|
|
61
|
+
});
|
|
62
|
+
} catch (err) {
|
|
63
|
+
log(`MCP HTTP request error: ${err.message}`);
|
|
64
|
+
if (!res.headersSent) {
|
|
65
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
66
|
+
res.end(JSON.stringify({ error: "internal error" }));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
})();
|
|
70
|
+
};
|
|
71
|
+
}
|