@pipeworx/mcp-market-recap 0.1.1 → 0.1.3
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 +229 -49
- package/src/server.ts +1 -1
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 1764+ 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 1764+ 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.3",
|
|
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-58d41789dcd5a989d5a582e5247a24d5a192dec6f2be42dc2486674749e406a3",
|
|
30
|
+
"sourceCommit": "0ce6905893fe8da0d285be2f05754a4200d67371"
|
|
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.3",
|
|
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
|
@@ -452,7 +452,42 @@ async function fetchWithTimeout(
|
|
|
452
452
|
),
|
|
453
453
|
);
|
|
454
454
|
}
|
|
455
|
-
|
|
455
|
+
// Fleet #2382. Everything that isn't a timeout/abort here is a genuine
|
|
456
|
+
// NETWORK-LEVEL failure — DNS resolution, connection refused, TLS handshake,
|
|
457
|
+
// Cloudflare's own "Network connection lost." — meaning `fetch()` itself
|
|
458
|
+
// threw and no HTTP response of any kind was ever received. Until this fix
|
|
459
|
+
// that raw exception was rethrown VERBATIM: a bare `TypeError: fetch failed`
|
|
460
|
+
// (or the Workers-runtime equivalent) names no upstream, carries no class
|
|
461
|
+
// token, and reads exactly like a defect in OUR code — because it says
|
|
462
|
+
// nothing about the call at all. It landed in `error`, the tier that means
|
|
463
|
+
// "Pipeworx has a defect", for every one of the (at the time of writing)
|
|
464
|
+
// ~470 packs that call this helper directly with no wrapper of their own.
|
|
465
|
+
//
|
|
466
|
+
// `dexscreener` hit this independently (fleet #1579) and fixed it with a
|
|
467
|
+
// bespoke per-pack try/catch around `fetchWithTimeout`. That fix is correct
|
|
468
|
+
// but only covers one pack; every other caller of this shared helper still
|
|
469
|
+
// leaked the raw exception. Moving the same fix HERE — the one place that
|
|
470
|
+
// already carries the timeout case — covers every pack that uses
|
|
471
|
+
// `fetchWithTimeout` without a wrapper, for free, and without widening
|
|
472
|
+
// `classifyToolError`'s regex list: the fix is giving the message a proper
|
|
473
|
+
// `upstream_down:` token at the point the two facts (no response was ever
|
|
474
|
+
// received, and which host we were trying to reach) are actually in hand,
|
|
475
|
+
// not teaching the classifier to guess from prose after the fact.
|
|
476
|
+
//
|
|
477
|
+
// Safe on the same grounds as the timeout branch above: no argument a
|
|
478
|
+
// caller passes can make `fetch()` itself throw a connection-level error,
|
|
479
|
+
// so this is always an availability failure, never a caller mistake. Same
|
|
480
|
+
// `markInternalOrigin` treatment — an origin we run that never answered is
|
|
481
|
+
// still ours, not a third party's outage.
|
|
482
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
483
|
+
throw new Error(
|
|
484
|
+
markInternalOrigin(
|
|
485
|
+
`upstream_down: could not reach ${name} at all (${raw.slice(0, 160)}). ` +
|
|
486
|
+
`No request reached ${name}, so this says NOTHING about whether the arguments you passed ` +
|
|
487
|
+
'are valid — do not re-check them on the strength of this error. Retry shortly.',
|
|
488
|
+
url,
|
|
489
|
+
),
|
|
490
|
+
);
|
|
456
491
|
}
|
|
457
492
|
}
|
|
458
493
|
|
|
@@ -758,7 +793,7 @@ const tools: McpToolExport['tools'] = [
|
|
|
758
793
|
{
|
|
759
794
|
name: 'get_quotes',
|
|
760
795
|
description:
|
|
761
|
-
'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,
|
|
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, 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.',
|
|
762
797
|
summary: 'Current prices for stocks, indices, ETFs, crypto, FX and commodities by symbol.',
|
|
763
798
|
inputSchema: {
|
|
764
799
|
type: 'object',
|
|
@@ -783,7 +818,7 @@ const tools: McpToolExport['tools'] = [
|
|
|
783
818
|
symbols: {
|
|
784
819
|
type: ['string', 'array'],
|
|
785
820
|
items: { type: 'string' },
|
|
786
|
-
description: 'One or more Yahoo Finance symbols, e.g. "^GSPC" or ["^GSPC","^IXIC","^DJI","^SOX"]. Comma-separated string also accepted. Up to
|
|
821
|
+
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.',
|
|
787
822
|
},
|
|
788
823
|
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.' },
|
|
789
824
|
end_date: { type: 'string', description: 'Last date wanted, YYYY-MM-DD. Defaults to start_date.' },
|
|
@@ -816,6 +851,18 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<un
|
|
|
816
851
|
}
|
|
817
852
|
|
|
818
853
|
const CHART = 'https://query1.finance.yahoo.com/v8/finance/chart';
|
|
854
|
+
// /chart has no batch endpoint (unlike /spark's SPARK_MAX=20) — it is one
|
|
855
|
+
// upstream fetch per symbol. The old code capped the INPUT at 8 and silently
|
|
856
|
+
// dropped the rest, reporting `requested: 8` even when the caller asked for
|
|
857
|
+
// 18 (fleet #2909 — 11 judged misses, this bug the common one: a plausible
|
|
858
|
+
// 200 that never says 10 symbols vanished). There is no real vendor limit
|
|
859
|
+
// here, so CHART_MAX is a generosity cap against pathological input (a
|
|
860
|
+
// 500-symbol list), not a batching limit — raise it rather than silently
|
|
861
|
+
// truncating. CHART_CHUNK bounds how many of those fetches run concurrently
|
|
862
|
+
// per call, so we fan out politely rather than firing 40 requests at Yahoo
|
|
863
|
+
// at once.
|
|
864
|
+
const CHART_MAX = 40; // matches get_quotes' cap; generous enough that no real question hits it
|
|
865
|
+
const CHART_CHUNK = 8; // concurrency per batch, not a count cap
|
|
819
866
|
|
|
820
867
|
/**
|
|
821
868
|
* Why ONE symbol is missing, in words the caller can act on.
|
|
@@ -867,6 +914,24 @@ export function priceHistoryMissReason(
|
|
|
867
914
|
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.`;
|
|
868
915
|
}
|
|
869
916
|
|
|
917
|
+
/**
|
|
918
|
+
* Split a requested symbol list into the ones this call will actually fetch
|
|
919
|
+
* and the ones it will not, against a hard per-call cap.
|
|
920
|
+
*
|
|
921
|
+
* Pulled out so the no-silent-truncation behaviour is testable without the
|
|
922
|
+
* network: the bug this exists to prevent (fleet #2909) was `requested`
|
|
923
|
+
* silently reporting the CAPPED count instead of what was actually asked
|
|
924
|
+
* for, with the overflow symbols simply gone from the response. `served`
|
|
925
|
+
* always reflects the real cap, `requested` always reflects the real ask,
|
|
926
|
+
* and every overflow symbol is named in `dropped` rather than disappearing.
|
|
927
|
+
*/
|
|
928
|
+
export function splitPriceHistorySymbols(
|
|
929
|
+
requested: string[],
|
|
930
|
+
max: number,
|
|
931
|
+
): { served: string[]; dropped: string[] } {
|
|
932
|
+
return { served: requested.slice(0, max), dropped: requested.slice(max) };
|
|
933
|
+
}
|
|
934
|
+
|
|
870
935
|
// Historical daily closes + computed daily returns, per symbol, from Yahoo's
|
|
871
936
|
// keyless v8 chart endpoint. Exists because "closing prices and daily returns
|
|
872
937
|
// for four indices on August 7" had NO servable tool: get_quotes is
|
|
@@ -875,11 +940,11 @@ export function priceHistoryMissReason(
|
|
|
875
940
|
// week back so the previous trading close — required for the daily return —
|
|
876
941
|
// is always present even across weekends and holidays.
|
|
877
942
|
async function priceHistory(args: Record<string, unknown>): Promise<unknown> {
|
|
878
|
-
const
|
|
943
|
+
const requested = (Array.isArray(args.symbols) ? args.symbols : String(args.symbols ?? '').split(','))
|
|
879
944
|
.map((s) => String(s).trim().toUpperCase())
|
|
880
|
-
.filter(Boolean)
|
|
881
|
-
|
|
882
|
-
|
|
945
|
+
.filter(Boolean);
|
|
946
|
+
if (requested.length === 0) throw new Error('Pass one or more Yahoo Finance symbols, e.g. {symbols: ["^GSPC","^IXIC"], start_date: "2026-08-07"}.');
|
|
947
|
+
const { served: list, dropped } = splitPriceHistorySymbols(requested, CHART_MAX);
|
|
883
948
|
const startStr = String(args.start_date ?? '').trim();
|
|
884
949
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(startStr)) throw new Error('start_date must be YYYY-MM-DD, e.g. "2026-08-07".');
|
|
885
950
|
const endStr = String(args.end_date ?? startStr).trim();
|
|
@@ -892,44 +957,61 @@ async function priceHistory(args: Record<string, unknown>): Promise<unknown> {
|
|
|
892
957
|
const period1 = Math.floor(startMs / 1000) - 7 * 86400;
|
|
893
958
|
const period2 = Math.floor(endMs / 1000) + 86400 + 86400;
|
|
894
959
|
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
960
|
+
// /chart has no batch endpoint, so every symbol is its own upstream fetch.
|
|
961
|
+
// Fan out in CHART_CHUNK-sized batches, sequentially, rather than firing
|
|
962
|
+
// `list.length` concurrent requests at Yahoo at once — list.length can now
|
|
963
|
+
// be up to CHART_MAX (was hard-capped at 8, which is what dropped symbols
|
|
964
|
+
// 9+ silently; see splitPriceHistorySymbols above for the honest version
|
|
965
|
+
// of that cap).
|
|
966
|
+
const results: { symbol: string; found: boolean; error?: string; currency?: string | null; exchange?: string | null; days?: unknown }[] = [];
|
|
967
|
+
for (let i = 0; i < list.length; i += CHART_CHUNK) {
|
|
968
|
+
const chunk = list.slice(i, i + CHART_CHUNK);
|
|
969
|
+
const chunkResults = await Promise.all(chunk.map(async (symbol) => {
|
|
901
970
|
try {
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
const
|
|
914
|
-
|
|
915
|
-
|
|
971
|
+
const url = `${CHART}/${encodeURIComponent(symbol)}?period1=${period1}&period2=${period2}&interval=1d&events=history`;
|
|
972
|
+
const controller = new AbortController();
|
|
973
|
+
const t = setTimeout(() => controller.abort(), 15_000);
|
|
974
|
+
let res: Response;
|
|
975
|
+
try {
|
|
976
|
+
res = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/json' }, signal: controller.signal });
|
|
977
|
+
} finally { clearTimeout(t); }
|
|
978
|
+
if (!res.ok) return { symbol, found: false, error: priceHistoryMissReason(symbol, 'http', res.status) };
|
|
979
|
+
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 } } };
|
|
980
|
+
const r = data.chart?.result?.[0];
|
|
981
|
+
if (!r?.timestamp?.length) return { symbol, found: false, error: priceHistoryMissReason(symbol, 'empty', data.chart?.error?.description) };
|
|
982
|
+
const closes = r.indicators?.quote?.[0]?.close ?? [];
|
|
983
|
+
// Collapse to (date, close) pairs — the bar timestamp is market open in
|
|
984
|
+
// exchange-local time; the UTC date of that instant is the trading date.
|
|
985
|
+
const bars: { date: string; close: number }[] = [];
|
|
986
|
+
for (let j = 0; j < r.timestamp.length; j++) {
|
|
987
|
+
const c = closes[j];
|
|
988
|
+
if (c == null) continue;
|
|
989
|
+
bars.push({ date: new Date(r.timestamp[j] * 1000).toISOString().slice(0, 10), close: c });
|
|
990
|
+
}
|
|
991
|
+
const days = bars
|
|
992
|
+
.map((b, j) => ({
|
|
993
|
+
date: b.date,
|
|
994
|
+
close: Number(b.close.toFixed(4)),
|
|
995
|
+
previous_close: j > 0 ? Number(bars[j - 1].close.toFixed(4)) : null,
|
|
996
|
+
daily_return_pct: j > 0 && bars[j - 1].close !== 0
|
|
997
|
+
? Number(((b.close / bars[j - 1].close - 1) * 100).toFixed(3))
|
|
998
|
+
: null,
|
|
999
|
+
}))
|
|
1000
|
+
.filter((d) => d.date >= startStr && d.date <= endStr);
|
|
1001
|
+
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'}.` };
|
|
1002
|
+
return { symbol, found: true, currency: r.meta?.currency ?? null, exchange: r.meta?.exchangeName ?? null, days };
|
|
1003
|
+
} catch (e) {
|
|
1004
|
+
return { symbol, found: false, error: priceHistoryMissReason(symbol, 'thrown', (e as Error).message) };
|
|
916
1005
|
}
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
.filter((d) => d.date >= startStr && d.date <= endStr);
|
|
927
|
-
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'}.` };
|
|
928
|
-
return { symbol, found: true, currency: r.meta?.currency ?? null, exchange: r.meta?.exchangeName ?? null, days };
|
|
929
|
-
} catch (e) {
|
|
930
|
-
return { symbol, found: false, error: priceHistoryMissReason(symbol, 'thrown', (e as Error).message) };
|
|
931
|
-
}
|
|
932
|
-
}));
|
|
1006
|
+
}));
|
|
1007
|
+
results.push(...chunkResults);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// Overflow symbols past CHART_MAX were never fetched — say so by name,
|
|
1011
|
+
// the same way a genuine vendor miss is named, rather than letting them
|
|
1012
|
+
// vanish between `requested` and the results array (fleet #2909).
|
|
1013
|
+
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".`;
|
|
1014
|
+
for (const symbol of dropped) results.push({ symbol, found: false, error: droppedReason });
|
|
933
1015
|
|
|
934
1016
|
// Say the partial part OUT LOUD. `requested` vs `lookups` already implied it,
|
|
935
1017
|
// but implying it means the caller has to diff two arrays to notice — and the
|
|
@@ -943,15 +1025,21 @@ async function priceHistory(args: Record<string, unknown>): Promise<unknown> {
|
|
|
943
1025
|
return {
|
|
944
1026
|
start_date: startStr,
|
|
945
1027
|
end_date: endStr,
|
|
946
|
-
|
|
1028
|
+
// Always the actual ask — never the capped/served count. This is the
|
|
1029
|
+
// field fleet #2909 was filed over: it used to read `requested: 8` when
|
|
1030
|
+
// 18 symbols were asked for, which is indistinguishable from "you only
|
|
1031
|
+
// asked for 8".
|
|
1032
|
+
requested: requested.length,
|
|
1033
|
+
served: list.length,
|
|
947
1034
|
// One underlying data request per symbol — the billing lane's per-lookup
|
|
948
1035
|
// metering signal (same rule as ask_pipeworx's billable_lookups).
|
|
949
1036
|
lookups: results.filter((r) => r.found).length,
|
|
1037
|
+
...(dropped.length ? { dropped_symbols: dropped, dropped_note: droppedReason } : {}),
|
|
950
1038
|
...(missing.length
|
|
951
1039
|
? {
|
|
952
1040
|
partial: true,
|
|
953
1041
|
missing,
|
|
954
|
-
partial_note: `Answered ${
|
|
1042
|
+
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.`,
|
|
955
1043
|
}
|
|
956
1044
|
: {}),
|
|
957
1045
|
results,
|
|
@@ -975,12 +1063,84 @@ async function getQuotes(rawSymbols: unknown): Promise<unknown> {
|
|
|
975
1063
|
const prev = q?.previous_close ?? null;
|
|
976
1064
|
const change = price != null && prev != null ? Number((price - prev).toFixed(4)) : null;
|
|
977
1065
|
const change_pct = price != null && prev ? Number((((price - prev) / prev) * 100).toFixed(2)) : null;
|
|
978
|
-
|
|
1066
|
+
const found = q != null && price != null;
|
|
1067
|
+
const row = {
|
|
1068
|
+
symbol,
|
|
1069
|
+
name: q?.name ?? null,
|
|
1070
|
+
price,
|
|
1071
|
+
previous_close: prev,
|
|
1072
|
+
change,
|
|
1073
|
+
change_pct,
|
|
1074
|
+
volume: q?.volume ?? null,
|
|
1075
|
+
day_high: q?.day_high ?? null,
|
|
1076
|
+
day_low: q?.day_low ?? null,
|
|
1077
|
+
currency: q?.currency ?? null,
|
|
1078
|
+
exchange: q?.exchange ?? null,
|
|
1079
|
+
as_of: q?.as_of ?? null,
|
|
1080
|
+
market_state: q?.market_state ?? null,
|
|
1081
|
+
found,
|
|
1082
|
+
};
|
|
1083
|
+
// Fleet #2908: a quote with no volume and no timestamp cannot be told apart
|
|
1084
|
+
// from a stale close, and the judge marked 11 answers down for exactly
|
|
1085
|
+
// that. Every field is always present; when the source has no value we say
|
|
1086
|
+
// which, rather than leaving the caller to notice an absence.
|
|
1087
|
+
const missing_fields = found
|
|
1088
|
+
? (['volume', 'as_of', 'market_state'] as const).filter((k) => row[k] == null)
|
|
1089
|
+
: [];
|
|
1090
|
+
return missing_fields.length
|
|
1091
|
+
? {
|
|
1092
|
+
...row,
|
|
1093
|
+
missing_fields,
|
|
1094
|
+
missing_note: `Yahoo Finance did not supply ${missing_fields.join(', ')} for "${symbol}".`
|
|
1095
|
+
+ (missing_fields.includes('volume') && q?.instrument_type && !['EQUITY', 'ETF', 'MUTUALFUND'].includes(q.instrument_type)
|
|
1096
|
+
? ` ${q.instrument_type} quotes (indices, yields, FX) have no traded volume.`
|
|
1097
|
+
: ''),
|
|
1098
|
+
}
|
|
1099
|
+
: row;
|
|
979
1100
|
});
|
|
980
|
-
return {
|
|
1101
|
+
return {
|
|
1102
|
+
requested: list.length,
|
|
1103
|
+
found: results.filter((r) => r.found).length,
|
|
1104
|
+
retrieved_at: new Date().toISOString(),
|
|
1105
|
+
quotes: results,
|
|
1106
|
+
source: 'Yahoo Finance (keyless)',
|
|
1107
|
+
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.',
|
|
1108
|
+
};
|
|
981
1109
|
}
|
|
982
1110
|
|
|
983
|
-
interface
|
|
1111
|
+
interface TradingPeriod { start?: number; end?: number }
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* Which session the exchange is in at `nowSec`, from Yahoo's currentTradingPeriod.
|
|
1115
|
+
* Returns null when the periods are absent so the caller reports it as missing
|
|
1116
|
+
* rather than guessing "closed". Pure, so it is testable without the network.
|
|
1117
|
+
*/
|
|
1118
|
+
export function marketState(
|
|
1119
|
+
periods: { pre?: TradingPeriod; regular?: TradingPeriod; post?: TradingPeriod } | undefined,
|
|
1120
|
+
nowSec: number,
|
|
1121
|
+
): 'open' | 'pre' | 'post' | 'closed' | null {
|
|
1122
|
+
const reg = periods?.regular;
|
|
1123
|
+
if (reg?.start == null || reg?.end == null) return null;
|
|
1124
|
+
const within = (p?: TradingPeriod) => p?.start != null && p?.end != null && p.end > p.start && nowSec >= p.start && nowSec < p.end;
|
|
1125
|
+
if (within(reg)) return 'open';
|
|
1126
|
+
if (within(periods?.pre)) return 'pre';
|
|
1127
|
+
if (within(periods?.post)) return 'post';
|
|
1128
|
+
return 'closed';
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
interface Quote {
|
|
1132
|
+
price: number | null;
|
|
1133
|
+
previous_close: number | null;
|
|
1134
|
+
name?: string | null;
|
|
1135
|
+
volume?: number | null;
|
|
1136
|
+
day_high?: number | null;
|
|
1137
|
+
day_low?: number | null;
|
|
1138
|
+
currency?: string | null;
|
|
1139
|
+
exchange?: string | null;
|
|
1140
|
+
instrument_type?: string | null;
|
|
1141
|
+
as_of?: string | null;
|
|
1142
|
+
market_state?: 'open' | 'pre' | 'post' | 'closed' | null;
|
|
1143
|
+
}
|
|
984
1144
|
|
|
985
1145
|
async function fetchSpark(symbols: string[]): Promise<Map<string, Quote>> {
|
|
986
1146
|
const out = new Map<string, Quote>();
|
|
@@ -997,15 +1157,35 @@ async function fetchSpark(symbols: string[]): Promise<Map<string, Quote>> {
|
|
|
997
1157
|
const res = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/json' }, signal: controller.signal });
|
|
998
1158
|
if (!res.ok) throw await httpError(res, 'Yahoo spark');
|
|
999
1159
|
const j = (await res.json()) as {
|
|
1000
|
-
spark?: { result?: { symbol?: string; response?: { meta?: {
|
|
1160
|
+
spark?: { result?: { symbol?: string; response?: { meta?: {
|
|
1161
|
+
symbol?: string; regularMarketPrice?: number; chartPreviousClose?: number; previousClose?: number;
|
|
1162
|
+
regularMarketVolume?: number; regularMarketTime?: number; regularMarketDayHigh?: number; regularMarketDayLow?: number;
|
|
1163
|
+
currency?: string; fullExchangeName?: string; exchangeName?: string; instrumentType?: string; shortName?: string; longName?: string;
|
|
1164
|
+
currentTradingPeriod?: { pre?: TradingPeriod; regular?: TradingPeriod; post?: TradingPeriod };
|
|
1165
|
+
} }[] }[] };
|
|
1001
1166
|
};
|
|
1167
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
1168
|
+
const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
1002
1169
|
for (const r of j.spark?.result ?? []) {
|
|
1003
1170
|
const meta = r.response?.[0]?.meta;
|
|
1004
1171
|
const sym = meta?.symbol ?? r.symbol;
|
|
1005
1172
|
if (!sym) continue;
|
|
1173
|
+
// Yahoo reports volume 0 for indices, yields and FX, which have no
|
|
1174
|
+
// traded volume — 0 there means "not applicable", not "nothing traded".
|
|
1175
|
+
const vol = num(meta?.regularMarketVolume);
|
|
1176
|
+
const t = num(meta?.regularMarketTime);
|
|
1006
1177
|
out.set(sym, {
|
|
1007
1178
|
price: meta?.regularMarketPrice ?? null,
|
|
1008
1179
|
previous_close: meta?.chartPreviousClose ?? meta?.previousClose ?? null,
|
|
1180
|
+
name: meta?.longName ?? meta?.shortName ?? null,
|
|
1181
|
+
volume: vol != null && vol > 0 ? vol : null,
|
|
1182
|
+
day_high: num(meta?.regularMarketDayHigh),
|
|
1183
|
+
day_low: num(meta?.regularMarketDayLow),
|
|
1184
|
+
currency: meta?.currency ?? null,
|
|
1185
|
+
exchange: meta?.fullExchangeName ?? meta?.exchangeName ?? null,
|
|
1186
|
+
instrument_type: meta?.instrumentType ?? null,
|
|
1187
|
+
as_of: t != null && t > 0 ? new Date(t * 1000).toISOString() : null,
|
|
1188
|
+
market_state: marketState(meta?.currentTradingPeriod, nowSec),
|
|
1009
1189
|
});
|
|
1010
1190
|
}
|
|
1011
1191
|
} finally {
|
package/src/server.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot
|
|
|
9
9
|
import pack from './index.js';
|
|
10
10
|
|
|
11
11
|
const server = new Server(
|
|
12
|
-
{ name: '@pipeworx/mcp-market-recap', version: '0.1.
|
|
12
|
+
{ name: '@pipeworx/mcp-market-recap', version: '0.1.3' },
|
|
13
13
|
{ capabilities: { tools: {} } },
|
|
14
14
|
);
|
|
15
15
|
|