apple-tools-mcp 2.1.4 → 3.0.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/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: @xenova/transformers loads sharp at import time. A static
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("@xenova/transformers");
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/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
- const KNOWN_CONFIG_KEYS = new Set(["indexInterval", "indexIntervalMs"]);
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
+ }
@@ -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
+ }