pi-note 0.28.0 → 0.29.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/mcp/http.ts ADDED
@@ -0,0 +1,192 @@
1
+ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
2
+ import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
3
+ import { appendFileSync, mkdirSync } from 'node:fs';
4
+ import { dirname, resolve } from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+ import { isIP } from 'node:net';
7
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
8
+ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
9
+ import { createPinoteServer } from './core.ts';
10
+ import { pinoteToken } from '../token.ts';
11
+
12
+ export interface PinoteHttpOptions {
13
+ token?: string;
14
+ host?: string;
15
+ port?: number;
16
+ /** Exact URL authorities, including port. Required for DNS names other than localhost. */
17
+ allowedHosts?: string[];
18
+ /** Exact browser origins. Omitted means all browser Origin headers are rejected. No CORS is enabled. */
19
+ allowedOrigins?: string[];
20
+ maxSessions?: number;
21
+ maxBodyBytes?: number;
22
+ idleTtlMs?: number;
23
+ logPath?: string;
24
+ }
25
+
26
+ function diagnostic(path: string, event: string): void {
27
+ const line = `${new Date().toISOString()} Pinote HTTP ${event}\n`;
28
+ process.stdout.write(line);
29
+ try { appendFileSync(path, line, { mode: 0o600 }); }
30
+ catch { process.stdout.write('Pinote HTTP log write failed\n'); }
31
+ }
32
+
33
+ /** Remote deployments MUST use a private VPN or trusted TLS reverse proxy.
34
+ * Forward Host unchanged or explicitly allow the proxy authority; never expose this plaintext listener publicly.
35
+ * PINOTE_TOKEN is mandatory; PINOTE_HOST/PORT and PINOTE_LOG_PATH configure the CLI.
36
+ * No request arguments, results, tokens, or exception messages are logged.
37
+ */
38
+ export async function startPinoteHttpServer(options: PinoteHttpOptions = {}) {
39
+ const token = options.token === undefined ? pinoteToken() : pinoteToken({ PINOTE_TOKEN: options.token });
40
+ const host = options.host ?? process.env.PINOTE_HOST ?? '127.0.0.1';
41
+ const port = options.port ?? Number(process.env.PINOTE_PORT ?? 8765);
42
+ const maxSessions = options.maxSessions ?? 64;
43
+ const maxBody = options.maxBodyBytes ?? 1024 * 1024;
44
+ const ttl = options.idleTtlMs ?? 30 * 60_000;
45
+ if (!Number.isInteger(port) || port < 0 || port > 65535 || ![maxSessions, maxBody, ttl].every((n) => Number.isSafeInteger(n) && n > 0)) throw new Error('Invalid HTTP limits or port');
46
+ const logPath = resolve(options.logPath ?? process.env.PINOTE_LOG_PATH ?? 'app.log');
47
+ mkdirSync(dirname(logPath), { recursive: true });
48
+ // Fail startup if the configured log is not writable.
49
+ appendFileSync(logPath, '', { mode: 0o600 });
50
+ const log = (event: string) => diagnostic(logPath, event);
51
+ const digest = (value: string) => createHash('sha256').update(value).digest();
52
+ const expected = digest(`Bearer ${token}`);
53
+ type Session = { transport: StreamableHTTPServerTransport; server: ReturnType<typeof createPinoteServer>; busy: number; streams: number; touched: number };
54
+ const sessions = new Map<string, Session>();
55
+ const all = new Set<Session>();
56
+ let stopping = false;
57
+ const configuredHosts = options.allowedHosts ?? process.env.PINOTE_ALLOWED_HOSTS?.split(',').map((value) => value.trim()).filter(Boolean);
58
+ if (configuredHosts?.some((value) => !/^(?:\[[0-9a-fA-F:]+\]|[a-zA-Z0-9.-]+)(?::[0-9]+)?$/.test(value))) throw new Error('Invalid allowed HTTP authority');
59
+ let allowedHosts = new Set(configuredHosts);
60
+ const reject = (res: ServerResponse, status: number) => { res.writeHead(status, { 'Content-Type': 'text/plain' }); res.end(`${status}\n`); };
61
+ const single = (req: IncomingMessage, name: string) => {
62
+ const values = req.rawHeaders.filter((_, i) => i % 2 === 0 && req.rawHeaders[i].toLowerCase() === name);
63
+ return values.length <= 1;
64
+ };
65
+ const listener = createServer(async (req, res) => {
66
+ let created: Session | undefined;
67
+ try {
68
+ for (const name of ['authorization', 'host', 'origin', 'mcp-session-id', 'mcp-protocol-version', 'content-type', 'content-length', 'accept', 'last-event-id']) {
69
+ if (!single(req, name)) return reject(res, 400);
70
+ }
71
+ if (!timingSafeEqual(digest(req.headers.authorization ?? ''), expected)) {
72
+ res.setHeader('WWW-Authenticate', 'Bearer');
73
+ return reject(res, 401);
74
+ }
75
+ if (!allowedHosts.has(req.headers.host ?? '')) return reject(res, 403);
76
+ if (req.headers.origin !== undefined && !options.allowedOrigins?.includes(req.headers.origin)) return reject(res, 403);
77
+ if (req.url !== '/mcp') return reject(res, 404);
78
+ if (stopping) return reject(res, 503);
79
+ if (!['GET', 'POST', 'DELETE'].includes(req.method ?? '')) { res.setHeader('Allow', 'GET, POST, DELETE'); return reject(res, 405); }
80
+ const id = req.headers['mcp-session-id'];
81
+ if (id !== undefined && (typeof id !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id))) return reject(res, 400);
82
+ let session = typeof id === 'string' ? sessions.get(id) : undefined;
83
+ if (id && !session) return reject(res, 404);
84
+ const track = (current: Session) => {
85
+ current.streams++;
86
+ current.touched = Date.now();
87
+ let released = false;
88
+ const release = () => { if (!released) { released = true; current.streams--; current.touched = Date.now(); } };
89
+ res.once('close', release);
90
+ res.once('finish', release);
91
+ };
92
+ if (session) track(session);
93
+ let body: unknown;
94
+ if (req.method === 'POST') {
95
+ if (!/^application\/json(?:\s*;|$)/i.test(req.headers['content-type'] ?? '')) return reject(res, 415);
96
+ if (Number(req.headers['content-length'] ?? 0) > maxBody) return reject(res, 413);
97
+ let size = 0;
98
+ const chunks: Buffer[] = [];
99
+ for await (const chunk of req) {
100
+ size += chunk.length;
101
+ if (size > maxBody) return reject(res, 413);
102
+ chunks.push(Buffer.from(chunk));
103
+ }
104
+ try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')); }
105
+ catch { return reject(res, 400); }
106
+ }
107
+ if (stopping) return reject(res, 503);
108
+ if (!session) {
109
+ if (req.method !== 'POST' || !isInitializeRequest(body) || Array.isArray(body)) return reject(res, 400);
110
+ if (all.size >= maxSessions) return reject(res, 503);
111
+ const transport = new StreamableHTTPServerTransport({
112
+ sessionIdGenerator: randomUUID,
113
+ onsessioninitialized: (sessionId) => { sessions.set(sessionId, entry); },
114
+ });
115
+ const entry: Session = { transport, server: undefined!, busy: 0, streams: 0, touched: Date.now() };
116
+ entry.server = createPinoteServer({
117
+ onError: () => log('protocol or notification failed'),
118
+ onBusy: (delta) => { entry.busy += delta; entry.touched = Date.now(); },
119
+ onClose: () => { all.delete(entry); if (transport.sessionId) sessions.delete(transport.sessionId); log('session closed'); },
120
+ });
121
+ all.add(entry);
122
+ created = entry;
123
+ session = entry;
124
+ track(entry);
125
+ await entry.server.connect(transport);
126
+ log('session opened');
127
+ } else if (isInitializeRequest(body)) return reject(res, 400);
128
+ const current = session;
129
+ await current.transport.handleRequest(req, res, body);
130
+ if (current.transport.sessionId && all.has(current)) sessions.set(current.transport.sessionId, current);
131
+ else await current.server.close();
132
+ } catch {
133
+ if (created) await created.server.close().catch(() => {});
134
+ log('request failed');
135
+ if (!res.headersSent) reject(res, 500); else res.end();
136
+ }
137
+ });
138
+ listener.requestTimeout = 30_000;
139
+ listener.headersTimeout = 10_000;
140
+ await new Promise<void>((ok, fail) => { listener.once('error', fail); listener.listen(port, host, () => { listener.off('error', fail); ok(); }); });
141
+ const address = listener.address();
142
+ if (!address || typeof address === 'string') throw new Error('Missing HTTP address');
143
+ const authority = `${isIP(host) === 6 ? `[${host}]` : host}:${address.port}`;
144
+ if (!configuredHosts) {
145
+ // Never trust arbitrary DNS names or wildcard listen addresses by default.
146
+ if (host === 'localhost' || isIP(host) && host !== '0.0.0.0' && host !== '::') allowedHosts = new Set([authority]);
147
+ }
148
+ const timer = setInterval(() => {
149
+ for (const session of all) {
150
+ if (!session.busy && !session.streams && Date.now() - session.touched >= ttl) {
151
+ session.touched = Infinity;
152
+ void session.server.close().catch(() => log('session cleanup failed'));
153
+ }
154
+ }
155
+ }, Math.min(ttl, 30_000));
156
+ timer.unref();
157
+ log('listening; remote access requires private VPN or TLS reverse proxy');
158
+ let closing: Promise<void> | undefined;
159
+ const close = () => closing ??= (async () => {
160
+ stopping = true;
161
+ clearInterval(timer);
162
+ const closed = new Promise<void>((ok) => listener.close(() => ok()));
163
+ const force = setTimeout(() => listener.closeAllConnections(), 3000);
164
+ try {
165
+ await Promise.allSettled([...all].map((session) => session.server.close()));
166
+ await closed;
167
+ } finally { clearTimeout(force); log('stopped'); }
168
+ })();
169
+ return { listener, url: `http://${authority}/mcp`, close };
170
+ }
171
+
172
+ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
173
+ try {
174
+ const service = await startPinoteHttpServer();
175
+ const stop = (code = 0) => {
176
+ const deadline = setTimeout(() => process.exit(1), 5000);
177
+ service.close().then(() => { clearTimeout(deadline); process.exit(code); }, () => process.exit(1));
178
+ };
179
+ const fatal = () => { diagnostic(resolve(process.env.PINOTE_LOG_PATH ?? 'app.log'), 'fatal runtime error'); stop(1); };
180
+ process.once('uncaughtException', fatal);
181
+ process.once('unhandledRejection', fatal);
182
+ process.on('warning', () => diagnostic(resolve(process.env.PINOTE_LOG_PATH ?? 'app.log'), 'runtime warning'));
183
+ process.once('SIGTERM', () => stop());
184
+ process.once('SIGINT', () => stop());
185
+ } catch {
186
+ // Startup exceptions can contain paths or third-party data: do not print them.
187
+ const line = 'Pinote HTTP startup failed; check token, listener and writable log configuration\n';
188
+ process.stdout.write(line);
189
+ try { appendFileSync(resolve(process.env.PINOTE_LOG_PATH ?? 'app.log'), line, { mode: 0o600 }); } catch { /* An unwritable log can itself be the startup failure. */ }
190
+ process.exitCode = 1;
191
+ }
192
+ }