@pipeworx/mcp-aviationstack 0.1.2 → 0.1.4
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 +20 -9
- package/package.json +3 -3
- package/server.json +1 -1
- package/src/index.ts +406 -0
- package/src/server.ts +5 -2
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @pipeworx/aviationstack
|
|
2
2
|
|
|
3
|
-
Aviationstack MCP — global flight tracking + airport / airline / route reference data.
|
|
3
|
+
Aviationstack MCP — global flight tracking + airport / airline / route reference data.
|
|
4
4
|
|
|
5
|
-
Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to
|
|
5
|
+
Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 1847+ live data sources. This is an independent, unofficial integration — not affiliated with, endorsed by, or published by the upstream provider.
|
|
6
6
|
|
|
7
7
|
## Tools
|
|
8
8
|
|
|
@@ -12,19 +12,29 @@ Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents
|
|
|
12
12
|
- `cities(search?, iata_code?, country_iso2?, limit?)` — city → primary airport mapping
|
|
13
13
|
- `countries(search?, country_iso2?, limit?)` — country reference
|
|
14
14
|
- `routes(dep_iata?, arr_iata?, airline_iata?, flight_number?, limit?)` — scheduled routes
|
|
15
|
+
- `aviationstack_future_flights(iataCode, type, date, airline_iata?, airline_icao?, flight_number?, limit?, offset?)` — scheduled future flights at one airport for a specific date (`type`: `departure` or `arrival`). Backed by Aviationstack's `/flightsFuture` endpoint, which apilayer's 2026-10-06 notice opened only for the **next 7 days** — `date` must be `tomorrow..today+7` (UTC); the pack refuses out-of-window dates itself before calling upstream (fleet #2700).
|
|
15
16
|
|
|
16
17
|
## Auth
|
|
17
18
|
|
|
18
19
|
- **Platform key:** gateway env `PLATFORM_AVIATIONSTACK_KEY`
|
|
19
20
|
- **BYO:** `?_apiKey=<key>` after registering at https://aviationstack.com/signup/free
|
|
20
21
|
|
|
21
|
-
###
|
|
22
|
+
### Plan tier
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
As of 2026-10-06 the shared platform key is on a plan that reaches `flights`, `airports`,
|
|
25
|
+
`airlines`, `cities`, `countries` and `routes` live (verified: `airports`/JFK and
|
|
26
|
+
`routes`/JFK→LAX both returned non-empty data — neither is free-tier-only any more; an
|
|
27
|
+
earlier version of this README, written 2026-08-28, said otherwise and was stale).
|
|
28
|
+
`aviationstack_future_flights` was added the same day the vendor emailed that they opened
|
|
29
|
+
`/flightsFuture` more broadly; whether our specific plan reaches *that* endpoint was not
|
|
30
|
+
independently confirmed before this tool shipped (the platform key's raw value is a
|
|
31
|
+
Cloudflare/Supabase secret, not available to a pack-authoring session) — if a live call
|
|
32
|
+
answers HTTP 403 `function_access_restricted`, that means the plan doesn't cover this
|
|
33
|
+
endpoint yet and is a money/plan decision, not a pack bug.
|
|
24
34
|
|
|
25
|
-
The free plan
|
|
26
|
-
endpoint answers 429 — including
|
|
27
|
-
|
|
35
|
+
The free plan (if the platform key ever reverts to it) is capped at **100 requests per
|
|
36
|
+
month**, and once that is spent every endpoint answers 429 — including ones the tier would
|
|
37
|
+
otherwise allow. Free plan is also HTTP-only (no https) — the pack already uses `http://`.
|
|
28
38
|
|
|
29
39
|
**Reading a 429 here:** Aviationstack labels the monthly-allowance refusal with the code
|
|
30
40
|
`rate_limit_reached` and only says "monthly" in the message text, so the code name argues
|
|
@@ -33,7 +43,8 @@ renews. The pack branches on the message, not the code. Fleet #577.
|
|
|
33
43
|
|
|
34
44
|
## Data source
|
|
35
45
|
|
|
36
|
-
`http://api.aviationstack.com/v1/` — `access_key` query param.
|
|
46
|
+
`http://api.aviationstack.com/v1/` — `access_key` query param. `/flightsFuture` spec:
|
|
47
|
+
https://api.swaggerhub.com/apis/apilayer-863/AviationstackAPI/1.0.0/swagger.json
|
|
37
48
|
|
|
38
49
|
## Quick Start
|
|
39
50
|
|
|
@@ -79,7 +90,7 @@ directly, instead of just this one's:
|
|
|
79
90
|
}
|
|
80
91
|
```
|
|
81
92
|
|
|
82
|
-
Both URLs reach the same gateway and the same
|
|
93
|
+
Both URLs reach the same gateway and the same 1847+ data sources. The
|
|
83
94
|
only difference is which pack's tools are listed **directly**; `ask_pipeworx`
|
|
84
95
|
reaches all of them from either one.
|
|
85
96
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pipeworx/mcp-aviationstack",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Aviationstack MCP — global flight + airport + airline data",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"@cloudflare/workers-types": "^4.20260405.1"
|
|
27
27
|
},
|
|
28
28
|
"pipeworx": {
|
|
29
|
-
"sourceHash": "v1-
|
|
30
|
-
"sourceCommit": "
|
|
29
|
+
"sourceHash": "v1-eec8b23dd927b0fad41b15aba49607560f3a3da5c7fbcab4175b92ef7d403733",
|
|
30
|
+
"sourceCommit": "54bf7d1c06a0775508a65d0c388dd10ba75174c3"
|
|
31
31
|
}
|
|
32
32
|
}
|
package/server.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "io.github.pipeworx-io/aviationstack",
|
|
4
4
|
"title": "Aviationstack",
|
|
5
5
|
"description": "Aviationstack MCP — global flight + airport + airline data",
|
|
6
|
-
"version": "0.1.
|
|
6
|
+
"version": "0.1.4",
|
|
7
7
|
"websiteUrl": "https://pipeworx.io/packs/aviationstack",
|
|
8
8
|
"repository": {
|
|
9
9
|
"url": "https://github.com/pipeworx-io/mcp-aviationstack",
|
package/src/index.ts
CHANGED
|
@@ -670,6 +670,254 @@ function pickMessage(node: unknown, depth: number): string | null {
|
|
|
670
670
|
function collapse(s: string): string {
|
|
671
671
|
return s.replace(/\s+/g, ' ').trim();
|
|
672
672
|
}
|
|
673
|
+
|
|
674
|
+
// ── Bounded readers — peak memory, not just wall time (fleet #3322) ──────
|
|
675
|
+
//
|
|
676
|
+
// `parseJson` above does `await res.text()` then `JSON.parse(raw)`, so a
|
|
677
|
+
// multi-megabyte upstream body is held TWICE at peak: once as the raw string
|
|
678
|
+
// and once as the object graph (which for an array of small objects runs
|
|
679
|
+
// several times the byte size). Nothing in `workers/gateway` can see that
|
|
680
|
+
// peak — it happens inside the pack, before any gateway code holds a payload —
|
|
681
|
+
// which is why the gateway-side payload caps of #3293 and #3320 did not move
|
|
682
|
+
// the 503/1102 rate at all.
|
|
683
|
+
//
|
|
684
|
+
// Measured on the live gateway, 2026-10-19, sha fa5de5c34, direct tools/call:
|
|
685
|
+
// statcan_list_cubes 8,049,566 B <- one upstream array, no limit argument
|
|
686
|
+
// gnomad_gene BRCA1 2,732,706 B
|
|
687
|
+
// Five of those in flight at the quality runner's width is ~40 MB of parse
|
|
688
|
+
// peak against a 128 MB isolate; Cloudflare kills the isolate and every
|
|
689
|
+
// request in flight returns 503/1102, which is why the victims look random.
|
|
690
|
+
//
|
|
691
|
+
// `readJsonArrayPrefix` is the fix for the dominant shape: an upstream that
|
|
692
|
+
// returns one enormous top-level JSON array with no server-side paging. It
|
|
693
|
+
// parses elements off the stream and DISCARDS the consumed bytes, so peak
|
|
694
|
+
// retained memory is one element plus one chunk rather than the whole body,
|
|
695
|
+
// and it cancels the body as soon as it has what it was asked for.
|
|
696
|
+
|
|
697
|
+
/** Default ceiling for a single upstream JSON body read into an isolate.
|
|
698
|
+
* Chosen so the known tail (statcan 8.0 MB, gnomad 2.7 MB) is bounded well
|
|
699
|
+
* below the point where a handful of concurrent reads exhausts 128 MB. */
|
|
700
|
+
const DEFAULT_MAX_JSON_BYTES = 1_500_000;
|
|
701
|
+
|
|
702
|
+
interface JsonArrayPrefix<T> {
|
|
703
|
+
/** The decoded elements, at most `maxItems` of them. */
|
|
704
|
+
items: T[];
|
|
705
|
+
/** True when the upstream array had more elements than we read. */
|
|
706
|
+
truncated: boolean;
|
|
707
|
+
/** Bytes actually pulled off the socket — the number that bounds memory. */
|
|
708
|
+
bytesRead: number;
|
|
709
|
+
/** `Content-Length` if the upstream declared one, else null. */
|
|
710
|
+
declaredBytes: number | null;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Read the FIRST `maxItems` elements of a top-level JSON array off a response
|
|
715
|
+
* stream without ever materialising the whole body.
|
|
716
|
+
*
|
|
717
|
+
* Use this instead of `parseJson` when the upstream returns one big array and
|
|
718
|
+
* offers no `limit`/`offset` of its own. Peak retained memory is one element
|
|
719
|
+
* plus the current chunk; the body is cancelled as soon as `maxItems` is
|
|
720
|
+
* reached, so the remaining megabytes are never transferred at all.
|
|
721
|
+
*
|
|
722
|
+
* const { items, truncated } = await readJsonArrayPrefix<Cube>(
|
|
723
|
+
* res, 'StatCan WDS', { maxItems: limit + offset });
|
|
724
|
+
*
|
|
725
|
+
* `maxBytes` is a second, independent backstop for a pathological element (a
|
|
726
|
+
* single 50 MB object): hitting it stops the read and sets `truncated`, rather
|
|
727
|
+
* than throwing, because a partial answer plus an explicit truncated flag is
|
|
728
|
+
* more useful to a caller than an error.
|
|
729
|
+
*/
|
|
730
|
+
async function readJsonArrayPrefix<T>(
|
|
731
|
+
res: Response,
|
|
732
|
+
name: string,
|
|
733
|
+
opts: { maxItems: number; maxBytes?: number },
|
|
734
|
+
): Promise<JsonArrayPrefix<T>> {
|
|
735
|
+
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_JSON_BYTES;
|
|
736
|
+
const declaredRaw = Number(res.headers.get('content-length'));
|
|
737
|
+
const declaredBytes = Number.isFinite(declaredRaw) && declaredRaw > 0 ? declaredRaw : null;
|
|
738
|
+
|
|
739
|
+
// No streaming body available (a mock, or an already-buffered response):
|
|
740
|
+
// fall back to the whole-text path so behaviour stays correct, just unbounded.
|
|
741
|
+
if (!res.body) {
|
|
742
|
+
const all = await parseJson<T[]>(res, name);
|
|
743
|
+
if (!Array.isArray(all)) throw new Error(`upstream_down: ${name} returned ${typeof all} where a JSON array was expected.`);
|
|
744
|
+
return {
|
|
745
|
+
items: all.slice(0, opts.maxItems),
|
|
746
|
+
truncated: all.length > opts.maxItems,
|
|
747
|
+
bytesRead: declaredBytes ?? 0,
|
|
748
|
+
declaredBytes,
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const reader = res.body.getReader();
|
|
753
|
+
const decoder = new TextDecoder();
|
|
754
|
+
const items: T[] = [];
|
|
755
|
+
|
|
756
|
+
let buf = '';
|
|
757
|
+
let scan = 0;
|
|
758
|
+
let bytesRead = 0;
|
|
759
|
+
let started = false; // consumed the opening '['
|
|
760
|
+
let arrayEnded = false;
|
|
761
|
+
let depth = 0;
|
|
762
|
+
let inStr = false;
|
|
763
|
+
let esc = false;
|
|
764
|
+
let elemStart = -1;
|
|
765
|
+
let truncated = false;
|
|
766
|
+
|
|
767
|
+
const push = (text: string) => {
|
|
768
|
+
const t = text.trim();
|
|
769
|
+
if (!t) return;
|
|
770
|
+
items.push(JSON.parse(t) as T);
|
|
771
|
+
};
|
|
772
|
+
|
|
773
|
+
try {
|
|
774
|
+
outer: while (items.length < opts.maxItems && !arrayEnded) {
|
|
775
|
+
const { value, done } = await reader.read();
|
|
776
|
+
if (done) break;
|
|
777
|
+
bytesRead += value.byteLength;
|
|
778
|
+
buf += decoder.decode(value, { stream: true });
|
|
779
|
+
if (bytesRead > maxBytes) { truncated = true; break; }
|
|
780
|
+
|
|
781
|
+
for (; scan < buf.length; scan++) {
|
|
782
|
+
const ch = buf[scan];
|
|
783
|
+
|
|
784
|
+
if (inStr) {
|
|
785
|
+
if (esc) { esc = false; continue; }
|
|
786
|
+
if (ch === '\\') { esc = true; continue; }
|
|
787
|
+
if (ch === '"') inStr = false;
|
|
788
|
+
continue;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
if (!started) {
|
|
792
|
+
if (ch === '[') { started = true; continue; }
|
|
793
|
+
if (ch.trim() === '') continue;
|
|
794
|
+
throw new Error(
|
|
795
|
+
`upstream_down: ${name} returned a body that does not begin with a JSON array (saw ${JSON.stringify(ch)}). ` +
|
|
796
|
+
'This reader is only for upstreams that answer with one top-level array.',
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
if (ch === '"') { if (depth === 0 && elemStart < 0) elemStart = scan; inStr = true; continue; }
|
|
801
|
+
|
|
802
|
+
if (ch === '{' || ch === '[') {
|
|
803
|
+
if (depth === 0 && elemStart < 0) elemStart = scan;
|
|
804
|
+
depth++;
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
if (ch === '}' || ch === ']') {
|
|
809
|
+
if (depth > 0) {
|
|
810
|
+
depth--;
|
|
811
|
+
if (depth === 0 && elemStart >= 0) {
|
|
812
|
+
push(buf.slice(elemStart, scan + 1));
|
|
813
|
+
elemStart = -1;
|
|
814
|
+
if (items.length >= opts.maxItems) { truncated = true; scan++; break outer; }
|
|
815
|
+
}
|
|
816
|
+
continue;
|
|
817
|
+
}
|
|
818
|
+
// depth 0 and a closing bracket: the end of the top-level array.
|
|
819
|
+
if (elemStart >= 0) { push(buf.slice(elemStart, scan)); elemStart = -1; }
|
|
820
|
+
arrayEnded = true;
|
|
821
|
+
break outer;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
if (ch === ',' && depth === 0) {
|
|
825
|
+
if (elemStart >= 0) {
|
|
826
|
+
push(buf.slice(elemStart, scan));
|
|
827
|
+
elemStart = -1;
|
|
828
|
+
if (items.length >= opts.maxItems) { truncated = true; scan++; break outer; }
|
|
829
|
+
}
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// A bare scalar element (number / true / false / null).
|
|
834
|
+
if (depth === 0 && elemStart < 0 && ch.trim() !== '') elemStart = scan;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// Drop everything already consumed. THIS is what bounds memory: without
|
|
838
|
+
// it `buf` grows to the full body and the reader is streaming in name only.
|
|
839
|
+
if (elemStart >= 0) {
|
|
840
|
+
buf = buf.slice(elemStart);
|
|
841
|
+
scan -= elemStart;
|
|
842
|
+
elemStart = 0;
|
|
843
|
+
} else {
|
|
844
|
+
buf = buf.slice(scan);
|
|
845
|
+
scan = 0;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
} finally {
|
|
849
|
+
// Cancel rather than drain: the point is that the remaining megabytes are
|
|
850
|
+
// never transferred. A cancel on an already-finished body is a no-op.
|
|
851
|
+
await reader.cancel().catch(() => {});
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
return { items, truncated, bytesRead, declaredBytes };
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* `parseJson`, with a hard ceiling on how many bytes are read.
|
|
859
|
+
*
|
|
860
|
+
* For an upstream whose body is not a single array (so `readJsonArrayPrefix`
|
|
861
|
+
* does not apply) but which can still answer with megabytes. Unlike the array
|
|
862
|
+
* reader this one THROWS when the cap is hit, because a truncated prefix of an
|
|
863
|
+
* arbitrary JSON document is not parseable and there is no partial answer to
|
|
864
|
+
* hand back. A declared `Content-Length` over the cap is refused before a
|
|
865
|
+
* single chunk is read.
|
|
866
|
+
*/
|
|
867
|
+
async function parseJsonBounded<T>(
|
|
868
|
+
res: Response,
|
|
869
|
+
name: string,
|
|
870
|
+
maxBytes: number = DEFAULT_MAX_JSON_BYTES,
|
|
871
|
+
): Promise<T> {
|
|
872
|
+
const declaredRaw = Number(res.headers.get('content-length'));
|
|
873
|
+
const declared = Number.isFinite(declaredRaw) && declaredRaw > 0 ? declaredRaw : null;
|
|
874
|
+
|
|
875
|
+
const tooBig = (n: number) =>
|
|
876
|
+
new Error(
|
|
877
|
+
`upstream_down: ${name} answered with ${n.toLocaleString()} bytes, over this pack's ${maxBytes.toLocaleString()}-byte ceiling. ` +
|
|
878
|
+
'The body is not read past the ceiling on purpose — a few concurrent reads of a body that size exhaust the ' +
|
|
879
|
+
'Worker isolate and take every other in-flight request down with them. Narrow the request (a smaller range, ' +
|
|
880
|
+
'a specific id, or a lower limit) and retry.',
|
|
881
|
+
);
|
|
882
|
+
|
|
883
|
+
if (declared !== null && declared > maxBytes) {
|
|
884
|
+
await res.body?.cancel().catch(() => {});
|
|
885
|
+
throw tooBig(declared);
|
|
886
|
+
}
|
|
887
|
+
if (!res.body) return parseJson<T>(res, name);
|
|
888
|
+
|
|
889
|
+
const reader = res.body.getReader();
|
|
890
|
+
const decoder = new TextDecoder();
|
|
891
|
+
let raw = '';
|
|
892
|
+
let bytesRead = 0;
|
|
893
|
+
try {
|
|
894
|
+
for (;;) {
|
|
895
|
+
const { value, done } = await reader.read();
|
|
896
|
+
if (done) break;
|
|
897
|
+
bytesRead += value.byteLength;
|
|
898
|
+
if (bytesRead > maxBytes) throw tooBig(bytesRead);
|
|
899
|
+
raw += decoder.decode(value, { stream: true });
|
|
900
|
+
}
|
|
901
|
+
raw += decoder.decode();
|
|
902
|
+
} finally {
|
|
903
|
+
await reader.cancel().catch(() => {});
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
if (!raw.trim()) {
|
|
907
|
+
throw new Error(
|
|
908
|
+
`upstream_down: ${name} answered HTTP ${res.status} with an EMPTY body where JSON was expected. ` +
|
|
909
|
+
'Nothing about the request can cause this — it is an upstream fault, and the same call may well work on retry.',
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
try {
|
|
913
|
+
return JSON.parse(raw) as T;
|
|
914
|
+
} catch {
|
|
915
|
+
throw new Error(
|
|
916
|
+
`upstream_down: ${name} answered HTTP ${res.status} with a body that is not valid JSON. ` +
|
|
917
|
+
`It begins: ${stripMarkup(raw).slice(0, 120) || '(unreadable)'}`,
|
|
918
|
+
);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
673
921
|
/**
|
|
674
922
|
* Aviationstack MCP — global flight + airport + airline data
|
|
675
923
|
*
|
|
@@ -777,6 +1025,109 @@ const tools: McpToolExport['tools'] = [
|
|
|
777
1025
|
},
|
|
778
1026
|
},
|
|
779
1027
|
},
|
|
1028
|
+
{
|
|
1029
|
+
// apilayer emailed developer@mojibake.ai on 2026-10-06 ("What the new
|
|
1030
|
+
// /flightsFuture range changes for your app"): they opened this endpoint
|
|
1031
|
+
// to cover the next 7 days for itinerary/booking views. Vendor spec:
|
|
1032
|
+
// https://api.swaggerhub.com/apis/apilayer-863/AviationstackAPI/1.0.0/swagger.json
|
|
1033
|
+
// (paths./v1/flightsFuture — iataCode + type + date all required; date
|
|
1034
|
+
// format YYYY-MM-DD). The 7-day window is the vendor's own notice, not a
|
|
1035
|
+
// field the swagger spec encodes, so it is enforced here rather than left
|
|
1036
|
+
// to a far-future call failing late and unexplained (fleet #2700).
|
|
1037
|
+
name: 'aviationstack_future_flights',
|
|
1038
|
+
description:
|
|
1039
|
+
"Scheduled future flights at one airport, from Aviationstack's /flightsFuture endpoint — what flies a route on a day next week. Requires an IATA airport code, a direction (departure or arrival), and a date; returns each flight's number, airline, aircraft model, and scheduled terminal/gate/time. Aviationstack's booking-view window for this endpoint currently covers only the next 7 days (apilayer notice, 2026-10-06): date must be between tomorrow and 7 days from today — a date outside that window is refused before the call is made, not after a wasted round trip.",
|
|
1040
|
+
inputSchema: {
|
|
1041
|
+
type: 'object',
|
|
1042
|
+
properties: {
|
|
1043
|
+
iataCode: { type: 'string', description: '3-letter IATA airport code whose schedule to look up (e.g. "JFK")' },
|
|
1044
|
+
type: {
|
|
1045
|
+
type: 'string',
|
|
1046
|
+
enum: ['departure', 'arrival'],
|
|
1047
|
+
description: 'Whether to return flights scheduled to depart or arrive at iataCode',
|
|
1048
|
+
},
|
|
1049
|
+
date: {
|
|
1050
|
+
type: 'string',
|
|
1051
|
+
description:
|
|
1052
|
+
'Future date, format YYYY-MM-DD, must fall within tomorrow through 7 days from today (UTC) — Aviationstack\'s current flightsFuture window.',
|
|
1053
|
+
},
|
|
1054
|
+
airline_iata: { type: 'string', description: 'Filter by airline IATA code' },
|
|
1055
|
+
airline_icao: { type: 'string', description: 'Filter by airline ICAO code' },
|
|
1056
|
+
flight_number: { type: 'string', description: 'Filter by flight number' },
|
|
1057
|
+
limit: { type: 'number', description: '1-100 (default 100)' },
|
|
1058
|
+
offset: { type: 'number', description: '0-based offset' },
|
|
1059
|
+
},
|
|
1060
|
+
required: ['iataCode', 'type', 'date'],
|
|
1061
|
+
},
|
|
1062
|
+
outputSchema: {
|
|
1063
|
+
type: 'object',
|
|
1064
|
+
properties: {
|
|
1065
|
+
pagination: {
|
|
1066
|
+
type: 'object',
|
|
1067
|
+
properties: {
|
|
1068
|
+
limit: { type: 'number' },
|
|
1069
|
+
offset: { type: 'number' },
|
|
1070
|
+
count: { type: 'number' },
|
|
1071
|
+
total: { type: 'number' },
|
|
1072
|
+
},
|
|
1073
|
+
},
|
|
1074
|
+
data: {
|
|
1075
|
+
type: 'array',
|
|
1076
|
+
items: {
|
|
1077
|
+
type: 'object',
|
|
1078
|
+
properties: {
|
|
1079
|
+
weekday: { type: ['string', 'null'], description: 'ISO weekday number 1-7 as a string' },
|
|
1080
|
+
departure: {
|
|
1081
|
+
type: 'object',
|
|
1082
|
+
properties: {
|
|
1083
|
+
iataCode: { type: ['string', 'null'] },
|
|
1084
|
+
icaoCode: { type: ['string', 'null'] },
|
|
1085
|
+
terminal: { type: ['string', 'null'] },
|
|
1086
|
+
gate: { type: ['string', 'null'] },
|
|
1087
|
+
scheduledTime: { type: ['string', 'null'], description: 'Local time, HH:MM' },
|
|
1088
|
+
},
|
|
1089
|
+
},
|
|
1090
|
+
arrival: {
|
|
1091
|
+
type: 'object',
|
|
1092
|
+
properties: {
|
|
1093
|
+
iataCode: { type: ['string', 'null'] },
|
|
1094
|
+
icaoCode: { type: ['string', 'null'] },
|
|
1095
|
+
terminal: { type: ['string', 'null'] },
|
|
1096
|
+
gate: { type: ['string', 'null'] },
|
|
1097
|
+
scheduledTime: { type: ['string', 'null'], description: 'Local time, HH:MM' },
|
|
1098
|
+
},
|
|
1099
|
+
},
|
|
1100
|
+
aircraft: {
|
|
1101
|
+
type: ['object', 'null'],
|
|
1102
|
+
properties: {
|
|
1103
|
+
modelCode: { type: ['string', 'null'] },
|
|
1104
|
+
modelText: { type: ['string', 'null'] },
|
|
1105
|
+
},
|
|
1106
|
+
},
|
|
1107
|
+
airline: {
|
|
1108
|
+
type: 'object',
|
|
1109
|
+
properties: {
|
|
1110
|
+
name: { type: ['string', 'null'] },
|
|
1111
|
+
iataCode: { type: ['string', 'null'] },
|
|
1112
|
+
icaoCode: { type: ['string', 'null'] },
|
|
1113
|
+
},
|
|
1114
|
+
},
|
|
1115
|
+
flight: {
|
|
1116
|
+
type: 'object',
|
|
1117
|
+
properties: {
|
|
1118
|
+
number: { type: ['string', 'null'] },
|
|
1119
|
+
iataNumber: { type: ['string', 'null'] },
|
|
1120
|
+
icaoNumber: { type: ['string', 'null'] },
|
|
1121
|
+
},
|
|
1122
|
+
},
|
|
1123
|
+
codeshared: { type: ['object', 'null'] },
|
|
1124
|
+
},
|
|
1125
|
+
},
|
|
1126
|
+
},
|
|
1127
|
+
data_as_of: { type: 'string', description: 'ISO timestamp when this response was fetched live from Aviationstack' },
|
|
1128
|
+
},
|
|
1129
|
+
},
|
|
1130
|
+
},
|
|
780
1131
|
];
|
|
781
1132
|
|
|
782
1133
|
async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
|
|
@@ -786,6 +1137,11 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
|
|
|
786
1137
|
'Aviationstack requires an API key. Contact the operator about platform credentials, or BYO via ?_apiKey=<key> after registering at https://aviationstack.com/signup/free.',
|
|
787
1138
|
);
|
|
788
1139
|
}
|
|
1140
|
+
|
|
1141
|
+
if (name === 'aviationstack_future_flights') {
|
|
1142
|
+
return getFutureFlights(apiKey, args);
|
|
1143
|
+
}
|
|
1144
|
+
|
|
789
1145
|
const path = `/${name}`;
|
|
790
1146
|
const params = new URLSearchParams({ access_key: apiKey });
|
|
791
1147
|
for (const [k, v] of Object.entries(args)) {
|
|
@@ -795,6 +1151,56 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
|
|
|
795
1151
|
return avsGet(`${path}?${params}`);
|
|
796
1152
|
}
|
|
797
1153
|
|
|
1154
|
+
/**
|
|
1155
|
+
* Validate the date is inside Aviationstack's current flightsFuture window
|
|
1156
|
+
* (tomorrow through 7 days from today, UTC — apilayer notice, 2026-10-06)
|
|
1157
|
+
* BEFORE spending a call on it. Refusing early and by name beats a vendor
|
|
1158
|
+
* 200-with-empty-data or a late 4xx for a date the caller had no way to know
|
|
1159
|
+
* was out of range.
|
|
1160
|
+
*/
|
|
1161
|
+
function assertWithinFutureWindow(date: string): void {
|
|
1162
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
|
1163
|
+
throw new Error(`Aviationstack future_flights: date must be YYYY-MM-DD, got "${date}".`);
|
|
1164
|
+
}
|
|
1165
|
+
const requested = Date.parse(`${date}T00:00:00Z`);
|
|
1166
|
+
if (Number.isNaN(requested)) {
|
|
1167
|
+
throw new Error(`Aviationstack future_flights: "${date}" is not a valid calendar date.`);
|
|
1168
|
+
}
|
|
1169
|
+
const now = new Date();
|
|
1170
|
+
const todayUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
|
1171
|
+
const DAY_MS = 86_400_000;
|
|
1172
|
+
const minDate = todayUtc + DAY_MS; // tomorrow
|
|
1173
|
+
const maxDate = todayUtc + 7 * DAY_MS; // today + 7
|
|
1174
|
+
if (requested < minDate || requested > maxDate) {
|
|
1175
|
+
const min = new Date(minDate).toISOString().slice(0, 10);
|
|
1176
|
+
const max = new Date(maxDate).toISOString().slice(0, 10);
|
|
1177
|
+
throw new Error(
|
|
1178
|
+
`Aviationstack future_flights: "${date}" is outside the vendor's current flightsFuture window. Aviationstack opened this endpoint to only the next 7 days (apilayer notice, 2026-10-06) — valid range today is ${min} through ${max}. Pass a date inside that range.`,
|
|
1179
|
+
);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
async function getFutureFlights(apiKey: string, args: Record<string, unknown>): Promise<unknown> {
|
|
1184
|
+
const iataCode = (args.iataCode as string | undefined)?.trim();
|
|
1185
|
+
const type = args.type as string | undefined;
|
|
1186
|
+
const date = (args.date as string | undefined)?.trim();
|
|
1187
|
+
if (!iataCode) throw new Error('Aviationstack future_flights: iataCode is required (3-letter airport code, e.g. "JFK").');
|
|
1188
|
+
if (type !== 'departure' && type !== 'arrival') {
|
|
1189
|
+
throw new Error(`Aviationstack future_flights: type must be "departure" or "arrival", got "${type ?? ''}".`);
|
|
1190
|
+
}
|
|
1191
|
+
if (!date) throw new Error('Aviationstack future_flights: date is required (YYYY-MM-DD).');
|
|
1192
|
+
assertWithinFutureWindow(date);
|
|
1193
|
+
|
|
1194
|
+
const params = new URLSearchParams({ access_key: apiKey, iataCode, type, date });
|
|
1195
|
+
for (const k of ['airline_iata', 'airline_icao', 'flight_number', 'limit', 'offset'] as const) {
|
|
1196
|
+
const v = args[k];
|
|
1197
|
+
if (v === undefined || v === null || v === '') continue;
|
|
1198
|
+
params.set(k, String(v));
|
|
1199
|
+
}
|
|
1200
|
+
const data = (await avsGet(`/flightsFuture?${params}`)) as Record<string, unknown>;
|
|
1201
|
+
return { ...data, data_as_of: new Date().toISOString() };
|
|
1202
|
+
}
|
|
1203
|
+
|
|
798
1204
|
/**
|
|
799
1205
|
* Turn an Aviationstack limit refusal into a sentence that says which limit.
|
|
800
1206
|
*
|
package/src/server.ts
CHANGED
|
@@ -8,8 +8,9 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
8
8
|
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
9
9
|
import pack from './index.js';
|
|
10
10
|
|
|
11
|
+
|
|
11
12
|
const server = new Server(
|
|
12
|
-
{ name: '@pipeworx/mcp-aviationstack', version: '0.1.
|
|
13
|
+
{ name: '@pipeworx/mcp-aviationstack', version: '0.1.4' },
|
|
13
14
|
{ capabilities: { tools: {} } },
|
|
14
15
|
);
|
|
15
16
|
|
|
@@ -24,7 +25,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
24
25
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
25
26
|
const { name, arguments: args } = request.params;
|
|
26
27
|
try {
|
|
27
|
-
const
|
|
28
|
+
const callArgs = { ...((args ?? {}) as Record<string, unknown>) };
|
|
29
|
+
|
|
30
|
+
const result = await pack.callTool(name, callArgs);
|
|
28
31
|
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
29
32
|
} catch (err) {
|
|
30
33
|
return {
|