@flightbyte/mcp 0.0.0-stage → 1.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.
@@ -0,0 +1,239 @@
1
+ import { Readable } from 'node:stream';
2
+ import { pipeline } from 'node:stream/promises';
3
+ import { createMcpHandler, isLegacyRequest, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server';
4
+ import { VERSION } from './config.js';
5
+ import { createServer } from './server.js';
6
+ const CORS_HEADERS = {
7
+ 'access-control-allow-origin': '*',
8
+ 'access-control-allow-methods': 'POST, OPTIONS',
9
+ 'access-control-allow-headers': 'authorization, x-api-key, content-type, accept, mcp-protocol-version, mcp-method, mcp-name, mcp-session-id, last-event-id',
10
+ 'access-control-expose-headers': 'mcp-session-id, retry-after',
11
+ 'access-control-max-age': '86400',
12
+ };
13
+ /** A fixed one-minute window per key. Small, in memory, and per process: enough to stop one client hammering the server. */
14
+ export class RateLimiter {
15
+ limit;
16
+ windowMs;
17
+ now;
18
+ #hits = new Map();
19
+ constructor(limit, windowMs = 60_000, now = Date.now) {
20
+ this.limit = limit;
21
+ this.windowMs = windowMs;
22
+ this.now = now;
23
+ }
24
+ take(key) {
25
+ const now = this.now();
26
+ let entry = this.#hits.get(key);
27
+ if (!entry || entry.resetAt <= now) {
28
+ entry = { count: 0, resetAt: now + this.windowMs };
29
+ this.#hits.set(key, entry);
30
+ }
31
+ entry.count++;
32
+ return { ok: entry.count <= this.limit, retryAfterSeconds: Math.max(1, Math.ceil((entry.resetAt - now) / 1000)) };
33
+ }
34
+ sweep() {
35
+ const now = this.now();
36
+ for (const [key, entry] of this.#hits)
37
+ if (entry.resetAt <= now)
38
+ this.#hits.delete(key);
39
+ }
40
+ }
41
+ /** The caller's FlightByte key from `Authorization: Bearer <key>` or `x-api-key`. */
42
+ export function extractApiKey(headers) {
43
+ const bearer = headers.get('authorization')?.match(/^Bearer\s+(\S+)\s*$/i)?.[1];
44
+ if (bearer)
45
+ return bearer;
46
+ return headers.get('x-api-key')?.trim() || undefined;
47
+ }
48
+ export function clientIp(req, trustProxy) {
49
+ if (trustProxy > 0) {
50
+ const forwarded = first(req.headers['x-forwarded-for'])
51
+ ?.split(',')
52
+ .map((s) => s.trim())
53
+ .filter(Boolean);
54
+ if (forwarded && forwarded.length > 0)
55
+ return forwarded[Math.max(0, forwarded.length - trustProxy)];
56
+ }
57
+ return req.socket.remoteAddress ?? 'unknown';
58
+ }
59
+ export function createHttpApp(options) {
60
+ const maxBodyBytes = options.maxBodyBytes ?? 1_000_000;
61
+ const log = options.log ?? ((line) => console.error(line));
62
+ const now = options.now ?? Date.now;
63
+ const limiter = options.rateLimitPerMinute > 0 ? new RateLimiter(options.rateLimitPerMinute, 60_000, now) : undefined;
64
+ const allowedHosts = new Set(options.allowedHosts.map((h) => h.toLowerCase()));
65
+ const serverFor = (headers) => createServer({ apiUrl: options.apiUrl, apiKey: extractApiKey(headers), docs: options.docs, fetch: options.fetch });
66
+ // 2026-07-28 requests: the SDK entry builds a fresh server per request. Tools send nothing before their result, so it answers plain JSON.
67
+ const modern = createMcpHandler(({ requestInfo }) => serverFor(requestInfo?.headers ?? new Headers()), {
68
+ legacy: 'reject',
69
+ onerror: (err) => log(`mcp error: ${err.message}`),
70
+ });
71
+ // 2025-era requests (protocol 2024-11-05 to 2025-11-25): stateless Streamable HTTP with JSON responses, a fresh server per request.
72
+ const serveLegacy = async (request, body, res) => {
73
+ const server = serverFor(request.headers);
74
+ const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
75
+ res.on('close', () => {
76
+ void transport.close();
77
+ void server.close();
78
+ });
79
+ await server.connect(transport);
80
+ return transport.handleRequest(request, { parsedBody: body });
81
+ };
82
+ const handler = async (req, res) => {
83
+ const started = now();
84
+ const url = new URL(req.url ?? '/', 'http://localhost');
85
+ let note = '';
86
+ res.on('finish', () => {
87
+ // Never log headers or bodies: they carry API keys, webhook URLs and secrets.
88
+ log(`${req.method} ${url.pathname} ${res.statusCode} ${now() - started}ms${note}`);
89
+ });
90
+ try {
91
+ if (url.pathname === '/healthz' && (req.method === 'GET' || req.method === 'HEAD')) {
92
+ return sendJson(res, 200, { status: 'ok', version: VERSION });
93
+ }
94
+ if (url.pathname === '/' && req.method === 'GET') {
95
+ return sendJson(res, 200, {
96
+ name: 'FlightByte MCP server',
97
+ version: VERSION,
98
+ mcp: '/mcp',
99
+ transport: 'streamable-http',
100
+ auth: 'Authorization: Bearer <FlightByte API key>',
101
+ docs: 'https://flightbyte.dev/docs/ai',
102
+ });
103
+ }
104
+ if (url.pathname !== '/mcp')
105
+ return sendJson(res, 404, { error: 'Not found. The MCP endpoint is POST /mcp.' });
106
+ for (const [name, value] of Object.entries(CORS_HEADERS))
107
+ res.setHeader(name, value);
108
+ if (allowedHosts.size > 0) {
109
+ const host = hostname(first(req.headers.host));
110
+ if (!host || !allowedHosts.has(host))
111
+ return sendRpcError(res, 403, -32000, 'Host not allowed.');
112
+ }
113
+ if (req.method === 'OPTIONS') {
114
+ res.writeHead(204).end();
115
+ return;
116
+ }
117
+ if (req.method !== 'POST') {
118
+ res.setHeader('allow', 'POST, OPTIONS');
119
+ // Stateless server: no standalone SSE stream (GET) and no sessions to end (DELETE).
120
+ return sendRpcError(res, 405, -32000, 'Method not allowed. This server is stateless: send JSON-RPC requests with POST.');
121
+ }
122
+ if (limiter) {
123
+ const verdict = limiter.take(clientIp(req, options.trustProxy));
124
+ if (!verdict.ok) {
125
+ res.setHeader('retry-after', String(verdict.retryAfterSeconds));
126
+ return sendRpcError(res, 429, -32000, `Too many requests. Retry after ${verdict.retryAfterSeconds} seconds.`);
127
+ }
128
+ }
129
+ let raw;
130
+ let body;
131
+ try {
132
+ raw = await readBody(req, maxBodyBytes);
133
+ body = JSON.parse(raw);
134
+ }
135
+ catch (err) {
136
+ if (err instanceof BodyTooLargeError) {
137
+ res.setHeader('connection', 'close');
138
+ return sendRpcError(res, 413, -32600, `Request body over ${maxBodyBytes} bytes.`);
139
+ }
140
+ return sendRpcError(res, 400, -32700, 'Parse error: the body must be a JSON-RPC message.');
141
+ }
142
+ note = describeRpc(body);
143
+ // Aborted when the client goes away before the response is complete, which cancels the work behind it.
144
+ const abort = new AbortController();
145
+ res.on('close', () => {
146
+ if (!res.writableFinished)
147
+ abort.abort();
148
+ });
149
+ const request = toWebRequest(req, url, raw, abort.signal);
150
+ const legacy = await isLegacyRequest(request, body);
151
+ note += `${extractApiKey(request.headers) ? ' key=yes' : ' key=no'} era=${legacy ? 'legacy' : 'modern'}`;
152
+ await sendResponse(res, legacy ? await serveLegacy(request, body, res) : await modern.fetch(request, { parsedBody: body }));
153
+ }
154
+ catch (err) {
155
+ log(`error handling ${req.method} ${url.pathname}: ${err instanceof Error ? err.message : String(err)}`);
156
+ if (!res.headersSent)
157
+ sendRpcError(res, 500, -32603, 'Internal server error.');
158
+ else
159
+ res.end();
160
+ }
161
+ };
162
+ return { handler, limiter, close: () => modern.close() };
163
+ }
164
+ /** Hop-by-hop headers describe this connection, not the request; a web Request must not carry them. */
165
+ const HOP_BY_HOP = new Set(['connection', 'keep-alive', 'transfer-encoding', 'content-length', 'upgrade', 'te', 'trailer']);
166
+ function toWebRequest(req, url, body, signal) {
167
+ const headers = new Headers();
168
+ for (const [name, value] of Object.entries(req.headers)) {
169
+ if (value === undefined || HOP_BY_HOP.has(name))
170
+ continue;
171
+ for (const v of Array.isArray(value) ? value : [value])
172
+ headers.append(name, v);
173
+ }
174
+ return new Request(url, { method: req.method, headers, body, signal });
175
+ }
176
+ async function sendResponse(res, response) {
177
+ res.statusCode = response.status;
178
+ response.headers.forEach((value, name) => res.setHeader(name, value));
179
+ if (!response.body) {
180
+ res.end();
181
+ return;
182
+ }
183
+ try {
184
+ await pipeline(Readable.fromWeb(response.body), res);
185
+ }
186
+ catch (err) {
187
+ // A client that hangs up mid-stream is not a server error.
188
+ if (!res.destroyed)
189
+ throw err;
190
+ }
191
+ }
192
+ class BodyTooLargeError extends Error {
193
+ }
194
+ function readBody(req, limit) {
195
+ return new Promise((resolve, reject) => {
196
+ const chunks = [];
197
+ let size = 0;
198
+ req.on('data', (chunk) => {
199
+ size += chunk.length;
200
+ if (size > limit) {
201
+ // Stop buffering but let the request drain, so the 413 still reaches the client.
202
+ req.removeAllListeners('data');
203
+ req.resume();
204
+ reject(new BodyTooLargeError());
205
+ return;
206
+ }
207
+ chunks.push(chunk);
208
+ });
209
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
210
+ req.on('error', reject);
211
+ });
212
+ }
213
+ function describeRpc(body) {
214
+ const messages = Array.isArray(body) ? body : [body];
215
+ const parts = messages.map((m) => {
216
+ const msg = m;
217
+ if (typeof msg?.method !== 'string')
218
+ return 'response';
219
+ return msg.method === 'tools/call' && typeof msg.params?.name === 'string' ? `tools/call:${msg.params.name}` : msg.method;
220
+ });
221
+ return parts.length > 0 ? ` ${parts.join(',')}` : '';
222
+ }
223
+ function sendJson(res, status, body) {
224
+ res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify(body));
225
+ }
226
+ function sendRpcError(res, status, code, message) {
227
+ sendJson(res, status, { jsonrpc: '2.0', error: { code, message }, id: null });
228
+ }
229
+ function first(value) {
230
+ return Array.isArray(value) ? value[0] : value;
231
+ }
232
+ function hostname(host) {
233
+ if (!host)
234
+ return undefined;
235
+ const lower = host.toLowerCase();
236
+ if (lower.startsWith('['))
237
+ return lower.slice(0, lower.indexOf(']') + 1);
238
+ return lower.split(':')[0];
239
+ }
package/dist/http.js ADDED
@@ -0,0 +1,51 @@
1
+ import { createServer as createHttpServer } from 'node:http';
2
+ import { loadConfig, VERSION } from './config.js';
3
+ import { DocsStore } from './docs.js';
4
+ import { createHttpApp } from './http-app.js';
5
+ const config = loadConfig();
6
+ const port = intEnv('PORT', 8080);
7
+ const host = process.env.HOST?.trim() || '127.0.0.1';
8
+ const loopback = ['127.0.0.1', 'localhost', '::1'].includes(host);
9
+ // Bound to loopback, only loopback Host headers are accepted (DNS rebinding protection), unless ALLOWED_HOSTS says otherwise.
10
+ const allowedHosts = (process.env.ALLOWED_HOSTS ?? '')
11
+ .split(',')
12
+ .map((h) => h.trim())
13
+ .filter(Boolean);
14
+ if (allowedHosts.length === 0 && loopback)
15
+ allowedHosts.push('localhost', '127.0.0.1', '[::1]');
16
+ const app = createHttpApp({
17
+ apiUrl: config.apiUrl,
18
+ docs: new DocsStore({ docsUrl: config.docsUrl }),
19
+ rateLimitPerMinute: intEnv('RATE_LIMIT_PER_MINUTE', 120),
20
+ trustProxy: intEnv('TRUST_PROXY', 0),
21
+ allowedHosts,
22
+ });
23
+ const server = createHttpServer((req, res) => void app.handler(req, res));
24
+ server.keepAliveTimeout = 65_000;
25
+ server.headersTimeout = 66_000;
26
+ server.requestTimeout = 120_000;
27
+ const sweeper = setInterval(() => app.limiter?.sweep(), 60_000);
28
+ sweeper.unref();
29
+ server.listen(port, host, () => {
30
+ const address = server.address();
31
+ const bound = typeof address === 'object' && address ? address.port : port;
32
+ console.error(`flightbyte-mcp ${VERSION} listening on http://${host.includes(':') ? `[${host}]` : host}:${bound}/mcp`);
33
+ });
34
+ const shutdown = (signal) => {
35
+ console.error(`${signal} received, closing`);
36
+ // End in-flight 2026-era exchanges so open streams do not hold the server past the grace period.
37
+ void app.close();
38
+ server.close(() => process.exit(0));
39
+ setTimeout(() => process.exit(0), 10_000).unref();
40
+ };
41
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
42
+ process.on('SIGINT', () => shutdown('SIGINT'));
43
+ function intEnv(name, fallback) {
44
+ const raw = process.env[name]?.trim();
45
+ if (!raw)
46
+ return fallback;
47
+ const value = Number.parseInt(raw, 10);
48
+ if (!Number.isFinite(value) || value < 0)
49
+ throw new Error(`${name} must be a non-negative integer, got "${raw}"`);
50
+ return value;
51
+ }
package/dist/server.js ADDED
@@ -0,0 +1,27 @@
1
+ import { McpServer } from '@modelcontextprotocol/server';
2
+ import { ApiClient } from './api.js';
3
+ import { VERSION } from './config.js';
4
+ import { DocsStore } from './docs.js';
5
+ import { API_TOOLS, inputSchema, runApiTool } from './tools/api-tools.js';
6
+ import { registerDocsTools } from './tools/docs-tools.js';
7
+ export { VERSION } from './config.js';
8
+ export { DocsStore } from './docs.js';
9
+ export { API_TOOLS } from './tools/api-tools.js';
10
+ const INSTRUCTIONS = `FlightByte is a flight status API with signed webhooks.
11
+
12
+ - Questions about the API, its errors, webhooks or how to integrate: call search_docs, then get_doc_page for the full page. These work without an API key.
13
+ - Flight status: get_flight with carrier (e.g. "TK"), flightNumber (e.g. "1848") and date, the local departure date at the origin (YYYY-MM-DD). A flight number can operate several legs on one date; pass origin when the user names a departure airport.
14
+ - To be told about changes instead of asking repeatedly, create_subscription with the user's webhook URL. Each subscription is a billable watched flight, so create one only when the user asks. The response carries the webhook signing secret, shown once: relay it to the user.
15
+ - Manage subscriptions with list_subscriptions, get_subscription, update_subscription, pause_subscription, resume_subscription and delete_subscription (ask before deleting); debug a receiver with send_test_event, list_deliveries and list_events.
16
+ - When a tool returns an error, follow its "What to do" line; do not retry 400, 401, 402 or 404 errors unchanged.`;
17
+ export function createServer(options) {
18
+ const server = new McpServer({ name: 'flightbyte', title: 'FlightByte', version: VERSION, websiteUrl: 'https://flightbyte.dev/docs/ai' },
19
+ // The tool list never changes, so clients have nothing to listen for.
20
+ { instructions: INSTRUCTIONS, capabilities: { tools: { listChanged: false } } });
21
+ const client = new ApiClient({ baseUrl: options.apiUrl, apiKey: options.apiKey, fetch: options.fetch });
22
+ registerDocsTools(server, options.docs);
23
+ for (const def of API_TOOLS) {
24
+ server.registerTool(def.name, { title: def.title, description: def.description, inputSchema: inputSchema(def), annotations: def.annotations }, async (args) => runApiTool(def, client, args));
25
+ }
26
+ return server;
27
+ }
package/dist/stdio.js ADDED
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ import { serveStdio } from '@modelcontextprotocol/server/stdio';
3
+ import { loadConfig, VERSION } from './config.js';
4
+ import { DocsStore } from './docs.js';
5
+ import { createServer } from './server.js';
6
+ // stdout carries the protocol; anything human-readable goes to stderr.
7
+ const args = process.argv.slice(2);
8
+ if (args.includes('--version') || args.includes('-v')) {
9
+ console.error(VERSION);
10
+ process.exit(0);
11
+ }
12
+ if (args.includes('--help') || args.includes('-h')) {
13
+ console.error(`flightbyte-mcp ${VERSION}: the FlightByte MCP server over stdio.
14
+
15
+ Environment:
16
+ FLIGHTBYTE_KEY Your FlightByte API key (https://app.flightbyte.dev/signup). Docs tools work without it.
17
+ FLIGHTBYTE_API_URL API base URL (default https://api.flightbyte.dev/v1)
18
+ FLIGHTBYTE_DOCS_URL Docs site (default https://flightbyte.dev)
19
+
20
+ Docs: https://flightbyte.dev/docs/ai`);
21
+ process.exit(0);
22
+ }
23
+ const config = loadConfig();
24
+ const apiKey = process.env.FLIGHTBYTE_KEY?.trim() || undefined;
25
+ const docs = new DocsStore({ docsUrl: config.docsUrl });
26
+ // The client's opening message picks the protocol era (2025 initialize or 2026-07-28); one server instance serves the connection.
27
+ const connection = serveStdio(() => createServer({ apiUrl: config.apiUrl, apiKey, docs }), {
28
+ onerror: (err) => console.error(`stdio error: ${err.message}`),
29
+ });
30
+ console.error(`flightbyte-mcp ${VERSION} running on stdio (${apiKey ? 'API key set' : 'no FLIGHTBYTE_KEY: docs tools only'})`);
31
+ const shutdown = () => {
32
+ void connection.close().finally(() => process.exit(0));
33
+ };
34
+ process.on('SIGINT', shutdown);
35
+ process.on('SIGTERM', shutdown);