@pipeworx/mcp-china-exchange-data 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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Live market data straight from the two Chinese mainland exchanges — SZSE (Shenzhen) and SSE (Shanghai), one pack because they are a single jurisdiction. Fills what `@pipeworx/china-stocks` doesn't cover: industry/sector turnover ranking, index daily/weekly bars, a full-market board snapshot for screens, and company bulletins (公告) by code.
4
4
 
5
- Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 1679+ live data sources.
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
  板块 主力资金 净流入 指数 日线 公告 — 深交所 上交所 A股行情数据接口
8
8
 
@@ -81,7 +81,7 @@ directly, instead of just this one's:
81
81
  }
82
82
  ```
83
83
 
84
- Both URLs reach the same gateway and the same 1679+ data sources. The
84
+ Both URLs reach the same gateway and the same 1764+ data sources. The
85
85
  only difference is which pack's tools are listed **directly**; `ask_pipeworx`
86
86
  reaches all of them from either one.
87
87
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pipeworx/mcp-china-exchange-data",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "China Exchange Data MCP (SZSE + SSE). Keyless.",
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-4049c46d530c9aed9be5f23e04c67462f4dd1aed6546ecfe21574c682737c5c9",
30
- "sourceCommit": "60f04b68c347e970c6be94a53439d1364061fc06"
29
+ "sourceHash": "v1-8195c53163250126a605b218ef1356b0b73857bc1a28595cb321f7eff3f25320",
30
+ "sourceCommit": "0ce6905893fe8da0d285be2f05754a4200d67371"
31
31
  }
32
32
  }
package/server.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "name": "io.github.pipeworx-io/china-exchange-data",
4
4
  "title": "China Exchange Data",
5
5
  "description": "China Exchange Data MCP (SZSE + SSE). Keyless.",
6
- "version": "0.1.1",
6
+ "version": "0.1.3",
7
7
  "websiteUrl": "https://pipeworx.io/packs/china-exchange-data",
8
8
  "repository": {
9
9
  "url": "https://github.com/pipeworx-io/mcp-china-exchange-data",
package/src/index.ts CHANGED
@@ -452,7 +452,42 @@ async function fetchWithTimeout(
452
452
  ),
453
453
  );
454
454
  }
455
- throw err;
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
 
@@ -652,14 +687,23 @@ function collapse(s: string): string {
652
687
  * CATALOGID=<id> — used here for CATALOGID=1804_gptj_merge_after (股票行业
653
688
  * 统计, industry turnover statistics; requires txtQueryDate).
654
689
  * - SZSE quote history: www.szse.cn/api/market/ssjjhq/getHistoryData?
655
- * cycleType=<32|33>&marketId=1&code=<code> — daily(32)/weekly(33) OHLC
656
- * bars for any SZSE-listed code OR SZSE index (399xxx). Row shape
690
+ * cycleType=<32|33|37>&marketId=1&code=<code> — daily(32)/weekly(33)/
691
+ * annual(37) OHLC bars for any SZSE-listed code OR SZSE index (399xxx).
692
+ * No begin/end window param — always returns full available history
693
+ * (confirmed back to 1991 for 399xxx indices), sliced client-side to the
694
+ * requested count. (cycleType 34/35/36 = monthly/quarterly/semi-annual,
695
+ * probed but not exposed as tool params — not asked for.) Row shape
657
696
  * (empirically confirmed, not documented by SZSE):
658
- * [date, open, close, low, high, change, change_pct, volume, amount].
697
+ * [date, open, close, low, high, change, change_pct, volume, amount] —
698
+ * change/change_pct are period-over-period (e.g. for cycleType=37, this
699
+ * year's close vs last year's close), computed by SZSE itself.
659
700
  * - SSE public quote system: yunhq.sse.com.cn:32041/v1/sh1/... (plain HTTP
660
- * only — the TLS port is not reachable from here). /dayk/<code> gives
661
- * daily OHLCV bars for any SSE code OR SSE index (000xxx/999xxx), row =
662
- * [date(YYYYMMDD), open, high, low, close, volume, amount].
701
+ * only — the TLS port is not reachable from here). /dayk/<code>?period=
702
+ * day|week|year gives OHLCV bars for any SSE code OR SSE index
703
+ * (000xxx/999xxx); period=year confirmed live back to the 1990s. Row =
704
+ * [date(YYYYMMDD), open, high, low, close, volume, amount] — no
705
+ * change/change_pct field, so ashares_index_bars computes it here from
706
+ * consecutive closes (fetches one extra bar as the prior-close anchor).
663
707
  * /list/exchange/equity gives the full SSE equity board as one paged call
664
708
  * (~2,358 rows): [code, name, last, change_pct, volume, amount] — a
665
709
  * real-time/latest-session snapshot, not per-symbol OHLC, and not
@@ -718,6 +762,24 @@ function round2(v: number | null): number | null {
718
762
  function todayISO(): string {
719
763
  return new Date().toISOString().slice(0, 10);
720
764
  }
765
+ /** True if `dateStr` (YYYY-MM-DD) is the exchange's in-progress CURRENT year,
766
+ * not yet a closed calendar year — both exchanges' annual-bar endpoints append
767
+ * a running bar for the current year dated "today" rather than year-end until
768
+ * the year actually closes (confirmed live 2026-10-10: the newest annual bar
769
+ * for 000001/399001 was dated 2026-10-09, not 2026-12-3x). Treating that as a
770
+ * completed "2026 annual close" would silently push a 6-year request to
771
+ * 2021-2026 instead of 2020-2025 — so `years` mode drops it. Once the
772
+ * calendar reaches the exchanges' actual year-end trading days (~Dec 29-31)
773
+ * the bar IS the real close and is kept. */
774
+ function isPartialCurrentYear(dateStr: string): boolean {
775
+ const today = todayISO();
776
+ if (dateStr.slice(0, 4) !== today.slice(0, 4)) return false;
777
+ return !(today.slice(5, 7) === '12' && Number(today.slice(8, 10)) >= 28);
778
+ }
779
+ function dropTrailingPartialYear<T extends { date: string }>(bars: T[]): T[] {
780
+ if (bars.length === 0) return bars;
781
+ return isPartialCurrentYear(bars[bars.length - 1].date) ? bars.slice(0, -1) : bars;
782
+ }
721
783
  function stripJsonp(text: string): unknown {
722
784
  const m = text.match(/^[^(]*\(([\s\S]*)\)\s*;?\s*$/);
723
785
  const body = m ? m[1] : text;
@@ -786,13 +848,14 @@ const tools: McpToolExport['tools'] = [
786
848
  {
787
849
  name: 'ashares_index_bars',
788
850
  description:
789
- '上证/深证指数日线周线 — Daily or weekly OHLC + volume + turnover bars for a major China A-share index: 上证指数 Shanghai Composite (000001), 上证50 (000016), 沪深300 CSI 300 (000300), 科创50 STAR50 (000688) from SSE; 深证成指 Shenzhen Component (399001), 创业板指 ChiNext (399006) from SZSE. Answers "上证指数000001最近10个交易日OHLC", "Shanghai Composite last 10 days", "深证成指周线", "ChiNext index history". Source: SSE yunhq public quote system (000xxx/999xxx indices) + SZSE ssjjhq history API (399xxx indices), both keyless.',
851
+ '上证/深证指数日线周线年线 — Daily, weekly, or annual OHLC + volume + turnover bars for a major China A-share index: 上证指数 Shanghai Composite (000001), 上证50 (000016), 沪深300 CSI 300 (000300), 科创50 STAR50 (000688) from SSE; 深证成指 Shenzhen Component (399001), 创业板指 ChiNext (399006) from SZSE. Weekly history goes back up to 6 years (312 bars); `years` returns one bar per calendar year (close + year-over-year pct change) for multi-year questions like "5 years of annual closes for CSI 300". Answers "上证指数000001最近10个交易日OHLC", "Shanghai Composite last 10 days", "深证成指周线", "ChiNext index history", "CSI 300 annual close 2020-2025". Source: SSE yunhq public quote system (000xxx/999xxx indices) + SZSE ssjjhq history API (399xxx indices), both keyless.',
790
852
  inputSchema: {
791
853
  type: 'object' as const,
792
854
  properties: {
793
855
  index: { type: 'string', description: 'Index code: 000001 (上证指数), 000016 (上证50), 000300 (沪深300), 000688 (科创50), 399001 (深证成指), 399006 (创业板指). Default 000001.' },
794
- days: { type: 'number', description: 'Number of daily bars to return, 1-250 (default 10). Ignored if weeks is set.' },
795
- weeks: { type: 'number', description: 'Number of weekly bars to return, 1-104. Overrides days when set.' },
856
+ days: { type: 'number', description: 'Number of daily bars to return, 1-250 (default 10). Ignored if weeks or years is set.' },
857
+ weeks: { type: 'number', description: 'Number of weekly bars to return, 1-312 (up to ~6 years). Overrides days when set; ignored if years is set.' },
858
+ years: { type: 'number', description: 'Number of annual bars to return, 1-40 — one bar per calendar year with close and year-over-year pct_change. Overrides weeks and days when set. E.g. years:6 for 2020-2025 inclusive.' },
796
859
  },
797
860
  },
798
861
  },
@@ -869,13 +932,27 @@ async function sectorFlows(args: Record<string, unknown>) {
869
932
  // ── ashares_index_bars ──────────────────────────────────────────────
870
933
  async function indexBars(args: Record<string, unknown>) {
871
934
  const code = typeof args.index === 'string' && args.index.trim() ? args.index.trim() : '000001';
872
- const weeks = args.weeks != null ? Math.min(Math.max(Number(args.weeks), 1), 104) : null;
873
- const days = weeks == null ? Math.min(Math.max(Number(args.days ?? 10), 1), 250) : null;
935
+ // years > weeks > days. Weekly ceiling raised 104 -> 312 (~6y); years is new
936
+ // (both exchanges expose a native annual cycle — confirmed live 2026-10-10:
937
+ // SZSE cycleType=37, SSE yunhq period=year — so this is a real upstream bar
938
+ // size, not a client-side aggregation).
939
+ const years = args.years != null ? Math.min(Math.max(Number(args.years), 1), 40) : null;
940
+ const weeks = years == null && args.weeks != null ? Math.min(Math.max(Number(args.weeks), 1), 312) : null;
941
+ const days = years == null && weeks == null ? Math.min(Math.max(Number(args.days ?? 10), 1), 250) : null;
942
+ const want = (years ?? weeks ?? days) as number;
943
+ const period = years != null ? 'annual' : weeks != null ? 'weekly' : 'daily';
874
944
  const exch = indexExchange(code);
875
945
  const name = exch === 'sse' ? SSE_INDICES[code] : SZSE_INDICES[code];
876
946
 
877
947
  if (exch === 'szse') {
878
- const cycleType = weeks != null ? 33 : 32;
948
+ // SZSE cycleType: 32 daily, 33 weekly, 37 annual (confirmed live; 34/35/36
949
+ // are monthly/quarterly/semi-annual, not used here). getHistoryData has no
950
+ // begin/end window param — it always returns full available history and
951
+ // we slice client-side, which is also how the weekly ceiling raise works
952
+ // with no upstream change. change/change_pct in the row are already
953
+ // period-over-period (confirmed: 2020 annual change = 2020 close - 2019
954
+ // close for 399001), so no recomputation needed here.
955
+ const cycleType = years != null ? 37 : weeks != null ? 33 : 32;
879
956
  const params = new URLSearchParams({ random: String(Math.random()), cycleType: String(cycleType), marketId: '1', code });
880
957
  const res = await pwFetch(`${SZSE_HISTORY}?${params}`, { headers: { Referer: SZSE_REFERER } }, 'SZSE');
881
958
  if (!res.ok) throw await httpError(res, 'SZSE');
@@ -883,36 +960,63 @@ async function indexBars(args: Record<string, unknown>) {
883
960
  if (body.code !== '0' || !body.data?.picupdata) {
884
961
  return { index: code, name: name ?? body.data?.name ?? null, found: false, message: `No SZSE history for index ${code}.` };
885
962
  }
886
- const want = (weeks ?? days) as number;
887
- const bars = body.data.picupdata.slice(-want).map((r) => szseRow(r));
963
+ // For annual, fetch a small margin so dropping the in-progress current
964
+ // year still leaves `want` closed years (full history is always returned
965
+ // here regardless, so this costs nothing extra upstream).
966
+ let bars = body.data.picupdata.slice(-(want + (years != null ? 2 : 0))).map((r) => szseRow(r));
967
+ if (years != null) bars = dropTrailingPartialYear(bars);
968
+ bars = bars.slice(-want);
888
969
  return {
889
970
  index: code,
890
971
  name: name ?? body.data.name ?? null,
891
- period: weeks != null ? 'weekly' : 'daily',
972
+ period,
892
973
  bar_count: bars.length,
893
974
  bars,
894
- source: 'SZSE ssjjhq getHistoryData (cycleType=32 daily / 33 weekly, marketId=1) — undocumented row order confirmed empirically as [date, open, close, low, high, change, change_pct, volume, amount]',
975
+ source: years != null
976
+ ? 'SZSE ssjjhq getHistoryData (cycleType=37 annual, marketId=1) — one bar per calendar year; change/change_pct are SZSE-computed year-over-year close deltas'
977
+ : 'SZSE ssjjhq getHistoryData (cycleType=32 daily / 33 weekly, marketId=1) — undocumented row order confirmed empirically as [date, open, close, low, high, change, change_pct, volume, amount]',
895
978
  };
896
979
  }
897
980
 
898
- // SSE via yunhq: /v1/sh1/dayk/<code>?begin=-N&end=-1&period=day
899
- const want = (weeks ?? days) as number;
900
- const period = weeks != null ? 'week' : 'day';
901
- const url = `${SSE_YUNHQ}/v1/sh1/dayk/${encodeURIComponent(code)}?begin=-${want}&end=-1&period=${period}`;
981
+ // SSE via yunhq: /v1/sh1/dayk/<code>?begin=-N&end=-1&period=day|week|year
982
+ // (period=year confirmed live 2026-10-10 — e.g. 000001 has 37 annual bars
983
+ // back to the 1990s). Two things to compensate for, both confirmed live:
984
+ // (a) yunhq's "begin=-N" is off by one — it returns N-1 bars, not N (this
985
+ // IS the original bug symptom: weeks:104 returned bar_count 103).
986
+ // (b) yunhq's row has no change/change_pct field, so we compute it here
987
+ // from consecutive closes, which needs one extra prior-close anchor.
988
+ // For annual we also need one more so dropping the in-progress current
989
+ // year still leaves `want` closed years.
990
+ const upstreamPeriod = years != null ? 'year' : weeks != null ? 'week' : 'day';
991
+ const offByOne = 1; // (a)
992
+ const anchorExtra = 1; // (b)
993
+ const partialYearExtra = years != null ? 1 : 0;
994
+ const requestN = want + offByOne + anchorExtra + partialYearExtra;
995
+ const url = `${SSE_YUNHQ}/v1/sh1/dayk/${encodeURIComponent(code)}?begin=-${requestN}&end=-1&period=${upstreamPeriod}`;
902
996
  const res = await pwFetch(url, {}, 'SSE');
903
997
  if (!res.ok) throw await httpError(res, 'SSE');
904
998
  const body = (await res.json()) as { code?: string; kline?: unknown[][] };
905
999
  if (!body.kline || body.kline.length === 0) {
906
1000
  return { index: code, name: name ?? null, found: false, message: `No SSE history for index ${code}.` };
907
1001
  }
908
- const bars = body.kline.map((r) => sseRow(r));
1002
+ const parsed = body.kline.map((r) => sseRow(r));
1003
+ let withChange = parsed.map((r, i) => {
1004
+ const prevClose = i > 0 ? parsed[i - 1].close : null;
1005
+ const change = prevClose != null && r.close != null ? round2(r.close - prevClose) : null;
1006
+ const change_pct = prevClose != null && r.close != null && prevClose !== 0 ? round2(((r.close - prevClose) / prevClose) * 100) : null;
1007
+ return { ...r, change, change_pct };
1008
+ });
1009
+ if (years != null) withChange = dropTrailingPartialYear(withChange);
1010
+ const bars = withChange.length > want ? withChange.slice(-want) : withChange;
909
1011
  return {
910
1012
  index: code,
911
1013
  name: name ?? null,
912
- period: weeks != null ? 'weekly' : 'daily',
1014
+ period,
913
1015
  bar_count: bars.length,
914
1016
  bars,
915
- source: 'SSE yunhq public quote system /v1/sh1/dayk (row = [date, open, high, low, close, volume, amount])',
1017
+ source: years != null
1018
+ ? 'SSE yunhq public quote system /v1/sh1/dayk period=year — one bar per calendar year; change/change_pct computed here (close vs prior year close) since SSE does not return them'
1019
+ : 'SSE yunhq public quote system /v1/sh1/dayk (row = [date, open, high, low, close, volume, amount]; change/change_pct computed here from consecutive closes)',
916
1020
  };
917
1021
  }
918
1022
 
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-china-exchange-data', version: '0.1.1' },
12
+ { name: '@pipeworx/mcp-china-exchange-data', version: '0.1.3' },
13
13
  { capabilities: { tools: {} } },
14
14
  );
15
15