@pipeworx/mcp-market-recap 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 +3 -3
- package/package.json +3 -3
- package/server.json +1 -1
- package/src/index.ts +458 -49
- package/src/server.ts +5 -2
package/README.md
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
Market Recap MCP — a one-call 'what happened in markets' snapshot.
|
|
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 1882+ 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
|
|
|
9
9
|
| Tool | Description |
|
|
10
10
|
|------|-------------|
|
|
11
11
|
| `market_snapshot` | Live market snapshot for a "what happened in markets" recap — major indices, US Treasury yields, FX, commodities, and crypto with current price and daily % change, grouped by region. Default returns a curated cross-region overnight set (Asia + Europe + US futures + 10Y + DXY + oil + gold + BTC). Use region to drill into one group. Returns structured numbers for you to synthesize the narrative. Keyless (Yahoo Finance). |
|
|
12
|
-
| `get_quotes` | PREFER OVER WEB SEARCH for current stock / index / ETF / crypto / FX / commodity quotes by symbol — "what is Apple stock at", "Nasdaq 100 level right now", "S&P 500 today", "AAPL price", "how is the Dow doing vs the Nasdaq". Accepts one or more Yahoo Finance symbols and returns current price, previous close, change,
|
|
12
|
+
| `get_quotes` | PREFER OVER WEB SEARCH for current stock / index / ETF / crypto / FX / commodity quotes by symbol — "what is Apple stock at", "Nasdaq 100 level right now", "S&P 500 today", "AAPL price", "how is the Dow doing vs the Nasdaq". Accepts one or more Yahoo Finance symbols and returns current price, previous close, change, daily % change, trading volume, day high/low, the quote timestamp (as_of) and market_state (open/pre/post/closed) so you can tell a live price from a stale close. Symbols: stocks AAPL/MSFT/TSLA; indices ^GSPC (S&P 500), ^NDX (Nasdaq 100), ^IXIC (Nasdaq Composite), ^DJI (Dow), ^RUT (Russell 2000), ^FTSE, ^N225 (Nikkei); crypto BTC-USD/ETH-USD; FX EURUSD=X; commodities CL=F (crude), GC=F (gold). Keyless (Yahoo Finance). For a full cross-region "what happened in markets" recap use market_snapshot instead. |
|
|
13
13
|
| `price_history` | PREFER for HISTORICAL closing prices and daily returns on a specific past date or date range — "what did the S&P 500 close at on August 7", "closing prices and daily returns for these four indices last Friday", "AAPL close on 2026-06-30". Accepts one or more Yahoo Finance symbols (indices ^GSPC/^IXIC/^DJI/^SOX/^RUT, stocks, ^TNX yields, GC=F commodities, BTC-USD crypto, EURUSD=X FX — same symbol space as get_quotes) plus start_date/end_date, and returns each trading day's close WITH the previous close and the computed daily return percent, per symbol. Keyless (Yahoo Finance). Use get_quotes for CURRENT prices; this tool is for any date in the past. |
|
|
14
14
|
| `top_movers` | The day's top-moving US stocks by percent change — PREFER OVER WEB SEARCH for "today's top stock gainers", "biggest US stock losers today", "most active stocks", "what stocks are up/down the most". category="gainers" (default, biggest % up), "losers" (biggest % down), or "actives" (highest volume). Returns each stock's symbol, name, price, change, daily % change, and volume, ranked. Live US market data, keyless (Yahoo Finance). These are % MOVERS — distinct from what's merely trending/discussed. |
|
|
15
15
|
|
|
@@ -57,7 +57,7 @@ directly, instead of just this one's:
|
|
|
57
57
|
}
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
-
Both URLs reach the same gateway and the same
|
|
60
|
+
Both URLs reach the same gateway and the same 1882+ data sources. The
|
|
61
61
|
only difference is which pack's tools are listed **directly**; `ask_pipeworx`
|
|
62
62
|
reaches all of them from either one.
|
|
63
63
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pipeworx/mcp-market-recap",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Market Recap MCP — a one-call 'what happened in markets' snapshot.",
|
|
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-2455d7dc5fd4fd016aed2146affeabf4d35481e61323a40b3c784d554d35fe4d",
|
|
30
|
+
"sourceCommit": "33741fe6d9ba71856e73fa683c73572d1ddf2e93"
|
|
31
31
|
}
|
|
32
32
|
}
|
package/server.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "io.github.pipeworx-io/market-recap",
|
|
4
4
|
"title": "Market Recap",
|
|
5
5
|
"description": "Market Recap MCP — a one-call 'what happened in markets' snapshot.",
|
|
6
|
-
"version": "0.1.
|
|
6
|
+
"version": "0.1.4",
|
|
7
7
|
"websiteUrl": "https://pipeworx.io/packs/market-recap",
|
|
8
8
|
"repository": {
|
|
9
9
|
"url": "https://github.com/pipeworx-io/mcp-market-recap",
|
package/src/index.ts
CHANGED
|
@@ -295,12 +295,28 @@ async function httpErrorMessage(res: Response, name: string): Promise<string> {
|
|
|
295
295
|
// internal-host-class.ts; no-op for every third-party upstream, which is why
|
|
296
296
|
// this touches 481 packs' error text and changes none of it.
|
|
297
297
|
return markInternalOrigin(
|
|
298
|
-
`${name}: ${res.status}${detailSuffix(await readDetail(res))}`,
|
|
298
|
+
`${name}: ${res.status}${detailSuffix(await readDetail(res))}${responseHints(res)}`,
|
|
299
299
|
res.url,
|
|
300
300
|
res.status,
|
|
301
301
|
);
|
|
302
302
|
}
|
|
303
303
|
|
|
304
|
+
/**
|
|
305
|
+
* Facts about a FAILED response that live in its headers, not its body, appended
|
|
306
|
+
* so the gateway's classifier (which only ever sees this string) can tell a limit
|
|
307
|
+
* from a wall from a refusal (fleet #3477). Both are standard, both are absent on
|
|
308
|
+
* almost every response, and neither is invented: `Retry-After` is the server
|
|
309
|
+
* saying "come back later" (the one thing that makes a 403 a throttle), and
|
|
310
|
+
* Cloudflare marks a bot challenge with `cf-mitigated: challenge`.
|
|
311
|
+
*/
|
|
312
|
+
function responseHints(res: Response): string {
|
|
313
|
+
const hints: string[] = [];
|
|
314
|
+
const retryAfter = res.headers.get('retry-after');
|
|
315
|
+
if (retryAfter) hints.push(`retry-after: ${retryAfter.slice(0, 40)}`);
|
|
316
|
+
if ((res.headers.get('cf-mitigated') ?? '').toLowerCase() === 'challenge') hints.push('Cloudflare bot challenge (cf-mitigated: challenge)');
|
|
317
|
+
return hints.length ? ` (${hints.join('; ')})` : '';
|
|
318
|
+
}
|
|
319
|
+
|
|
304
320
|
/**
|
|
305
321
|
* Just the upstream's own explanation — no name, no status.
|
|
306
322
|
*
|
|
@@ -670,6 +686,254 @@ function pickMessage(node: unknown, depth: number): string | null {
|
|
|
670
686
|
function collapse(s: string): string {
|
|
671
687
|
return s.replace(/\s+/g, ' ').trim();
|
|
672
688
|
}
|
|
689
|
+
|
|
690
|
+
// ── Bounded readers — peak memory, not just wall time (fleet #3322) ──────
|
|
691
|
+
//
|
|
692
|
+
// `parseJson` above does `await res.text()` then `JSON.parse(raw)`, so a
|
|
693
|
+
// multi-megabyte upstream body is held TWICE at peak: once as the raw string
|
|
694
|
+
// and once as the object graph (which for an array of small objects runs
|
|
695
|
+
// several times the byte size). Nothing in `workers/gateway` can see that
|
|
696
|
+
// peak — it happens inside the pack, before any gateway code holds a payload —
|
|
697
|
+
// which is why the gateway-side payload caps of #3293 and #3320 did not move
|
|
698
|
+
// the 503/1102 rate at all.
|
|
699
|
+
//
|
|
700
|
+
// Measured on the live gateway, 2026-10-19, sha fa5de5c34, direct tools/call:
|
|
701
|
+
// statcan_list_cubes 8,049,566 B <- one upstream array, no limit argument
|
|
702
|
+
// gnomad_gene BRCA1 2,732,706 B
|
|
703
|
+
// Five of those in flight at the quality runner's width is ~40 MB of parse
|
|
704
|
+
// peak against a 128 MB isolate; Cloudflare kills the isolate and every
|
|
705
|
+
// request in flight returns 503/1102, which is why the victims look random.
|
|
706
|
+
//
|
|
707
|
+
// `readJsonArrayPrefix` is the fix for the dominant shape: an upstream that
|
|
708
|
+
// returns one enormous top-level JSON array with no server-side paging. It
|
|
709
|
+
// parses elements off the stream and DISCARDS the consumed bytes, so peak
|
|
710
|
+
// retained memory is one element plus one chunk rather than the whole body,
|
|
711
|
+
// and it cancels the body as soon as it has what it was asked for.
|
|
712
|
+
|
|
713
|
+
/** Default ceiling for a single upstream JSON body read into an isolate.
|
|
714
|
+
* Chosen so the known tail (statcan 8.0 MB, gnomad 2.7 MB) is bounded well
|
|
715
|
+
* below the point where a handful of concurrent reads exhausts 128 MB. */
|
|
716
|
+
const DEFAULT_MAX_JSON_BYTES = 1_500_000;
|
|
717
|
+
|
|
718
|
+
interface JsonArrayPrefix<T> {
|
|
719
|
+
/** The decoded elements, at most `maxItems` of them. */
|
|
720
|
+
items: T[];
|
|
721
|
+
/** True when the upstream array had more elements than we read. */
|
|
722
|
+
truncated: boolean;
|
|
723
|
+
/** Bytes actually pulled off the socket — the number that bounds memory. */
|
|
724
|
+
bytesRead: number;
|
|
725
|
+
/** `Content-Length` if the upstream declared one, else null. */
|
|
726
|
+
declaredBytes: number | null;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Read the FIRST `maxItems` elements of a top-level JSON array off a response
|
|
731
|
+
* stream without ever materialising the whole body.
|
|
732
|
+
*
|
|
733
|
+
* Use this instead of `parseJson` when the upstream returns one big array and
|
|
734
|
+
* offers no `limit`/`offset` of its own. Peak retained memory is one element
|
|
735
|
+
* plus the current chunk; the body is cancelled as soon as `maxItems` is
|
|
736
|
+
* reached, so the remaining megabytes are never transferred at all.
|
|
737
|
+
*
|
|
738
|
+
* const { items, truncated } = await readJsonArrayPrefix<Cube>(
|
|
739
|
+
* res, 'StatCan WDS', { maxItems: limit + offset });
|
|
740
|
+
*
|
|
741
|
+
* `maxBytes` is a second, independent backstop for a pathological element (a
|
|
742
|
+
* single 50 MB object): hitting it stops the read and sets `truncated`, rather
|
|
743
|
+
* than throwing, because a partial answer plus an explicit truncated flag is
|
|
744
|
+
* more useful to a caller than an error.
|
|
745
|
+
*/
|
|
746
|
+
async function readJsonArrayPrefix<T>(
|
|
747
|
+
res: Response,
|
|
748
|
+
name: string,
|
|
749
|
+
opts: { maxItems: number; maxBytes?: number },
|
|
750
|
+
): Promise<JsonArrayPrefix<T>> {
|
|
751
|
+
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_JSON_BYTES;
|
|
752
|
+
const declaredRaw = Number(res.headers.get('content-length'));
|
|
753
|
+
const declaredBytes = Number.isFinite(declaredRaw) && declaredRaw > 0 ? declaredRaw : null;
|
|
754
|
+
|
|
755
|
+
// No streaming body available (a mock, or an already-buffered response):
|
|
756
|
+
// fall back to the whole-text path so behaviour stays correct, just unbounded.
|
|
757
|
+
if (!res.body) {
|
|
758
|
+
const all = await parseJson<T[]>(res, name);
|
|
759
|
+
if (!Array.isArray(all)) throw new Error(`upstream_down: ${name} returned ${typeof all} where a JSON array was expected.`);
|
|
760
|
+
return {
|
|
761
|
+
items: all.slice(0, opts.maxItems),
|
|
762
|
+
truncated: all.length > opts.maxItems,
|
|
763
|
+
bytesRead: declaredBytes ?? 0,
|
|
764
|
+
declaredBytes,
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const reader = res.body.getReader();
|
|
769
|
+
const decoder = new TextDecoder();
|
|
770
|
+
const items: T[] = [];
|
|
771
|
+
|
|
772
|
+
let buf = '';
|
|
773
|
+
let scan = 0;
|
|
774
|
+
let bytesRead = 0;
|
|
775
|
+
let started = false; // consumed the opening '['
|
|
776
|
+
let arrayEnded = false;
|
|
777
|
+
let depth = 0;
|
|
778
|
+
let inStr = false;
|
|
779
|
+
let esc = false;
|
|
780
|
+
let elemStart = -1;
|
|
781
|
+
let truncated = false;
|
|
782
|
+
|
|
783
|
+
const push = (text: string) => {
|
|
784
|
+
const t = text.trim();
|
|
785
|
+
if (!t) return;
|
|
786
|
+
items.push(JSON.parse(t) as T);
|
|
787
|
+
};
|
|
788
|
+
|
|
789
|
+
try {
|
|
790
|
+
outer: while (items.length < opts.maxItems && !arrayEnded) {
|
|
791
|
+
const { value, done } = await reader.read();
|
|
792
|
+
if (done) break;
|
|
793
|
+
bytesRead += value.byteLength;
|
|
794
|
+
buf += decoder.decode(value, { stream: true });
|
|
795
|
+
if (bytesRead > maxBytes) { truncated = true; break; }
|
|
796
|
+
|
|
797
|
+
for (; scan < buf.length; scan++) {
|
|
798
|
+
const ch = buf[scan];
|
|
799
|
+
|
|
800
|
+
if (inStr) {
|
|
801
|
+
if (esc) { esc = false; continue; }
|
|
802
|
+
if (ch === '\\') { esc = true; continue; }
|
|
803
|
+
if (ch === '"') inStr = false;
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
if (!started) {
|
|
808
|
+
if (ch === '[') { started = true; continue; }
|
|
809
|
+
if (ch.trim() === '') continue;
|
|
810
|
+
throw new Error(
|
|
811
|
+
`upstream_down: ${name} returned a body that does not begin with a JSON array (saw ${JSON.stringify(ch)}). ` +
|
|
812
|
+
'This reader is only for upstreams that answer with one top-level array.',
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
if (ch === '"') { if (depth === 0 && elemStart < 0) elemStart = scan; inStr = true; continue; }
|
|
817
|
+
|
|
818
|
+
if (ch === '{' || ch === '[') {
|
|
819
|
+
if (depth === 0 && elemStart < 0) elemStart = scan;
|
|
820
|
+
depth++;
|
|
821
|
+
continue;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
if (ch === '}' || ch === ']') {
|
|
825
|
+
if (depth > 0) {
|
|
826
|
+
depth--;
|
|
827
|
+
if (depth === 0 && elemStart >= 0) {
|
|
828
|
+
push(buf.slice(elemStart, scan + 1));
|
|
829
|
+
elemStart = -1;
|
|
830
|
+
if (items.length >= opts.maxItems) { truncated = true; scan++; break outer; }
|
|
831
|
+
}
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
// depth 0 and a closing bracket: the end of the top-level array.
|
|
835
|
+
if (elemStart >= 0) { push(buf.slice(elemStart, scan)); elemStart = -1; }
|
|
836
|
+
arrayEnded = true;
|
|
837
|
+
break outer;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
if (ch === ',' && depth === 0) {
|
|
841
|
+
if (elemStart >= 0) {
|
|
842
|
+
push(buf.slice(elemStart, scan));
|
|
843
|
+
elemStart = -1;
|
|
844
|
+
if (items.length >= opts.maxItems) { truncated = true; scan++; break outer; }
|
|
845
|
+
}
|
|
846
|
+
continue;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// A bare scalar element (number / true / false / null).
|
|
850
|
+
if (depth === 0 && elemStart < 0 && ch.trim() !== '') elemStart = scan;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Drop everything already consumed. THIS is what bounds memory: without
|
|
854
|
+
// it `buf` grows to the full body and the reader is streaming in name only.
|
|
855
|
+
if (elemStart >= 0) {
|
|
856
|
+
buf = buf.slice(elemStart);
|
|
857
|
+
scan -= elemStart;
|
|
858
|
+
elemStart = 0;
|
|
859
|
+
} else {
|
|
860
|
+
buf = buf.slice(scan);
|
|
861
|
+
scan = 0;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
} finally {
|
|
865
|
+
// Cancel rather than drain: the point is that the remaining megabytes are
|
|
866
|
+
// never transferred. A cancel on an already-finished body is a no-op.
|
|
867
|
+
await reader.cancel().catch(() => {});
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
return { items, truncated, bytesRead, declaredBytes };
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* `parseJson`, with a hard ceiling on how many bytes are read.
|
|
875
|
+
*
|
|
876
|
+
* For an upstream whose body is not a single array (so `readJsonArrayPrefix`
|
|
877
|
+
* does not apply) but which can still answer with megabytes. Unlike the array
|
|
878
|
+
* reader this one THROWS when the cap is hit, because a truncated prefix of an
|
|
879
|
+
* arbitrary JSON document is not parseable and there is no partial answer to
|
|
880
|
+
* hand back. A declared `Content-Length` over the cap is refused before a
|
|
881
|
+
* single chunk is read.
|
|
882
|
+
*/
|
|
883
|
+
async function parseJsonBounded<T>(
|
|
884
|
+
res: Response,
|
|
885
|
+
name: string,
|
|
886
|
+
maxBytes: number = DEFAULT_MAX_JSON_BYTES,
|
|
887
|
+
): Promise<T> {
|
|
888
|
+
const declaredRaw = Number(res.headers.get('content-length'));
|
|
889
|
+
const declared = Number.isFinite(declaredRaw) && declaredRaw > 0 ? declaredRaw : null;
|
|
890
|
+
|
|
891
|
+
const tooBig = (n: number) =>
|
|
892
|
+
new Error(
|
|
893
|
+
`upstream_down: ${name} answered with ${n.toLocaleString()} bytes, over this pack's ${maxBytes.toLocaleString()}-byte ceiling. ` +
|
|
894
|
+
'The body is not read past the ceiling on purpose — a few concurrent reads of a body that size exhaust the ' +
|
|
895
|
+
'Worker isolate and take every other in-flight request down with them. Narrow the request (a smaller range, ' +
|
|
896
|
+
'a specific id, or a lower limit) and retry.',
|
|
897
|
+
);
|
|
898
|
+
|
|
899
|
+
if (declared !== null && declared > maxBytes) {
|
|
900
|
+
await res.body?.cancel().catch(() => {});
|
|
901
|
+
throw tooBig(declared);
|
|
902
|
+
}
|
|
903
|
+
if (!res.body) return parseJson<T>(res, name);
|
|
904
|
+
|
|
905
|
+
const reader = res.body.getReader();
|
|
906
|
+
const decoder = new TextDecoder();
|
|
907
|
+
let raw = '';
|
|
908
|
+
let bytesRead = 0;
|
|
909
|
+
try {
|
|
910
|
+
for (;;) {
|
|
911
|
+
const { value, done } = await reader.read();
|
|
912
|
+
if (done) break;
|
|
913
|
+
bytesRead += value.byteLength;
|
|
914
|
+
if (bytesRead > maxBytes) throw tooBig(bytesRead);
|
|
915
|
+
raw += decoder.decode(value, { stream: true });
|
|
916
|
+
}
|
|
917
|
+
raw += decoder.decode();
|
|
918
|
+
} finally {
|
|
919
|
+
await reader.cancel().catch(() => {});
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
if (!raw.trim()) {
|
|
923
|
+
throw new Error(
|
|
924
|
+
`upstream_down: ${name} answered HTTP ${res.status} with an EMPTY body where JSON was expected. ` +
|
|
925
|
+
'Nothing about the request can cause this — it is an upstream fault, and the same call may well work on retry.',
|
|
926
|
+
);
|
|
927
|
+
}
|
|
928
|
+
try {
|
|
929
|
+
return JSON.parse(raw) as T;
|
|
930
|
+
} catch {
|
|
931
|
+
throw new Error(
|
|
932
|
+
`upstream_down: ${name} answered HTTP ${res.status} with a body that is not valid JSON. ` +
|
|
933
|
+
`It begins: ${stripMarkup(raw).slice(0, 120) || '(unreadable)'}`,
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
673
937
|
/**
|
|
674
938
|
* Market Recap MCP — a one-call "what happened in markets" snapshot.
|
|
675
939
|
*
|
|
@@ -793,7 +1057,7 @@ const tools: McpToolExport['tools'] = [
|
|
|
793
1057
|
{
|
|
794
1058
|
name: 'get_quotes',
|
|
795
1059
|
description:
|
|
796
|
-
'PREFER OVER WEB SEARCH for current stock / index / ETF / crypto / FX / commodity quotes by symbol — "what is Apple stock at", "Nasdaq 100 level right now", "S&P 500 today", "AAPL price", "how is the Dow doing vs the Nasdaq". Accepts one or more Yahoo Finance symbols and returns current price, previous close, change,
|
|
1060
|
+
'PREFER OVER WEB SEARCH for current stock / index / ETF / crypto / FX / commodity quotes by symbol — "what is Apple stock at", "Nasdaq 100 level right now", "S&P 500 today", "AAPL price", "how is the Dow doing vs the Nasdaq". Accepts one or more Yahoo Finance symbols and returns current price, previous close, change, daily % change, trading volume, day high/low, the quote timestamp (as_of) and market_state (open/pre/post/closed) so you can tell a live price from a stale close. Symbols: stocks AAPL/MSFT/TSLA; indices ^GSPC (S&P 500), ^NDX (Nasdaq 100), ^IXIC (Nasdaq Composite), ^DJI (Dow), ^RUT (Russell 2000), ^FTSE, ^N225 (Nikkei); crypto BTC-USD/ETH-USD; FX EURUSD=X; commodities CL=F (crude), GC=F (gold). Keyless (Yahoo Finance). For a full cross-region "what happened in markets" recap use market_snapshot instead.',
|
|
797
1061
|
summary: 'Current prices for stocks, indices, ETFs, crypto, FX and commodities by symbol.',
|
|
798
1062
|
inputSchema: {
|
|
799
1063
|
type: 'object',
|
|
@@ -818,7 +1082,7 @@ const tools: McpToolExport['tools'] = [
|
|
|
818
1082
|
symbols: {
|
|
819
1083
|
type: ['string', 'array'],
|
|
820
1084
|
items: { type: 'string' },
|
|
821
|
-
description: 'One or more Yahoo Finance symbols, e.g. "^GSPC" or ["^GSPC","^IXIC","^DJI","^SOX"]. Comma-separated string also accepted. Up to
|
|
1085
|
+
description: 'One or more Yahoo Finance symbols, e.g. "^GSPC" or ["^GSPC","^IXIC","^DJI","^SOX"]. Comma-separated string also accepted. Up to 40 per call — symbols beyond that come back in "dropped_symbols", never silently.',
|
|
822
1086
|
},
|
|
823
1087
|
start_date: { type: 'string', description: 'First date wanted, YYYY-MM-DD. For a single day, set start_date = end_date — the previous close and daily return for that day are included automatically.' },
|
|
824
1088
|
end_date: { type: 'string', description: 'Last date wanted, YYYY-MM-DD. Defaults to start_date.' },
|
|
@@ -851,6 +1115,18 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
|
|
|
851
1115
|
}
|
|
852
1116
|
|
|
853
1117
|
const CHART = 'https://query1.finance.yahoo.com/v8/finance/chart';
|
|
1118
|
+
// /chart has no batch endpoint (unlike /spark's SPARK_MAX=20) — it is one
|
|
1119
|
+
// upstream fetch per symbol. The old code capped the INPUT at 8 and silently
|
|
1120
|
+
// dropped the rest, reporting `requested: 8` even when the caller asked for
|
|
1121
|
+
// 18 (fleet #2909 — 11 judged misses, this bug the common one: a plausible
|
|
1122
|
+
// 200 that never says 10 symbols vanished). There is no real vendor limit
|
|
1123
|
+
// here, so CHART_MAX is a generosity cap against pathological input (a
|
|
1124
|
+
// 500-symbol list), not a batching limit — raise it rather than silently
|
|
1125
|
+
// truncating. CHART_CHUNK bounds how many of those fetches run concurrently
|
|
1126
|
+
// per call, so we fan out politely rather than firing 40 requests at Yahoo
|
|
1127
|
+
// at once.
|
|
1128
|
+
const CHART_MAX = 40; // matches get_quotes' cap; generous enough that no real question hits it
|
|
1129
|
+
const CHART_CHUNK = 8; // concurrency per batch, not a count cap
|
|
854
1130
|
|
|
855
1131
|
/**
|
|
856
1132
|
* Why ONE symbol is missing, in words the caller can act on.
|
|
@@ -902,6 +1178,24 @@ export function priceHistoryMissReason(
|
|
|
902
1178
|
return `We could not price "${sym}" right now: the lookup failed before any data came back (${why}). This is a transient fetch failure, not a missing symbol — retry shortly.`;
|
|
903
1179
|
}
|
|
904
1180
|
|
|
1181
|
+
/**
|
|
1182
|
+
* Split a requested symbol list into the ones this call will actually fetch
|
|
1183
|
+
* and the ones it will not, against a hard per-call cap.
|
|
1184
|
+
*
|
|
1185
|
+
* Pulled out so the no-silent-truncation behaviour is testable without the
|
|
1186
|
+
* network: the bug this exists to prevent (fleet #2909) was `requested`
|
|
1187
|
+
* silently reporting the CAPPED count instead of what was actually asked
|
|
1188
|
+
* for, with the overflow symbols simply gone from the response. `served`
|
|
1189
|
+
* always reflects the real cap, `requested` always reflects the real ask,
|
|
1190
|
+
* and every overflow symbol is named in `dropped` rather than disappearing.
|
|
1191
|
+
*/
|
|
1192
|
+
export function splitPriceHistorySymbols(
|
|
1193
|
+
requested: string[],
|
|
1194
|
+
max: number,
|
|
1195
|
+
): { served: string[]; dropped: string[] } {
|
|
1196
|
+
return { served: requested.slice(0, max), dropped: requested.slice(max) };
|
|
1197
|
+
}
|
|
1198
|
+
|
|
905
1199
|
// Historical daily closes + computed daily returns, per symbol, from Yahoo's
|
|
906
1200
|
// keyless v8 chart endpoint. Exists because "closing prices and daily returns
|
|
907
1201
|
// for four indices on August 7" had NO servable tool: get_quotes is
|
|
@@ -910,11 +1204,11 @@ export function priceHistoryMissReason(
|
|
|
910
1204
|
// week back so the previous trading close — required for the daily return —
|
|
911
1205
|
// is always present even across weekends and holidays.
|
|
912
1206
|
async function priceHistory(args: Record<string, unknown>): Promise<unknown> {
|
|
913
|
-
const
|
|
1207
|
+
const requested = (Array.isArray(args.symbols) ? args.symbols : String(args.symbols ?? '').split(','))
|
|
914
1208
|
.map((s) => String(s).trim().toUpperCase())
|
|
915
|
-
.filter(Boolean)
|
|
916
|
-
|
|
917
|
-
|
|
1209
|
+
.filter(Boolean);
|
|
1210
|
+
if (requested.length === 0) throw new Error('Pass one or more Yahoo Finance symbols, e.g. {symbols: ["^GSPC","^IXIC"], start_date: "2026-08-07"}.');
|
|
1211
|
+
const { served: list, dropped } = splitPriceHistorySymbols(requested, CHART_MAX);
|
|
918
1212
|
const startStr = String(args.start_date ?? '').trim();
|
|
919
1213
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(startStr)) throw new Error('start_date must be YYYY-MM-DD, e.g. "2026-08-07".');
|
|
920
1214
|
const endStr = String(args.end_date ?? startStr).trim();
|
|
@@ -927,44 +1221,61 @@ async function priceHistory(args: Record<string, unknown>): Promise<unknown> {
|
|
|
927
1221
|
const period1 = Math.floor(startMs / 1000) - 7 * 86400;
|
|
928
1222
|
const period2 = Math.floor(endMs / 1000) + 86400 + 86400;
|
|
929
1223
|
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
1224
|
+
// /chart has no batch endpoint, so every symbol is its own upstream fetch.
|
|
1225
|
+
// Fan out in CHART_CHUNK-sized batches, sequentially, rather than firing
|
|
1226
|
+
// `list.length` concurrent requests at Yahoo at once — list.length can now
|
|
1227
|
+
// be up to CHART_MAX (was hard-capped at 8, which is what dropped symbols
|
|
1228
|
+
// 9+ silently; see splitPriceHistorySymbols above for the honest version
|
|
1229
|
+
// of that cap).
|
|
1230
|
+
const results: { symbol: string; found: boolean; error?: string; currency?: string | null; exchange?: string | null; days?: unknown }[] = [];
|
|
1231
|
+
for (let i = 0; i < list.length; i += CHART_CHUNK) {
|
|
1232
|
+
const chunk = list.slice(i, i + CHART_CHUNK);
|
|
1233
|
+
const chunkResults = await Promise.all(chunk.map(async (symbol) => {
|
|
936
1234
|
try {
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
const
|
|
949
|
-
|
|
950
|
-
|
|
1235
|
+
const url = `${CHART}/${encodeURIComponent(symbol)}?period1=${period1}&period2=${period2}&interval=1d&events=history`;
|
|
1236
|
+
const controller = new AbortController();
|
|
1237
|
+
const t = setTimeout(() => controller.abort(), 15_000);
|
|
1238
|
+
let res: Response;
|
|
1239
|
+
try {
|
|
1240
|
+
res = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/json' }, signal: controller.signal });
|
|
1241
|
+
} finally { clearTimeout(t); }
|
|
1242
|
+
if (!res.ok) return { symbol, found: false, error: priceHistoryMissReason(symbol, 'http', res.status) };
|
|
1243
|
+
const data = await res.json() as { chart?: { result?: { meta?: { currency?: string; exchangeName?: string; symbol?: string }; timestamp?: number[]; indicators?: { quote?: { close?: (number | null)[] }[] } }[]; error?: { description?: string } } };
|
|
1244
|
+
const r = data.chart?.result?.[0];
|
|
1245
|
+
if (!r?.timestamp?.length) return { symbol, found: false, error: priceHistoryMissReason(symbol, 'empty', data.chart?.error?.description) };
|
|
1246
|
+
const closes = r.indicators?.quote?.[0]?.close ?? [];
|
|
1247
|
+
// Collapse to (date, close) pairs — the bar timestamp is market open in
|
|
1248
|
+
// exchange-local time; the UTC date of that instant is the trading date.
|
|
1249
|
+
const bars: { date: string; close: number }[] = [];
|
|
1250
|
+
for (let j = 0; j < r.timestamp.length; j++) {
|
|
1251
|
+
const c = closes[j];
|
|
1252
|
+
if (c == null) continue;
|
|
1253
|
+
bars.push({ date: new Date(r.timestamp[j] * 1000).toISOString().slice(0, 10), close: c });
|
|
1254
|
+
}
|
|
1255
|
+
const days = bars
|
|
1256
|
+
.map((b, j) => ({
|
|
1257
|
+
date: b.date,
|
|
1258
|
+
close: Number(b.close.toFixed(4)),
|
|
1259
|
+
previous_close: j > 0 ? Number(bars[j - 1].close.toFixed(4)) : null,
|
|
1260
|
+
daily_return_pct: j > 0 && bars[j - 1].close !== 0
|
|
1261
|
+
? Number(((b.close / bars[j - 1].close - 1) * 100).toFixed(3))
|
|
1262
|
+
: null,
|
|
1263
|
+
}))
|
|
1264
|
+
.filter((d) => d.date >= startStr && d.date <= endStr);
|
|
1265
|
+
if (days.length === 0) return { symbol, found: false, error: priceHistoryMissReason(symbol, 'no-trading-days', `${startStr} to ${endStr}`) + ` Nearest earlier close the source returned: ${bars.at(-1)?.date ?? 'none'}.` };
|
|
1266
|
+
return { symbol, found: true, currency: r.meta?.currency ?? null, exchange: r.meta?.exchangeName ?? null, days };
|
|
1267
|
+
} catch (e) {
|
|
1268
|
+
return { symbol, found: false, error: priceHistoryMissReason(symbol, 'thrown', (e as Error).message) };
|
|
951
1269
|
}
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
.filter((d) => d.date >= startStr && d.date <= endStr);
|
|
962
|
-
if (days.length === 0) return { symbol, found: false, error: priceHistoryMissReason(symbol, 'no-trading-days', `${startStr} to ${endStr}`) + ` Nearest earlier close the source returned: ${bars.at(-1)?.date ?? 'none'}.` };
|
|
963
|
-
return { symbol, found: true, currency: r.meta?.currency ?? null, exchange: r.meta?.exchangeName ?? null, days };
|
|
964
|
-
} catch (e) {
|
|
965
|
-
return { symbol, found: false, error: priceHistoryMissReason(symbol, 'thrown', (e as Error).message) };
|
|
966
|
-
}
|
|
967
|
-
}));
|
|
1270
|
+
}));
|
|
1271
|
+
results.push(...chunkResults);
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// Overflow symbols past CHART_MAX were never fetched — say so by name,
|
|
1275
|
+
// the same way a genuine vendor miss is named, rather than letting them
|
|
1276
|
+
// vanish between `requested` and the results array (fleet #2909).
|
|
1277
|
+
const droppedReason = `price_history serves up to ${CHART_MAX} symbols per call; this call asked for ${requested.length}. Call price_history again with just the symbols listed in "dropped_symbols".`;
|
|
1278
|
+
for (const symbol of dropped) results.push({ symbol, found: false, error: droppedReason });
|
|
968
1279
|
|
|
969
1280
|
// Say the partial part OUT LOUD. `requested` vs `lookups` already implied it,
|
|
970
1281
|
// but implying it means the caller has to diff two arrays to notice — and the
|
|
@@ -978,15 +1289,21 @@ async function priceHistory(args: Record<string, unknown>): Promise<unknown> {
|
|
|
978
1289
|
return {
|
|
979
1290
|
start_date: startStr,
|
|
980
1291
|
end_date: endStr,
|
|
981
|
-
|
|
1292
|
+
// Always the actual ask — never the capped/served count. This is the
|
|
1293
|
+
// field fleet #2909 was filed over: it used to read `requested: 8` when
|
|
1294
|
+
// 18 symbols were asked for, which is indistinguishable from "you only
|
|
1295
|
+
// asked for 8".
|
|
1296
|
+
requested: requested.length,
|
|
1297
|
+
served: list.length,
|
|
982
1298
|
// One underlying data request per symbol — the billing lane's per-lookup
|
|
983
1299
|
// metering signal (same rule as ask_pipeworx's billable_lookups).
|
|
984
1300
|
lookups: results.filter((r) => r.found).length,
|
|
1301
|
+
...(dropped.length ? { dropped_symbols: dropped, dropped_note: droppedReason } : {}),
|
|
985
1302
|
...(missing.length
|
|
986
1303
|
? {
|
|
987
1304
|
partial: true,
|
|
988
1305
|
missing,
|
|
989
|
-
partial_note: `Answered ${
|
|
1306
|
+
partial_note: `Answered ${requested.length - missing.length} of the ${requested.length} symbols asked for. The rest are listed in "missing" with the reason each one could not be priced — report those as named gaps. Do NOT drop them silently and do NOT substitute a different symbol.`,
|
|
990
1307
|
}
|
|
991
1308
|
: {}),
|
|
992
1309
|
results,
|
|
@@ -1010,12 +1327,84 @@ async function getQuotes(rawSymbols: unknown): Promise<unknown> {
|
|
|
1010
1327
|
const prev = q?.previous_close ?? null;
|
|
1011
1328
|
const change = price != null && prev != null ? Number((price - prev).toFixed(4)) : null;
|
|
1012
1329
|
const change_pct = price != null && prev ? Number((((price - prev) / prev) * 100).toFixed(2)) : null;
|
|
1013
|
-
|
|
1330
|
+
const found = q != null && price != null;
|
|
1331
|
+
const row = {
|
|
1332
|
+
symbol,
|
|
1333
|
+
name: q?.name ?? null,
|
|
1334
|
+
price,
|
|
1335
|
+
previous_close: prev,
|
|
1336
|
+
change,
|
|
1337
|
+
change_pct,
|
|
1338
|
+
volume: q?.volume ?? null,
|
|
1339
|
+
day_high: q?.day_high ?? null,
|
|
1340
|
+
day_low: q?.day_low ?? null,
|
|
1341
|
+
currency: q?.currency ?? null,
|
|
1342
|
+
exchange: q?.exchange ?? null,
|
|
1343
|
+
as_of: q?.as_of ?? null,
|
|
1344
|
+
market_state: q?.market_state ?? null,
|
|
1345
|
+
found,
|
|
1346
|
+
};
|
|
1347
|
+
// Fleet #2908: a quote with no volume and no timestamp cannot be told apart
|
|
1348
|
+
// from a stale close, and the judge marked 11 answers down for exactly
|
|
1349
|
+
// that. Every field is always present; when the source has no value we say
|
|
1350
|
+
// which, rather than leaving the caller to notice an absence.
|
|
1351
|
+
const missing_fields = found
|
|
1352
|
+
? (['volume', 'as_of', 'market_state'] as const).filter((k) => row[k] == null)
|
|
1353
|
+
: [];
|
|
1354
|
+
return missing_fields.length
|
|
1355
|
+
? {
|
|
1356
|
+
...row,
|
|
1357
|
+
missing_fields,
|
|
1358
|
+
missing_note: `Yahoo Finance did not supply ${missing_fields.join(', ')} for "${symbol}".`
|
|
1359
|
+
+ (missing_fields.includes('volume') && q?.instrument_type && !['EQUITY', 'ETF', 'MUTUALFUND'].includes(q.instrument_type)
|
|
1360
|
+
? ` ${q.instrument_type} quotes (indices, yields, FX) have no traded volume.`
|
|
1361
|
+
: ''),
|
|
1362
|
+
}
|
|
1363
|
+
: row;
|
|
1014
1364
|
});
|
|
1015
|
-
return {
|
|
1365
|
+
return {
|
|
1366
|
+
requested: list.length,
|
|
1367
|
+
found: results.filter((r) => r.found).length,
|
|
1368
|
+
retrieved_at: new Date().toISOString(),
|
|
1369
|
+
quotes: results,
|
|
1370
|
+
source: 'Yahoo Finance (keyless)',
|
|
1371
|
+
note: 'as_of is the time of the last trade the price reflects. market_state "open" means the regular session is live; "pre"/"post" mean extended hours, where price is still the regular-session price; "closed" means price is the last close.',
|
|
1372
|
+
};
|
|
1016
1373
|
}
|
|
1017
1374
|
|
|
1018
|
-
interface
|
|
1375
|
+
interface TradingPeriod { start?: number; end?: number }
|
|
1376
|
+
|
|
1377
|
+
/**
|
|
1378
|
+
* Which session the exchange is in at `nowSec`, from Yahoo's currentTradingPeriod.
|
|
1379
|
+
* Returns null when the periods are absent so the caller reports it as missing
|
|
1380
|
+
* rather than guessing "closed". Pure, so it is testable without the network.
|
|
1381
|
+
*/
|
|
1382
|
+
export function marketState(
|
|
1383
|
+
periods: { pre?: TradingPeriod; regular?: TradingPeriod; post?: TradingPeriod } | undefined,
|
|
1384
|
+
nowSec: number,
|
|
1385
|
+
): 'open' | 'pre' | 'post' | 'closed' | null {
|
|
1386
|
+
const reg = periods?.regular;
|
|
1387
|
+
if (reg?.start == null || reg?.end == null) return null;
|
|
1388
|
+
const within = (p?: TradingPeriod) => p?.start != null && p?.end != null && p.end > p.start && nowSec >= p.start && nowSec < p.end;
|
|
1389
|
+
if (within(reg)) return 'open';
|
|
1390
|
+
if (within(periods?.pre)) return 'pre';
|
|
1391
|
+
if (within(periods?.post)) return 'post';
|
|
1392
|
+
return 'closed';
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
interface Quote {
|
|
1396
|
+
price: number | null;
|
|
1397
|
+
previous_close: number | null;
|
|
1398
|
+
name?: string | null;
|
|
1399
|
+
volume?: number | null;
|
|
1400
|
+
day_high?: number | null;
|
|
1401
|
+
day_low?: number | null;
|
|
1402
|
+
currency?: string | null;
|
|
1403
|
+
exchange?: string | null;
|
|
1404
|
+
instrument_type?: string | null;
|
|
1405
|
+
as_of?: string | null;
|
|
1406
|
+
market_state?: 'open' | 'pre' | 'post' | 'closed' | null;
|
|
1407
|
+
}
|
|
1019
1408
|
|
|
1020
1409
|
async function fetchSpark(symbols: string[]): Promise<Map<string, Quote>> {
|
|
1021
1410
|
const out = new Map<string, Quote>();
|
|
@@ -1032,15 +1421,35 @@ async function fetchSpark(symbols: string[]): Promise<Map<string, Quote>> {
|
|
|
1032
1421
|
const res = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/json' }, signal: controller.signal });
|
|
1033
1422
|
if (!res.ok) throw await httpError(res, 'Yahoo spark');
|
|
1034
1423
|
const j = (await res.json()) as {
|
|
1035
|
-
spark?: { result?: { symbol?: string; response?: { meta?: {
|
|
1424
|
+
spark?: { result?: { symbol?: string; response?: { meta?: {
|
|
1425
|
+
symbol?: string; regularMarketPrice?: number; chartPreviousClose?: number; previousClose?: number;
|
|
1426
|
+
regularMarketVolume?: number; regularMarketTime?: number; regularMarketDayHigh?: number; regularMarketDayLow?: number;
|
|
1427
|
+
currency?: string; fullExchangeName?: string; exchangeName?: string; instrumentType?: string; shortName?: string; longName?: string;
|
|
1428
|
+
currentTradingPeriod?: { pre?: TradingPeriod; regular?: TradingPeriod; post?: TradingPeriod };
|
|
1429
|
+
} }[] }[] };
|
|
1036
1430
|
};
|
|
1431
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
1432
|
+
const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
1037
1433
|
for (const r of j.spark?.result ?? []) {
|
|
1038
1434
|
const meta = r.response?.[0]?.meta;
|
|
1039
1435
|
const sym = meta?.symbol ?? r.symbol;
|
|
1040
1436
|
if (!sym) continue;
|
|
1437
|
+
// Yahoo reports volume 0 for indices, yields and FX, which have no
|
|
1438
|
+
// traded volume — 0 there means "not applicable", not "nothing traded".
|
|
1439
|
+
const vol = num(meta?.regularMarketVolume);
|
|
1440
|
+
const t = num(meta?.regularMarketTime);
|
|
1041
1441
|
out.set(sym, {
|
|
1042
1442
|
price: meta?.regularMarketPrice ?? null,
|
|
1043
1443
|
previous_close: meta?.chartPreviousClose ?? meta?.previousClose ?? null,
|
|
1444
|
+
name: meta?.longName ?? meta?.shortName ?? null,
|
|
1445
|
+
volume: vol != null && vol > 0 ? vol : null,
|
|
1446
|
+
day_high: num(meta?.regularMarketDayHigh),
|
|
1447
|
+
day_low: num(meta?.regularMarketDayLow),
|
|
1448
|
+
currency: meta?.currency ?? null,
|
|
1449
|
+
exchange: meta?.fullExchangeName ?? meta?.exchangeName ?? null,
|
|
1450
|
+
instrument_type: meta?.instrumentType ?? null,
|
|
1451
|
+
as_of: t != null && t > 0 ? new Date(t * 1000).toISOString() : null,
|
|
1452
|
+
market_state: marketState(meta?.currentTradingPeriod, nowSec),
|
|
1044
1453
|
});
|
|
1045
1454
|
}
|
|
1046
1455
|
} finally {
|
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-market-recap', version: '0.1.
|
|
13
|
+
{ name: '@pipeworx/mcp-market-recap', 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 {
|