@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.
- package/LICENSE +21 -0
- package/README.md +346 -2
- package/dist/api.js +80 -0
- package/dist/config.js +21 -0
- package/dist/docs.js +414 -0
- package/dist/errors.js +119 -0
- package/dist/http-app.js +239 -0
- package/dist/http.js +51 -0
- package/dist/server.js +27 -0
- package/dist/stdio.js +35 -0
- package/dist/tools/api-tools.js +437 -0
- package/dist/tools/docs-tools.js +84 -0
- package/package.json +71 -4
- package/server.json +53 -0
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { apiErrorResult, missingKeyError } from '../errors.js';
|
|
3
|
+
/** The 23 change types a subscription can filter on (CreateSubscriptionDto.events in the OpenAPI document). */
|
|
4
|
+
export const CHANGE_TYPES = [
|
|
5
|
+
'status.changed',
|
|
6
|
+
'flight.departed',
|
|
7
|
+
'flight.arrived',
|
|
8
|
+
'flight.cancelled',
|
|
9
|
+
'flight.diverted',
|
|
10
|
+
'flight.reinstated',
|
|
11
|
+
'departure.gate.changed',
|
|
12
|
+
'departure.terminal.changed',
|
|
13
|
+
'arrival.gate.changed',
|
|
14
|
+
'arrival.terminal.changed',
|
|
15
|
+
'arrival.baggage.changed',
|
|
16
|
+
'departure.scheduled.changed',
|
|
17
|
+
'departure.estimated.changed',
|
|
18
|
+
'departure.actual.changed',
|
|
19
|
+
'arrival.scheduled.changed',
|
|
20
|
+
'arrival.estimated.changed',
|
|
21
|
+
'arrival.actual.changed',
|
|
22
|
+
'delay.increased',
|
|
23
|
+
'delay.decreased',
|
|
24
|
+
'aircraft.changed',
|
|
25
|
+
'aircraft.tail.changed',
|
|
26
|
+
'route.changed',
|
|
27
|
+
'codeshares.changed',
|
|
28
|
+
];
|
|
29
|
+
export const SUBSCRIPTION_STATUSES = ['active', 'pending', 'paused', 'completed', 'suspended'];
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Shared parameter schemas. Patterns mirror the API's own validation, case-insensitively
|
|
32
|
+
// (the API upper-cases codes before validating them).
|
|
33
|
+
const carrier = z
|
|
34
|
+
.string()
|
|
35
|
+
.regex(/^[A-Za-z0-9]{2,3}$/)
|
|
36
|
+
.describe('Airline code: the 2-character IATA code (e.g. "TK") or 3-character ICAO code (e.g. "THY"). For flight "TK1848" this is "TK".');
|
|
37
|
+
const flightNumber = z
|
|
38
|
+
.string()
|
|
39
|
+
.regex(/^\d{1,4}[A-Za-z]?$/)
|
|
40
|
+
.describe('Flight number without the airline code: 1 to 4 digits and an optional letter suffix. For "TK1848" this is "1848". Leading zeros are ignored.');
|
|
41
|
+
const date = z
|
|
42
|
+
.string()
|
|
43
|
+
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
|
44
|
+
.describe('Local departure date at the origin airport, YYYY-MM-DD. A flight that leaves at 23:50 and lands the next morning belongs to the day it left. Live records cover a few days back to about two days ahead.');
|
|
45
|
+
const origin = z
|
|
46
|
+
.string()
|
|
47
|
+
.regex(/^[A-Za-z0-9]{3,4}$/)
|
|
48
|
+
.describe('Departure airport, IATA code (e.g. "IST"; the upstream code such as "ISL" also works). Picks the leg when the flight number operates more than one leg that day.');
|
|
49
|
+
const destination = z
|
|
50
|
+
.string()
|
|
51
|
+
.regex(/^[A-Za-z0-9]{3,4}$/)
|
|
52
|
+
.describe('Arrival airport, IATA code (e.g. "JFK"). Narrows further when several legs leave the same airport.');
|
|
53
|
+
const flightId = z
|
|
54
|
+
.string()
|
|
55
|
+
.regex(/^[A-Za-z0-9_-]{1,32}$/)
|
|
56
|
+
.describe("Exact leg pick: a providerFlightId from a previous response's `alternates`, or a flightId from an error's list of legs.");
|
|
57
|
+
const refresh = z
|
|
58
|
+
.boolean()
|
|
59
|
+
.describe("true skips FlightByte's cache and asks upstream now (counts as a normal request). Leave unset unless the user needs a guaranteed fresh answer.");
|
|
60
|
+
const subscriptionId = z
|
|
61
|
+
.string()
|
|
62
|
+
.min(1)
|
|
63
|
+
.max(64)
|
|
64
|
+
.describe('Subscription id, e.g. "sub_7Kd91mQx0Zb2", as returned by create_subscription or list_subscriptions.');
|
|
65
|
+
const webhookUrl = z
|
|
66
|
+
.url({ protocol: /^https?$/ })
|
|
67
|
+
.max(2048)
|
|
68
|
+
.describe("The user's public http(s) endpoint that receives a signed POST for every change. Addresses that resolve to private, loopback or link-local ranges are refused. Never invent one.");
|
|
69
|
+
const events = z
|
|
70
|
+
.array(z.enum(CHANGE_TYPES))
|
|
71
|
+
.min(1)
|
|
72
|
+
.describe('Change types to deliver; omit to deliver all 23. Common picks: "flight.departed", "flight.arrived", "flight.cancelled", "flight.diverted", "departure.gate.changed", "arrival.baggage.changed", "delay.increased".');
|
|
73
|
+
const delayThresholdMinutes = z
|
|
74
|
+
.number()
|
|
75
|
+
.int()
|
|
76
|
+
.min(1)
|
|
77
|
+
.max(240)
|
|
78
|
+
.describe('Delay changes smaller than this many minutes are not delivered. 1 to 240; the default is 1.');
|
|
79
|
+
const deliveryHeaders = z
|
|
80
|
+
.record(z.string(), z.string())
|
|
81
|
+
.describe('Extra HTTP headers sent with every delivery, e.g. {"Authorization": "Bearer <receiver token>"}.');
|
|
82
|
+
const metadata = z
|
|
83
|
+
.record(z.string(), z.string())
|
|
84
|
+
.describe('String map echoed back in every webhook, e.g. {"bookingId": "BK-1042"}. Use it to tie deliveries to your own records.');
|
|
85
|
+
const flightSelectors = {
|
|
86
|
+
origin: { in: 'query', schema: origin.optional() },
|
|
87
|
+
destination: { in: 'query', schema: destination.optional() },
|
|
88
|
+
};
|
|
89
|
+
const flightKeyPath = {
|
|
90
|
+
carrier: { in: 'path', schema: carrier },
|
|
91
|
+
flightNumber: { in: 'path', schema: flightNumber },
|
|
92
|
+
date: { in: 'path', schema: date },
|
|
93
|
+
};
|
|
94
|
+
const idPath = { id: { in: 'path', schema: subscriptionId } };
|
|
95
|
+
const READ = { readOnlyHint: true, openWorldHint: true };
|
|
96
|
+
const READ_ACCOUNT = { readOnlyHint: true, openWorldHint: false };
|
|
97
|
+
const SNAPSHOT_SUMMARY = "Returns FlightByte's flight snapshot: `status.phase` to branch on (scheduled, boarding, departed, en_route, landed, arrived, cancelled, diverted, redirected, unknown) " +
|
|
98
|
+
'with `delayed`/`cancelled`/`diverted` flags; departure and arrival airports with terminal, gate and baggage carousel; scheduled, estimated and actual gate and runway times in UTC and local time ' +
|
|
99
|
+
'(`bestTimeUtc` is the best single time, `delayMinutes` the gate delay); aircraft, codeshares, the airline timeline, `cached`, and `alternates`: every leg this flight number operates that day.';
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
export const API_TOOLS = [
|
|
102
|
+
{
|
|
103
|
+
name: 'get_flight',
|
|
104
|
+
operationId: 'get-flight',
|
|
105
|
+
method: 'GET',
|
|
106
|
+
path: '/flights/{carrier}/{flightNumber}/{date}',
|
|
107
|
+
title: 'Get a flight',
|
|
108
|
+
resource: 'flight',
|
|
109
|
+
description: 'Look up the live status of one flight leg by airline code, flight number and local departure date, e.g. TK1848 on 2026-10-18 → carrier "TK", flightNumber "1848", date "2026-10-18". ' +
|
|
110
|
+
`${SNAPSHOT_SUMMARY} ` +
|
|
111
|
+
'Some numbers fly more than one leg a day (TK1 is SAW→AMS in the morning and IST→JFK later): without a selector the default leg is returned, so pass `origin` when the user named a departure airport. ' +
|
|
112
|
+
'For updates over time, create a subscription instead of calling this repeatedly.',
|
|
113
|
+
annotations: { ...READ, title: 'Get a flight' },
|
|
114
|
+
params: {
|
|
115
|
+
...flightKeyPath,
|
|
116
|
+
...flightSelectors,
|
|
117
|
+
flightId: { in: 'query', schema: flightId.optional() },
|
|
118
|
+
refresh: { in: 'query', schema: refresh.optional() },
|
|
119
|
+
},
|
|
120
|
+
notes: legNotes,
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
name: 'search_flights',
|
|
124
|
+
operationId: 'search-flights',
|
|
125
|
+
method: 'GET',
|
|
126
|
+
path: '/flights/search',
|
|
127
|
+
title: 'Search flights',
|
|
128
|
+
resource: 'flight',
|
|
129
|
+
description: 'The same lookup as get_flight (same parameters, same snapshot), sent as query parameters to /v1/flights/search. Use either one, not both. ' +
|
|
130
|
+
`${SNAPSHOT_SUMMARY} ` +
|
|
131
|
+
'When the flight number operates several legs that day, pass `origin` (and optionally `destination`) to pick one; without a selector the default leg is returned and every leg is listed under `alternates`.',
|
|
132
|
+
annotations: { ...READ, title: 'Search flights' },
|
|
133
|
+
params: {
|
|
134
|
+
carrier: { in: 'query', schema: carrier },
|
|
135
|
+
flightNumber: { in: 'query', schema: flightNumber },
|
|
136
|
+
date: { in: 'query', schema: date },
|
|
137
|
+
...flightSelectors,
|
|
138
|
+
flightId: { in: 'query', schema: flightId.optional() },
|
|
139
|
+
refresh: { in: 'query', schema: refresh.optional() },
|
|
140
|
+
},
|
|
141
|
+
notes: legNotes,
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
name: 'get_flight_timeline',
|
|
145
|
+
operationId: 'flight-timeline',
|
|
146
|
+
method: 'GET',
|
|
147
|
+
path: '/flights/{carrier}/{flightNumber}/{date}/timeline',
|
|
148
|
+
title: 'Flight timeline',
|
|
149
|
+
resource: 'flight',
|
|
150
|
+
description: "The airline's own change history for one flight leg, newest first, as `{flightKey, count, data}`; each entry has `occurredAt`, `title`, `source` and `events` in the airline's words " +
|
|
151
|
+
'(e.g. "Actual Gate Arrival changed to 13:35"). Use it for "what changed" or "why is it late" questions. get_flight already contains the same timeline, so call this only when the history alone is needed.',
|
|
152
|
+
annotations: { ...READ, title: 'Flight timeline' },
|
|
153
|
+
params: { ...flightKeyPath, ...flightSelectors },
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
name: 'create_subscription',
|
|
157
|
+
operationId: 'create-subscription',
|
|
158
|
+
method: 'POST',
|
|
159
|
+
path: '/subscriptions',
|
|
160
|
+
title: 'Create a subscription',
|
|
161
|
+
resource: 'flight',
|
|
162
|
+
description: 'Watch a flight: FlightByte polls it on an adaptive schedule and POSTs a signed webhook to `webhookUrl` for every change (status, gates, terminals, times, delays, departure, arrival, baggage carousel, cancellation, diversion, aircraft, route, codeshares) until the flight is over. ' +
|
|
163
|
+
"Each new subscription counts as a watched flight on the user's plan (Free: 3 per month) and starts outbound requests to the URL, so only call this when the user asked to watch or be notified about a specific flight and gave the webhook URL. " +
|
|
164
|
+
'If the flight number operates more than one leg that day the call is refused with the list of legs: pass `origin`. Flights up to seven days ahead are accepted and wait as `pending` until published. ' +
|
|
165
|
+
'The response includes the signing secret in full; this is the only time it is shown, so relay it to the user to store for webhook signature verification. Pass `Idempotency-Key` to make a retry return the same subscription.',
|
|
166
|
+
annotations: { title: 'Create a subscription', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
167
|
+
params: {
|
|
168
|
+
carrier: { in: 'body', schema: carrier },
|
|
169
|
+
flightNumber: { in: 'body', schema: flightNumber },
|
|
170
|
+
date: { in: 'body', schema: date },
|
|
171
|
+
origin: { in: 'body', schema: origin.optional() },
|
|
172
|
+
destination: { in: 'body', schema: destination.optional() },
|
|
173
|
+
flightId: { in: 'body', schema: flightId.optional() },
|
|
174
|
+
webhookUrl: { in: 'body', schema: webhookUrl },
|
|
175
|
+
events: { in: 'body', schema: events.optional() },
|
|
176
|
+
delayThresholdMinutes: { in: 'body', schema: delayThresholdMinutes.optional() },
|
|
177
|
+
headers: { in: 'body', schema: deliveryHeaders.optional() },
|
|
178
|
+
metadata: { in: 'body', schema: metadata.optional() },
|
|
179
|
+
secret: {
|
|
180
|
+
in: 'body',
|
|
181
|
+
schema: z
|
|
182
|
+
.string()
|
|
183
|
+
.min(1)
|
|
184
|
+
.max(200)
|
|
185
|
+
.optional()
|
|
186
|
+
.describe("Bring your own signing secret, only when the user explicitly provides one their receiver already uses. Normally omit it: FlightByte generates one and returns it once."),
|
|
187
|
+
},
|
|
188
|
+
'Idempotency-Key': {
|
|
189
|
+
in: 'header',
|
|
190
|
+
schema: z
|
|
191
|
+
.string()
|
|
192
|
+
.min(1)
|
|
193
|
+
.max(255)
|
|
194
|
+
.optional()
|
|
195
|
+
.describe('Any unique string (e.g. a UUID). Replaying the same key within 24 hours returns the original subscription instead of creating a second one.'),
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
notes: (data) => {
|
|
199
|
+
const sub = asRecord(data);
|
|
200
|
+
const lines = [
|
|
201
|
+
'Subscription created.',
|
|
202
|
+
'IMPORTANT: `secret` below is the webhook signing secret, shown in full only in this response; later reads show only `secretHint`. ' +
|
|
203
|
+
'Give it to the user now so they can store it on their webhook receiver (the docs call it FLIGHTBYTE_WEBHOOK_SECRET) and verify the HMAC-SHA256 signature on every delivery. ' +
|
|
204
|
+
'This MCP server cannot reveal or rotate it later; that is done in the FlightByte dashboard or the API.',
|
|
205
|
+
];
|
|
206
|
+
if (sub?.status === 'pending') {
|
|
207
|
+
lines.push('Status is `pending`: the flight is not published upstream yet. It activates by itself and the receiver gets `subscription.activated` when it does.');
|
|
208
|
+
}
|
|
209
|
+
lines.push('Tip: send_test_event sends a signed test webhook to check the receiver now.');
|
|
210
|
+
return lines;
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
name: 'list_subscriptions',
|
|
215
|
+
operationId: 'list-subscriptions',
|
|
216
|
+
method: 'GET',
|
|
217
|
+
path: '/subscriptions',
|
|
218
|
+
title: 'List subscriptions',
|
|
219
|
+
resource: 'subscription',
|
|
220
|
+
description: 'List the subscriptions owned by this API key, newest first, as `{total, data}`. All filters are optional; page with `limit` and `offset`. ' +
|
|
221
|
+
'Secrets are masked (`secretHint`). Use it to find a subscription id before get, update, pause, resume, delete or log calls.',
|
|
222
|
+
annotations: { ...READ_ACCOUNT, title: 'List subscriptions' },
|
|
223
|
+
params: {
|
|
224
|
+
status: {
|
|
225
|
+
in: 'query',
|
|
226
|
+
schema: z
|
|
227
|
+
.enum(SUBSCRIPTION_STATUSES)
|
|
228
|
+
.optional()
|
|
229
|
+
.describe('Only subscriptions in this state: active (being polled), pending (flight not published yet), paused, completed (flight over) or suspended (20 failed deliveries in a row).'),
|
|
230
|
+
},
|
|
231
|
+
carrier: { in: 'query', schema: carrier.optional() },
|
|
232
|
+
flightNumber: { in: 'query', schema: flightNumber.optional() },
|
|
233
|
+
date: { in: 'query', schema: date.optional() },
|
|
234
|
+
offset: { in: 'query', schema: z.number().int().min(0).optional().describe('How many to skip, for paging. Default 0.') },
|
|
235
|
+
limit: { in: 'query', schema: z.number().int().min(1).max(200).optional().describe('Page size, 1 to 200. Default 25.') },
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
name: 'get_subscription',
|
|
240
|
+
operationId: 'get-subscription',
|
|
241
|
+
method: 'GET',
|
|
242
|
+
path: '/subscriptions/{id}',
|
|
243
|
+
title: 'Get a subscription',
|
|
244
|
+
resource: 'subscription',
|
|
245
|
+
description: 'Fetch one subscription: `status` (active, pending, paused, completed, suspended) and `statusReason`, `flightKey`, `flightResolved`, webhook URL, event filter, delay threshold, ' +
|
|
246
|
+
'`deliveredCount`/`failedCount`, and the last poll, change and delivery times. The secret is masked.',
|
|
247
|
+
annotations: { ...READ_ACCOUNT, title: 'Get a subscription' },
|
|
248
|
+
params: { ...idPath },
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
name: 'update_subscription',
|
|
252
|
+
operationId: 'update-subscription',
|
|
253
|
+
method: 'PATCH',
|
|
254
|
+
path: '/subscriptions/{id}',
|
|
255
|
+
title: 'Update a subscription',
|
|
256
|
+
resource: 'subscription',
|
|
257
|
+
description: "Change a subscription's `webhookUrl`, `events` filter, `delayThresholdMinutes`, `headers` or `metadata`. Send only the fields to change; `headers` and `metadata` replace the stored maps. " +
|
|
258
|
+
'The flight cannot be changed: create a new subscription for a different flight.',
|
|
259
|
+
annotations: { title: 'Update a subscription', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
260
|
+
params: {
|
|
261
|
+
...idPath,
|
|
262
|
+
webhookUrl: { in: 'body', schema: webhookUrl.optional() },
|
|
263
|
+
events: { in: 'body', schema: events.optional() },
|
|
264
|
+
delayThresholdMinutes: { in: 'body', schema: delayThresholdMinutes.optional() },
|
|
265
|
+
headers: { in: 'body', schema: deliveryHeaders.optional() },
|
|
266
|
+
metadata: { in: 'body', schema: metadata.optional() },
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
name: 'pause_subscription',
|
|
271
|
+
operationId: 'pause-subscription',
|
|
272
|
+
method: 'POST',
|
|
273
|
+
path: '/subscriptions/{id}/pause',
|
|
274
|
+
title: 'Pause a subscription',
|
|
275
|
+
resource: 'subscription',
|
|
276
|
+
description: 'Stop polling and webhook deliveries for a subscription without deleting it. Nothing is lost; resume_subscription continues it. Returns the updated subscription.',
|
|
277
|
+
annotations: { title: 'Pause a subscription', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
278
|
+
params: { ...idPath },
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
name: 'resume_subscription',
|
|
282
|
+
operationId: 'resume-subscription',
|
|
283
|
+
method: 'POST',
|
|
284
|
+
path: '/subscriptions/{id}/resume',
|
|
285
|
+
title: 'Resume a subscription',
|
|
286
|
+
resource: 'subscription',
|
|
287
|
+
description: 'Resume a paused subscription, or one that was suspended after 20 consecutive failed deliveries (fix the receiver first; send_test_event checks it). ' +
|
|
288
|
+
'A completed subscription cannot be resumed: create a new one. Returns the updated subscription.',
|
|
289
|
+
annotations: { title: 'Resume a subscription', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
290
|
+
params: { ...idPath },
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
name: 'delete_subscription',
|
|
294
|
+
operationId: 'delete-subscription',
|
|
295
|
+
method: 'DELETE',
|
|
296
|
+
path: '/subscriptions/{id}',
|
|
297
|
+
title: 'Delete a subscription',
|
|
298
|
+
resource: 'subscription',
|
|
299
|
+
description: 'Permanently delete a subscription: its polling and webhook deliveries stop and it cannot be restored. Confirm with the user first, and prefer pause_subscription if they may want it back. ' +
|
|
300
|
+
"Events and deliveries already recorded stay on FlightByte's side for 30 days.",
|
|
301
|
+
annotations: { title: 'Delete a subscription', readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
302
|
+
params: { ...idPath },
|
|
303
|
+
emptyText: (args) => `Subscription ${String(args.id)} deleted. Its polling and deliveries have stopped; recorded events and deliveries stay readable for 30 days.`,
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
name: 'send_test_event',
|
|
307
|
+
operationId: 'send-test-event',
|
|
308
|
+
method: 'POST',
|
|
309
|
+
path: '/subscriptions/{id}/test',
|
|
310
|
+
title: 'Send a test event',
|
|
311
|
+
resource: 'subscription',
|
|
312
|
+
description: "Send a signed `subscription.test` webhook through the real delivery pipeline to the subscription's webhookUrl, signed with its real secret, and return the `eventId`. " +
|
|
313
|
+
'Use it to prove a receiver and its signature check work before the first real change, then call list_deliveries to see how the delivery went.',
|
|
314
|
+
annotations: { title: 'Send a test event', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
315
|
+
params: { ...idPath },
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
name: 'list_deliveries',
|
|
319
|
+
operationId: 'list-deliveries',
|
|
320
|
+
method: 'GET',
|
|
321
|
+
path: '/subscriptions/{id}/deliveries',
|
|
322
|
+
title: 'List deliveries',
|
|
323
|
+
resource: 'subscription',
|
|
324
|
+
description: 'Recent webhook delivery attempts for a subscription, newest first: `eventId`, `attempt`, `status`, `httpStatus`, `error`, `durationMs`, `at` and `changeTypes`. ' +
|
|
325
|
+
'Use it to debug a receiver that is not getting webhooks or is answering with errors. Records are kept for 30 days.',
|
|
326
|
+
annotations: { ...READ_ACCOUNT, title: 'List deliveries' },
|
|
327
|
+
params: {
|
|
328
|
+
...idPath,
|
|
329
|
+
limit: { in: 'query', schema: z.number().int().min(1).max(100).optional().describe('How many attempts, 1 to 100. Default 25.') },
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
name: 'list_events',
|
|
334
|
+
operationId: 'list-events',
|
|
335
|
+
method: 'GET',
|
|
336
|
+
path: '/subscriptions/{id}/events',
|
|
337
|
+
title: 'List sent events',
|
|
338
|
+
resource: 'subscription',
|
|
339
|
+
description: 'Recent events generated for a subscription, newest first, with the exact webhook payloads that were sent (the list of changes and the flight snapshot at the time). ' +
|
|
340
|
+
'Use it to see what changed and when, or to replay a payload into a local receiver.',
|
|
341
|
+
annotations: { ...READ_ACCOUNT, title: 'List sent events' },
|
|
342
|
+
params: {
|
|
343
|
+
...idPath,
|
|
344
|
+
limit: { in: 'query', schema: z.number().int().min(1).max(100).optional().describe('How many events, 1 to 100. Default 25.') },
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
];
|
|
348
|
+
/** Codes the API upper-cases anyway; sending them normalised keeps cache keys and logs tidy. */
|
|
349
|
+
const UPPERCASE = new Set(['carrier', 'flightNumber', 'origin', 'destination']);
|
|
350
|
+
export function buildRequest(def, args) {
|
|
351
|
+
let path = def.path;
|
|
352
|
+
const query = {};
|
|
353
|
+
const headers = {};
|
|
354
|
+
const body = {};
|
|
355
|
+
for (const [name, param] of Object.entries(def.params)) {
|
|
356
|
+
let value = args[name];
|
|
357
|
+
if (value === undefined || value === null)
|
|
358
|
+
continue;
|
|
359
|
+
if (typeof value === 'string') {
|
|
360
|
+
value = value.trim();
|
|
361
|
+
if (UPPERCASE.has(name))
|
|
362
|
+
value = value.toUpperCase();
|
|
363
|
+
}
|
|
364
|
+
switch (param.in) {
|
|
365
|
+
case 'path':
|
|
366
|
+
path = path.replace(`{${name}}`, encodeURIComponent(String(value)));
|
|
367
|
+
break;
|
|
368
|
+
case 'query':
|
|
369
|
+
query[name] = String(value);
|
|
370
|
+
break;
|
|
371
|
+
case 'header':
|
|
372
|
+
headers[name] = String(value);
|
|
373
|
+
break;
|
|
374
|
+
case 'body':
|
|
375
|
+
body[name] = value;
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
const request = { method: def.method, path, query, headers };
|
|
380
|
+
if (Object.values(def.params).some((p) => p.in === 'body'))
|
|
381
|
+
request.body = body;
|
|
382
|
+
return request;
|
|
383
|
+
}
|
|
384
|
+
export function inputSchema(def) {
|
|
385
|
+
return z.object(Object.fromEntries(Object.entries(def.params).map(([name, p]) => [name, p.schema])));
|
|
386
|
+
}
|
|
387
|
+
export async function runApiTool(def, client, args) {
|
|
388
|
+
if (!client.hasKey)
|
|
389
|
+
return missingKeyError(def.name);
|
|
390
|
+
if (def.name === 'update_subscription') {
|
|
391
|
+
const fields = Object.entries(def.params).filter(([name, p]) => p.in === 'body' && args[name] !== undefined);
|
|
392
|
+
if (fields.length === 0) {
|
|
393
|
+
return {
|
|
394
|
+
isError: true,
|
|
395
|
+
content: [
|
|
396
|
+
{ type: 'text', text: 'Nothing to update: pass at least one of webhookUrl, events, delayThresholdMinutes, headers or metadata.' },
|
|
397
|
+
],
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
const result = await client.request(buildRequest(def, args));
|
|
402
|
+
if (result.kind !== 'ok')
|
|
403
|
+
return apiErrorResult(result, { tool: def.name, resource: def.resource });
|
|
404
|
+
if (result.data === undefined) {
|
|
405
|
+
const text = def.emptyText?.(args) ?? `Done (HTTP ${result.status}, no response body).`;
|
|
406
|
+
return { content: [{ type: 'text', text }] };
|
|
407
|
+
}
|
|
408
|
+
const content = [];
|
|
409
|
+
const notes = def.notes?.(result.data, args) ?? [];
|
|
410
|
+
if (notes.length > 0)
|
|
411
|
+
content.push({ type: 'text', text: notes.join('\n') });
|
|
412
|
+
content.push({ type: 'text', text: JSON.stringify(result.data) });
|
|
413
|
+
const out = { content };
|
|
414
|
+
const record = asRecord(result.data);
|
|
415
|
+
if (record)
|
|
416
|
+
out.structuredContent = record;
|
|
417
|
+
return out;
|
|
418
|
+
}
|
|
419
|
+
function legNotes(data, args) {
|
|
420
|
+
const snapshot = asRecord(data);
|
|
421
|
+
const alternates = Array.isArray(snapshot?.alternates) ? snapshot.alternates : [];
|
|
422
|
+
if (alternates.length < 2)
|
|
423
|
+
return [];
|
|
424
|
+
const legs = alternates
|
|
425
|
+
.map((leg) => `${String(leg.departureIata)}→${String(leg.arrivalIata)}${leg.selected ? ' (this snapshot)' : ''}`)
|
|
426
|
+
.join(', ');
|
|
427
|
+
const selected = args.origin || args.destination || args.flightId;
|
|
428
|
+
return [
|
|
429
|
+
`This flight number operates ${alternates.length} legs on ${String(snapshot?.date ?? args.date)}: ${legs}. ` +
|
|
430
|
+
(selected
|
|
431
|
+
? 'The snapshot below is the leg matching the selector.'
|
|
432
|
+
: 'No selector was given, so this is the default leg; if the user meant another one, call again with `origin`.'),
|
|
433
|
+
];
|
|
434
|
+
}
|
|
435
|
+
function asRecord(value) {
|
|
436
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : undefined;
|
|
437
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { toolError } from '../errors.js';
|
|
3
|
+
/** Path and URL parts that say nothing about which page was meant. */
|
|
4
|
+
const ROOT_WORDS = new Set(['docs', 'guides', 'glossary', 'reference', 'md', 'http', 'https', 'www', 'flightbyte', 'dev']);
|
|
5
|
+
export function registerDocsTools(server, docs) {
|
|
6
|
+
server.registerTool('search_docs', {
|
|
7
|
+
title: 'Search the FlightByte docs',
|
|
8
|
+
description: 'Keyword search over the whole FlightByte documentation: the docs, the API reference, the engineering guides and the glossary. ' +
|
|
9
|
+
'Returns the best-matching sections with title, URL and a snippet. Use it before answering questions about the API (authentication, quotas, errors, flight lookups, leg selection, ' +
|
|
10
|
+
'subscriptions, webhook payloads and change types, signature verification, retries) or before writing integration code, then read the full page with get_doc_page. Works without an API key.',
|
|
11
|
+
inputSchema: z.object({
|
|
12
|
+
query: z
|
|
13
|
+
.string()
|
|
14
|
+
.min(1)
|
|
15
|
+
.max(300)
|
|
16
|
+
.describe('Keywords, e.g. "verify webhook signature", "429 retry", "flight number two legs origin", "baggage carousel event".'),
|
|
17
|
+
limit: z.number().int().min(1).max(10).optional().describe('How many sections to return, 1 to 10. Default 5.'),
|
|
18
|
+
}),
|
|
19
|
+
annotations: { title: 'Search the FlightByte docs', readOnlyHint: true, idempotentHint: true, openWorldHint: true },
|
|
20
|
+
}, async ({ query, limit }) => {
|
|
21
|
+
let hits;
|
|
22
|
+
try {
|
|
23
|
+
hits = await docs.search(query, limit ?? 5);
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
return toolError(`The docs could not be loaded (${err instanceof Error ? err.message : String(err)}). Retry shortly; the same content is at ${docs.docsUrl}/llms-full.txt.`);
|
|
27
|
+
}
|
|
28
|
+
if (hits.length === 0) {
|
|
29
|
+
return {
|
|
30
|
+
content: [
|
|
31
|
+
{
|
|
32
|
+
type: 'text',
|
|
33
|
+
text: `No docs section matched "${query}". Try fewer or different keywords, or open the index with get_doc_page("/docs").`,
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
structuredContent: { query, results: [] },
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const text = [
|
|
40
|
+
`Top ${hits.length} docs sections for "${query}":`,
|
|
41
|
+
'',
|
|
42
|
+
...hits.flatMap((hit, i) => [`${i + 1}. ${hit.title}`, ` ${hit.url}`, ` ${hit.snippet}`, '']),
|
|
43
|
+
'Read a full page with get_doc_page, passing its URL.',
|
|
44
|
+
].join('\n');
|
|
45
|
+
return {
|
|
46
|
+
content: [{ type: 'text', text }],
|
|
47
|
+
structuredContent: { query, results: hits.map(({ title, url, snippet }) => ({ title, url, snippet })) },
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
server.registerTool('get_doc_page', {
|
|
51
|
+
title: 'Read a FlightByte docs page',
|
|
52
|
+
description: 'Fetch one FlightByte documentation page as Markdown. Accepts a URL from search_docs (anchors are ignored) or a path such as "/docs/webhooks", "/docs/reference/subscriptions/create-subscription", ' +
|
|
53
|
+
'"/guides/verifying-webhook-signatures" or "/glossary/flight-phase". "/docs" is the introduction, with links to every page. Works without an API key.',
|
|
54
|
+
inputSchema: z.object({
|
|
55
|
+
path: z
|
|
56
|
+
.string()
|
|
57
|
+
.min(1)
|
|
58
|
+
.max(500)
|
|
59
|
+
.describe('Docs path or URL, e.g. "/docs/errors" or "https://flightbyte.dev/docs/webhooks#verifying-signatures".'),
|
|
60
|
+
}),
|
|
61
|
+
annotations: { title: 'Read a FlightByte docs page', readOnlyHint: true, idempotentHint: true, openWorldHint: true },
|
|
62
|
+
}, async ({ path }) => {
|
|
63
|
+
const page = await docs.page(path);
|
|
64
|
+
if (page.ok)
|
|
65
|
+
return { content: [{ type: 'text', text: page.markdown }] };
|
|
66
|
+
if (page.status === 'not_found') {
|
|
67
|
+
let suggestions = '';
|
|
68
|
+
try {
|
|
69
|
+
const words = path
|
|
70
|
+
.split(/[/#?.:-]+/)
|
|
71
|
+
.filter((w) => w && !ROOT_WORDS.has(w.toLowerCase()))
|
|
72
|
+
.join(' ');
|
|
73
|
+
const hits = words ? await docs.search(words, 3) : [];
|
|
74
|
+
if (hits.length > 0)
|
|
75
|
+
suggestions = `\nClosest pages: ${hits.map((h) => h.url.split('#')[0]).filter((u, i, a) => a.indexOf(u) === i).join(', ')}`;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// Suggestions are a courtesy; the error stands without them.
|
|
79
|
+
}
|
|
80
|
+
return toolError(`${page.message} Use search_docs to find the right page, or get_doc_page("/docs") for the index.${suggestions}`);
|
|
81
|
+
}
|
|
82
|
+
return toolError(page.message);
|
|
83
|
+
});
|
|
84
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,73 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flightbyte/mcp",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server for FlightByte, the flight status API with signed webhooks: look flights up, watch them with webhook subscriptions, and search the docs from any AI assistant.",
|
|
5
|
+
"mcpName": "dev.flightbyte/mcp",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "FlightByte (https://flightbyte.dev)",
|
|
8
|
+
"homepage": "https://flightbyte.dev/docs/ai",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/flightbyte-dev/flightbyte-mcp.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/flightbyte-dev/flightbyte-mcp/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"mcp",
|
|
18
|
+
"model-context-protocol",
|
|
19
|
+
"mcp-server",
|
|
20
|
+
"flightbyte",
|
|
21
|
+
"flight-status",
|
|
22
|
+
"flight-tracking",
|
|
23
|
+
"flights",
|
|
24
|
+
"webhooks",
|
|
25
|
+
"aviation",
|
|
26
|
+
"ai"
|
|
27
|
+
],
|
|
28
|
+
"type": "module",
|
|
29
|
+
"bin": {
|
|
30
|
+
"flightbyte-mcp": "dist/stdio.js"
|
|
31
|
+
},
|
|
32
|
+
"exports": {
|
|
33
|
+
".": "./dist/server.js"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE",
|
|
39
|
+
"server.json"
|
|
40
|
+
],
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=20"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"build": "tsc -p tsconfig.build.json",
|
|
49
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
50
|
+
"lint": "eslint .",
|
|
51
|
+
"test": "npm run build && vitest run",
|
|
52
|
+
"test:watch": "vitest",
|
|
53
|
+
"check": "npm run typecheck && npm run lint && npm test",
|
|
54
|
+
"start": "node dist/stdio.js",
|
|
55
|
+
"start:http": "node dist/http.js",
|
|
56
|
+
"sync:openapi": "node scripts/sync-openapi.mjs",
|
|
57
|
+
"prepublishOnly": "npm run check"
|
|
58
|
+
},
|
|
59
|
+
"dependencies": {
|
|
60
|
+
"@modelcontextprotocol/server": "^2.3.1",
|
|
61
|
+
"zod": "^4.6.7"
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"@eslint/js": "^10.0.1",
|
|
65
|
+
"@modelcontextprotocol/client": "^2.3.1",
|
|
66
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
67
|
+
"@types/node": "^22.20.5",
|
|
68
|
+
"eslint": "^10.13.0",
|
|
69
|
+
"typescript": "~6.0.3",
|
|
70
|
+
"typescript-eslint": "^8.71.2",
|
|
71
|
+
"vitest": "^5.0.5"
|
|
72
|
+
}
|
|
73
|
+
}
|
package/server.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "dev.flightbyte/mcp",
|
|
4
|
+
"title": "FlightByte",
|
|
5
|
+
"description": "Live flight status, flight watches with signed webhooks, and FlightByte docs search.",
|
|
6
|
+
"version": "1.0.0",
|
|
7
|
+
"websiteUrl": "https://flightbyte.dev/docs/ai",
|
|
8
|
+
"repository": {
|
|
9
|
+
"url": "https://github.com/flightbyte-dev/flightbyte-mcp",
|
|
10
|
+
"source": "github"
|
|
11
|
+
},
|
|
12
|
+
"packages": [
|
|
13
|
+
{
|
|
14
|
+
"registryType": "npm",
|
|
15
|
+
"registryBaseUrl": "https://registry.npmjs.org",
|
|
16
|
+
"identifier": "@flightbyte/mcp",
|
|
17
|
+
"version": "1.0.0",
|
|
18
|
+
"runtimeHint": "npx",
|
|
19
|
+
"transport": { "type": "stdio" },
|
|
20
|
+
"environmentVariables": [
|
|
21
|
+
{
|
|
22
|
+
"name": "FLIGHTBYTE_KEY",
|
|
23
|
+
"description": "Your FlightByte API key (free at https://app.flightbyte.dev/signup). The docs tools work without it.",
|
|
24
|
+
"isRequired": true,
|
|
25
|
+
"isSecret": true,
|
|
26
|
+
"format": "string"
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
],
|
|
31
|
+
"remotes": [
|
|
32
|
+
{
|
|
33
|
+
"type": "streamable-http",
|
|
34
|
+
"url": "https://mcp.flightbyte.dev/mcp",
|
|
35
|
+
"headers": [
|
|
36
|
+
{
|
|
37
|
+
"name": "Authorization",
|
|
38
|
+
"description": "Your FlightByte API key as a bearer token: Bearer <key>. The docs tools work without it.",
|
|
39
|
+
"value": "Bearer {flightbyte_key}",
|
|
40
|
+
"isRequired": true,
|
|
41
|
+
"isSecret": true,
|
|
42
|
+
"variables": {
|
|
43
|
+
"flightbyte_key": {
|
|
44
|
+
"description": "Your FlightByte API key (free at https://app.flightbyte.dev/signup)",
|
|
45
|
+
"isRequired": true,
|
|
46
|
+
"isSecret": true
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
}
|