@cyanheads/hn-mcp-server 0.5.21 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/AGENTS.md +26 -10
  2. package/CLAUDE.md +26 -10
  3. package/Dockerfile +10 -4
  4. package/README.md +31 -19
  5. package/changelog/0.5.x/0.5.22.md +45 -0
  6. package/changelog/0.6.x/0.6.0.md +38 -0
  7. package/dist/index.js +1 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/mcp-server/tools/definitions/get-stories.tool.d.ts.map +1 -1
  10. package/dist/mcp-server/tools/definitions/get-stories.tool.js +31 -17
  11. package/dist/mcp-server/tools/definitions/get-stories.tool.js.map +1 -1
  12. package/dist/mcp-server/tools/definitions/get-thread.tool.d.ts +4 -2
  13. package/dist/mcp-server/tools/definitions/get-thread.tool.d.ts.map +1 -1
  14. package/dist/mcp-server/tools/definitions/get-thread.tool.js +427 -220
  15. package/dist/mcp-server/tools/definitions/get-thread.tool.js.map +1 -1
  16. package/dist/mcp-server/tools/definitions/get-user.tool.d.ts +1 -0
  17. package/dist/mcp-server/tools/definitions/get-user.tool.d.ts.map +1 -1
  18. package/dist/mcp-server/tools/definitions/get-user.tool.js +176 -106
  19. package/dist/mcp-server/tools/definitions/get-user.tool.js.map +1 -1
  20. package/dist/mcp-server/tools/definitions/search-content.tool.d.ts +106 -0
  21. package/dist/mcp-server/tools/definitions/search-content.tool.d.ts.map +1 -1
  22. package/dist/mcp-server/tools/definitions/search-content.tool.js +532 -194
  23. package/dist/mcp-server/tools/definitions/search-content.tool.js.map +1 -1
  24. package/dist/mcp-server/tools/response-budget.d.ts +111 -0
  25. package/dist/mcp-server/tools/response-budget.d.ts.map +1 -0
  26. package/dist/mcp-server/tools/response-budget.js +204 -0
  27. package/dist/mcp-server/tools/response-budget.js.map +1 -0
  28. package/dist/mcp-server/tools/served-at-cap.d.ts +14 -0
  29. package/dist/mcp-server/tools/served-at-cap.d.ts.map +1 -0
  30. package/dist/mcp-server/tools/served-at-cap.js +16 -0
  31. package/dist/mcp-server/tools/served-at-cap.js.map +1 -0
  32. package/dist/mcp-server/tools/thread-cursor.d.ts +68 -0
  33. package/dist/mcp-server/tools/thread-cursor.d.ts.map +1 -0
  34. package/dist/mcp-server/tools/thread-cursor.js +200 -0
  35. package/dist/mcp-server/tools/thread-cursor.js.map +1 -0
  36. package/dist/services/hn/hn-service.d.ts +73 -12
  37. package/dist/services/hn/hn-service.d.ts.map +1 -1
  38. package/dist/services/hn/hn-service.js +127 -38
  39. package/dist/services/hn/hn-service.js.map +1 -1
  40. package/dist/services/hn/types.d.ts +9 -0
  41. package/dist/services/hn/types.d.ts.map +1 -1
  42. package/package.json +13 -12
  43. package/server.json +3 -3
  44. package/skills/hn-guide/SKILL.md +39 -0
@@ -0,0 +1,200 @@
1
+ /**
2
+ * @fileoverview The hn_get_thread cursor: the unvisited frontier packed as
3
+ * base64url over a varint payload, and a decoder that also reads the JSON
4
+ * format earlier releases issued, accepting every string they accepted.
5
+ * Anything else decodes to `undefined`.
6
+ * @module mcp-server/tools/thread-cursor
7
+ */
8
+ import { z } from '@cyanheads/mcp-ts-core';
9
+ /**
10
+ * Longest cursor accepted, in characters: over four times the longest a real
11
+ * thread issues in either format. A longer one is rejected unread.
12
+ */
13
+ export const MAX_CURSOR_LENGTH = 150_000;
14
+ /** First payload byte of a packed cursor. Any other payload is read as the JSON format. */
15
+ const PACKED_FORMAT = 0x01;
16
+ /** Seven value bits per varint byte, so eight bytes cover every safe integer. */
17
+ const MAX_VARINT_BYTES = 8;
18
+ const utf8 = new TextEncoder();
19
+ const strictUtf8 = new TextDecoder('utf-8', { fatal: true });
20
+ /** Shared by both formats. Each frontier group is `[level, parentId, ...ids]`. */
21
+ const CursorStateSchema = z.object({
22
+ offset: z.number().int().min(0),
23
+ itemId: z.number().int(),
24
+ depth: z.number().int().min(1),
25
+ frontier: z.array(z.array(z.number().int().min(0)).min(3)).min(1),
26
+ depthCut: z.literal(true).optional(),
27
+ story: z.object({ id: z.number().int().min(1), by: z.string().min(1).optional() }).optional(),
28
+ });
29
+ /** The JSON format also carries `limit`, which nothing reads, and never a story. */
30
+ const JsonCursorSchema = CursorStateSchema.omit({ story: true }).extend({
31
+ limit: z.number().int().min(1),
32
+ });
33
+ const zigzag = (n) => (n < 0 ? -2 * n - 1 : 2 * n);
34
+ const unzigzag = (n) => (n % 2 === 0 ? n / 2 : -(n + 1) / 2);
35
+ /**
36
+ * Pack a cursor:
37
+ *
38
+ * ```text
39
+ * 0x01 format version
40
+ * varint itemId, depth, offset, depthCut (0|1), groupCount
41
+ * per (level, parentId) group:
42
+ * varint level, zigzag(parentId - itemId), idCount
43
+ * zigzag varint per ID: delta from the previous ID, the first from parentId
44
+ * varint storyId (0 = none or unresolved)
45
+ * if non-zero: varint author byte length, then the author as UTF-8
46
+ * ```
47
+ *
48
+ * A run of frontier entries sharing a level and parent forms one group, so
49
+ * siblings share a header; entries keep their traversal order.
50
+ */
51
+ export function encodeThreadCursor(state) {
52
+ const out = [PACKED_FORMAT];
53
+ const varint = (n) => {
54
+ let rest = n;
55
+ while (rest >= 0x80) {
56
+ out.push((rest % 0x80) + 0x80);
57
+ rest = Math.floor(rest / 0x80);
58
+ }
59
+ out.push(rest);
60
+ };
61
+ const groups = [];
62
+ for (const { id, level, parentId } of state.frontier) {
63
+ const last = groups.at(-1);
64
+ if (last?.level === level && last.parentId === parentId)
65
+ last.ids.push(id);
66
+ else
67
+ groups.push({ ids: [id], level, parentId });
68
+ }
69
+ varint(state.itemId);
70
+ varint(state.depth);
71
+ varint(state.offset);
72
+ varint(state.depthCut ? 1 : 0);
73
+ varint(groups.length);
74
+ for (const { ids, level, parentId } of groups) {
75
+ varint(level);
76
+ varint(zigzag(parentId - state.itemId));
77
+ varint(ids.length);
78
+ let previous = parentId;
79
+ for (const id of ids) {
80
+ varint(zigzag(id - previous));
81
+ previous = id;
82
+ }
83
+ }
84
+ varint(state.story?.id ?? 0);
85
+ if (state.story) {
86
+ const author = utf8.encode(state.story.by ?? '');
87
+ varint(author.length);
88
+ for (const byte of author)
89
+ out.push(byte);
90
+ }
91
+ return Buffer.from(out).toString('base64url');
92
+ }
93
+ /**
94
+ * Read a packed payload into the shape {@link CursorStateSchema} validates,
95
+ * throwing on one the layout cannot hold. Every loop step consumes at least one
96
+ * byte or throws, so a count the payload claims never drives more work — or a
97
+ * larger allocation — than the payload's own length.
98
+ */
99
+ function unpack(bytes) {
100
+ let at = 1;
101
+ const varint = () => {
102
+ let value = 0;
103
+ let scale = 1;
104
+ for (let i = 0; i < MAX_VARINT_BYTES; i++) {
105
+ const byte = bytes[at++];
106
+ if (byte === undefined)
107
+ throw new Error('truncated');
108
+ value += (byte & 0x7f) * scale;
109
+ if (byte < 0x80) {
110
+ if (value > Number.MAX_SAFE_INTEGER)
111
+ throw new Error('unsafe integer');
112
+ return value;
113
+ }
114
+ scale *= 0x80;
115
+ }
116
+ throw new Error('varint too long');
117
+ };
118
+ const itemId = varint();
119
+ const depth = varint();
120
+ const offset = varint();
121
+ const depthCut = varint();
122
+ if (depthCut > 1)
123
+ throw new Error('depth-cut flag');
124
+ const frontier = [];
125
+ const groupCount = varint();
126
+ for (let g = 0; g < groupCount; g++) {
127
+ const level = varint();
128
+ const parentId = itemId + unzigzag(varint());
129
+ const group = [level, parentId];
130
+ const idCount = varint();
131
+ let previous = parentId;
132
+ for (let k = 0; k < idCount; k++) {
133
+ previous += unzigzag(varint());
134
+ group.push(previous);
135
+ }
136
+ frontier.push(group);
137
+ }
138
+ const storyId = varint();
139
+ let story;
140
+ if (storyId > 0) {
141
+ const length = varint();
142
+ if (length > bytes.length - at)
143
+ throw new Error('author past the payload');
144
+ const by = strictUtf8.decode(bytes.subarray(at, at + length));
145
+ at += length;
146
+ story = { id: storyId, ...(by.length > 0 && { by }) };
147
+ }
148
+ if (at !== bytes.length)
149
+ throw new Error('trailing bytes');
150
+ return {
151
+ offset,
152
+ itemId,
153
+ depth,
154
+ frontier,
155
+ ...(depthCut === 1 && { depthCut: true }),
156
+ ...(story && { story }),
157
+ };
158
+ }
159
+ /**
160
+ * Decode a cursor in either format; `undefined` for anything that is not a
161
+ * state hn_get_thread can resume — a cursor longer than
162
+ * {@link MAX_CURSOR_LENGTH}, a payload in neither format, a truncated or
163
+ * overlong one, or a state that fails validation (a depth past `maxDepth`, a
164
+ * frontier level at or past its depth). Linear in the cursor's length.
165
+ *
166
+ * Both formats are read as earlier releases read the JSON one, so every string
167
+ * they accepted still decodes to the same state: either base64 alphabet,
168
+ * padding or none, characters outside the alphabet (whitespace among them)
169
+ * skipped, and nothing read past the first `=`. A JSON payload is decoded as
170
+ * UTF-8 with invalid bytes replaced, then parsed, so whitespace around its
171
+ * tokens and keys it does not use are ignored.
172
+ */
173
+ export function decodeThreadCursor(cursor, maxDepth) {
174
+ if (cursor.length > MAX_CURSOR_LENGTH)
175
+ return;
176
+ const bytes = Buffer.from(cursor.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
177
+ const packed = bytes[0] === PACKED_FORMAT;
178
+ let decoded;
179
+ try {
180
+ decoded = packed ? unpack(bytes) : JSON.parse(bytes.toString('utf8'));
181
+ }
182
+ catch {
183
+ return;
184
+ }
185
+ const parsed = (packed ? CursorStateSchema : JsonCursorSchema).safeParse(decoded);
186
+ if (!parsed.success)
187
+ return;
188
+ const { offset, itemId, depth, frontier, depthCut, story } = parsed.data;
189
+ if (depth > maxDepth || frontier.some(([level = depth]) => level >= depth))
190
+ return;
191
+ return {
192
+ offset,
193
+ itemId,
194
+ depth,
195
+ depthCut: depthCut === true,
196
+ frontier: frontier.flatMap(([level = 0, parentId = 0, ...ids]) => ids.map((id) => ({ id, level, parentId }))),
197
+ ...(story && { story }),
198
+ };
199
+ }
200
+ //# sourceMappingURL=thread-cursor.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"thread-cursor.js","sourceRoot":"","sources":["../../../src/mcp-server/tools/thread-cursor.ts"],"names":[],"mappings":"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"}
@@ -5,6 +5,17 @@
5
5
  import type { Context } from '@cyanheads/mcp-ts-core';
6
6
  import { McpError } from '@cyanheads/mcp-ts-core/errors';
7
7
  import type { AlgoliaResponse, HnFeedType, HnItem, HnUser } from './types.js';
8
+ /** One attempt's timeout, the body read included. */
9
+ export declare const REQUEST_TIMEOUT_MS = 10000;
10
+ /**
11
+ * The time budget of one tool call that chains batch fetches, counted from the
12
+ * call's start and shared by every request in it, retries and backoff included.
13
+ * One hung fetch's whole retry ladder (four attempts and three backoffs, at most
14
+ * 48.75 s with jitter) fits, so a single slow item settles as it would unbounded;
15
+ * the margin under the MCP SDK client's 60-second default request timeout covers
16
+ * transport and serialization.
17
+ */
18
+ export declare const CALL_BUDGET_MS = 50000;
8
19
  /** Decode numeric and named HTML entities. An undeclared name stays as typed. */
9
20
  export declare function decodeHtmlEntities(text: string): string;
10
21
  /**
@@ -17,7 +28,7 @@ export declare function decodeHtmlEntities(text: string): string;
17
28
  */
18
29
  export declare function stripHtml(html: string): string;
19
30
  /**
20
- * {@link stripHtml} for an Algolia highlight snippet, which is the field's HTML
31
+ * {@link stripHtml} for an Algolia highlight value, which is the field's HTML
21
32
  * with `<em>` inserted around matched terms: the `<em>…</em>` markers survive,
22
33
  * including inside a link collapsed to its href.
23
34
  */
@@ -45,14 +56,28 @@ export declare function dateBoundToEpochMs(bound: string): number;
45
56
  * `10:00:00.5` must still admit an item created at `10:00:00`.
46
57
  */
47
58
  export declare function dateBoundToEpochSeconds(bound: string, rounding?: 'floor' | 'ceil'): number;
59
+ /**
60
+ * The exclusive `created_at_i` bounds a search sends to Algolia, in Unix
61
+ * seconds: the start rounded down and the end rounded up, as
62
+ * {@link dateBoundToEpochSeconds} describes. A bound left unset is absent. The
63
+ * request and the tool's echo of its criteria both read this one conversion.
64
+ */
65
+ export declare function dateRangeToEpochSeconds(range: {
66
+ start?: string | undefined;
67
+ end?: string | undefined;
68
+ }): {
69
+ start?: number;
70
+ end?: number;
71
+ };
48
72
  /**
49
73
  * One slot of a {@link HnService.fetchItems} batch, in input order.
50
74
  *
51
75
  * - `item` — HN returned the item. It may be dead or deleted; callers filter.
52
76
  * - `absent` — HN answered `null`: no item exists under this ID.
53
- * - `failed` — the fetch failed after retries, or never started because an
54
- * earlier slot was rate-limited. `error` is the classified failure. A later
55
- * call may succeed, so a caller reports the ID rather than dropping it.
77
+ * - `failed` — the fetch failed after retries, the batch's deadline cut it, or
78
+ * it never started because an earlier slot was rate-limited or the deadline
79
+ * had passed. `error` is the classified failure. A later call may succeed, so
80
+ * a caller reports the ID rather than dropping it.
56
81
  */
57
82
  export type ItemSlot = {
58
83
  kind: 'item';
@@ -78,6 +103,18 @@ export declare function settlePage(slots: readonly ItemSlot[]): {
78
103
  };
79
104
  /** True for the classified 429 `getJson` throws once retries are spent or the wait outlasts them. */
80
105
  export declare function isRateLimited(err: unknown): err is McpError;
106
+ /**
107
+ * True for a failure a deadline caused: a retry ladder it cut short
108
+ * (`retry_deadline_exceeded`) or a {@link HnService.fetchItems} slot it left
109
+ * unstarted (`deadline_exceeded`). A hung fetch whose whole ladder ran out
110
+ * before the deadline is an ordinary `Timeout`, not a cut.
111
+ */
112
+ export declare function isDeadlineCut(err: unknown): err is McpError;
113
+ /**
114
+ * The sentence, leading space included, a paged tool's failure notice adds when
115
+ * the call's time budget cut some of the failed `slots`; empty when it cut none.
116
+ */
117
+ export declare function timeBudgetCutSentence(slots: readonly ItemSlot[]): string;
81
118
  export declare class HnService {
82
119
  private readonly concurrencyLimit;
83
120
  constructor(concurrencyLimit: number);
@@ -85,19 +122,35 @@ export declare class HnService {
85
122
  * GET a JSON document from an upstream, with retries around the full
86
123
  * fetch-and-parse pipeline and both failure shapes — non-2xx status and
87
124
  * HTML-instead-of-JSON — classified onto a contract reason.
125
+ *
126
+ * `deadline`, a `Date.now()` instant, bounds every attempt and backoff
127
+ * together; past it the call fails with a `Timeout` whose `data.reason` is
128
+ * `retry_deadline_exceeded`, unless the last attempt failed on an HTTP
129
+ * status, which keeps its contract reason. A caller abort still wins over it.
130
+ *
131
+ * An attempt keeps its full request timeout even with less of the deadline
132
+ * left: the deadline's own signal then ends it, so a cut always reads as the
133
+ * deadline's rather than racing an attempt timer set to the same instant.
88
134
  */
89
135
  private getJson;
90
- /** Fetch a single item by ID. Returns null when HN reports the item is missing. */
91
- fetchItem(id: number, ctx: Context): Promise<HnItem | null>;
136
+ /**
137
+ * Fetch a single item by ID. Returns null when HN reports the item is missing.
138
+ * `deadline` bounds its retries as in {@link HnService.getJson}.
139
+ */
140
+ fetchItem(id: number, ctx: Context, deadline?: number): Promise<HnItem | null>;
92
141
  /**
93
142
  * Fetch a user profile by username. Returns null when the user does not exist.
94
143
  * The username is percent-encoded as a single path segment — an unencoded
95
144
  * value containing `/` or dot-segments would otherwise escape the `/user/`
96
- * route and resolve to a different Firebase resource.
145
+ * route and resolve to a different Firebase resource. `deadline` bounds its
146
+ * retries as in {@link HnService.getJson}.
147
+ */
148
+ fetchUser(username: string, ctx: Context, deadline?: number): Promise<HnUser | null>;
149
+ /**
150
+ * Fetch a feed's ID array. Throws on upstream failure after retries.
151
+ * `deadline` bounds its retries as in {@link HnService.getJson}.
97
152
  */
98
- fetchUser(username: string, ctx: Context): Promise<HnUser | null>;
99
- /** Fetch a feed's ID array. Throws on upstream failure after retries. */
100
- fetchFeed(type: HnFeedType, ctx: Context): Promise<number[]>;
153
+ fetchFeed(type: HnFeedType, ctx: Context, deadline?: number): Promise<number[]>;
101
154
  /**
102
155
  * Batch-fetch items with concurrency limiting: one {@link ItemSlot} per ID,
103
156
  * in input order. A per-item failure after exhausted retries fills a
@@ -105,13 +158,21 @@ export declare class HnService {
105
158
  * not per-item:
106
159
  *
107
160
  * - A caller abort rethrows, ending the batch instead of spending the rest of
108
- * the ID list on fetches nobody is waiting for.
161
+ * the ID list on fetches nobody is waiting for — including one that lands
162
+ * during a retry's backoff, which surfaces as the raw abort reason.
109
163
  * - An `upstream_rate_limited` failure stops the batch. Fetches already in
110
164
  * flight settle, and every slot not yet started is reported `failed` with
111
165
  * that error, rather than each running its own retry ladder against a
112
166
  * throttled upstream.
167
+ *
168
+ * With a `deadline` (a `Date.now()` instant), a fetch still retrying when it
169
+ * passes fails its slot with a `Timeout` instead of finishing its ladder, and
170
+ * no fetch starts past it: every slot not yet started is reported `failed`
171
+ * with one `Timeout` (`data.reason: 'deadline_exceeded'`), or with the rate
172
+ * limit when one also stopped the batch. {@link isDeadlineCut} tells both
173
+ * kinds of cut slot from an ordinary failure.
113
174
  */
114
- fetchItems(ids: readonly number[], ctx: Context): Promise<ItemSlot[]>;
175
+ fetchItems(ids: readonly number[], ctx: Context, deadline?: number): Promise<ItemSlot[]>;
115
176
  /**
116
177
  * Search HN via Algolia. Throws on upstream failure after retries. An omitted
117
178
  * `query` sends no `query` param, making a filter-only search; the tags are
@@ -1 +1 @@
1
- {"version":3,"file":"hn-service.d.ts","sourceRoot":"","sources":["../../../src/services/hn/hn-service.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"hn-service.d.ts","sourceRoot":"","sources":["../../../src/services/hn/hn-service.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,wBAAwB,CAAC;AACtD,OAAO,EAGL,QAAQ,EAIT,MAAM,+BAA+B,CAAC;AAIvC,OAAO,KAAK,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AAK9E,qDAAqD;AACrD,eAAO,MAAM,kBAAkB,QAAS,CAAC;AAEzC;;;;;;;GAOG;AACH,eAAO,MAAM,cAAc,QAAS,CAAC;AA4BrC,iFAAiF;AACjF,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAMvD;AAyID;;;;;;;GAOG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE9C;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE1D;AAED,gDAAgD;AAChD,wBAAgB,YAAY,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,GAAG,SAAS,CAEpE;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAOzE;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAGxD;AAED;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CACrC,KAAK,EAAE,MAAM,EACb,QAAQ,GAAE,OAAO,GAAG,MAAgB,GACnC,MAAM,CAER;AAED;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE;IAC7C,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3B,GAAG,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAC1B,GAAG;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,CAKnC;AAED;;;;;;;;;GASG;AACH,MAAM,MAAM,QAAQ,GAChB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAC1C;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,EAAE,EAAE,MAAM,CAAA;CAAE,GAC9B;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,CAAC;AAEnD;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,SAAS,QAAQ,EAAE,GAAG;IAAE,KAAK,EAAE,MAAM,EAAE,CAAC;IAAC,SAAS,EAAE,MAAM,EAAE,CAAA;CAAE,CAc/F;AAED,qGAAqG;AACrG,wBAAgB,aAAa,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,IAAI,QAAQ,CAE3D;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,IAAI,QAAQ,CAM3D;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,SAAS,QAAQ,EAAE,GAAG,MAAM,CAMxE;AA8ID,qBAAa,SAAS;IACpB,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAS;IAE1C,YAAY,gBAAgB,EAAE,MAAM,EAEnC;IAED;;;;;;;;;;;;;OAaG;IACH,OAAO,CAAC,OAAO;IAuBf;;;OAGG;IACH,SAAS,CAAC,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAE7E;IAED;;;;;;OAMG;IACH,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAQnF;IAED;;;OAGG;IACH,SAAS,CAAC,IAAI,EAAE,UAAU,EAAE,GAAG,EAAE,OAAO,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAG9E;IAED;;;;;;;;;;;;;;;;;;;;OAoBG;IACG,UAAU,CAAC,GAAG,EAAE,SAAS,MAAM,EAAE,EAAE,GAAG,EAAE,OAAO,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC,CAoD7F;IAED;;;;OAIG;IACH,MAAM,CACJ,MAAM,EAAE;QACN,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;QAC3B,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;QAC1B,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;QAC5B,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;QAC7B,IAAI,EAAE,WAAW,GAAG,MAAM,CAAC;QAC3B,SAAS,CAAC,EAAE;YAAE,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;YAAC,GAAG,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;SAAE,GAAG,SAAS,CAAC;QACjF,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;QAC/B,KAAK,EAAE,MAAM,CAAC;QACd,IAAI,EAAE,MAAM,CAAC;KACd,EACD,GAAG,EAAE,OAAO,GACX,OAAO,CAAC,eAAe,CAAC,CAsB1B;CACF;AAQD,wBAAgB,aAAa,IAAI,IAAI,CAGpC;AAED,wBAAgB,YAAY,IAAI,SAAS,CAGxC"}
@@ -2,12 +2,22 @@
2
2
  * @fileoverview HN API service — Firebase and Algolia HTTP clients with HTML stripping utilities.
3
3
  * @module services/hn/hn-service
4
4
  */
5
- import { invalidParams, JsonRpcErrorCode, McpError, rateLimited, serviceUnavailable, } from '@cyanheads/mcp-ts-core/errors';
5
+ import { invalidParams, JsonRpcErrorCode, McpError, rateLimited, serviceUnavailable, timeout, } from '@cyanheads/mcp-ts-core/errors';
6
6
  import { fetchWithTimeout, withRetry } from '@cyanheads/mcp-ts-core/utils';
7
7
  import { getServerConfig } from '../../config/server-config.js';
8
8
  const HN_API = 'https://hacker-news.firebaseio.com/v0';
9
9
  const ALGOLIA_API = 'https://hn.algolia.com/api/v1';
10
- const REQUEST_TIMEOUT_MS = 10_000;
10
+ /** One attempt's timeout, the body read included. */
11
+ export const REQUEST_TIMEOUT_MS = 10_000;
12
+ /**
13
+ * The time budget of one tool call that chains batch fetches, counted from the
14
+ * call's start and shared by every request in it, retries and backoff included.
15
+ * One hung fetch's whole retry ladder (four attempts and three backoffs, at most
16
+ * 48.75 s with jitter) fits, so a single slow item settles as it would unbounded;
17
+ * the margin under the MCP SDK client's 60-second default request timeout covers
18
+ * transport and serialization.
19
+ */
20
+ export const CALL_BUDGET_MS = 50_000;
11
21
  // ---------------------------------------------------------------------------
12
22
  // HTML utilities
13
23
  // ---------------------------------------------------------------------------
@@ -65,8 +75,7 @@ const PARTIAL_ENTITY = /&#?[0-9A-Za-z]*$/;
65
75
  * Replace each `open … close` span, leftmost opener first and the nearest closer
66
76
  * after it, with `render(opener, inner)`. An opener with no closer after it ends
67
77
  * the scan — no later opener can find one either — so every search runs forward
68
- * once and a pass stays linear, where a lazy `open([\s\S]*?)close` regex rescans
69
- * to the end for each unclosed opener.
78
+ * once and a pass stays linear.
70
79
  */
71
80
  function replaceSpans(text, open, close, render) {
72
81
  let out = '';
@@ -85,8 +94,7 @@ function replaceSpans(text, open, close, render) {
85
94
  }
86
95
  /**
87
96
  * Apply `rewrite` to the text up to its last `>`. No tag can close past that
88
- * point, and leaving it out keeps a regex from rescanning to the end of the
89
- * string for every unmatched `<` there.
97
+ * point, so the rewrite never reads beyond it.
90
98
  */
91
99
  function throughLastTag(text, rewrite) {
92
100
  const end = text.lastIndexOf('>') + 1;
@@ -169,7 +177,7 @@ export function stripHtml(html) {
169
177
  return toPlainText(html, false);
170
178
  }
171
179
  /**
172
- * {@link stripHtml} for an Algolia highlight snippet, which is the field's HTML
180
+ * {@link stripHtml} for an Algolia highlight value, which is the field's HTML
173
181
  * with `<em>` inserted around matched terms: the `<em>…</em>` markers survive,
174
182
  * including inside a link collapsed to its href.
175
183
  */
@@ -215,6 +223,18 @@ export function dateBoundToEpochMs(bound) {
215
223
  export function dateBoundToEpochSeconds(bound, rounding = 'floor') {
216
224
  return Math[rounding](dateBoundToEpochMs(bound) / 1000);
217
225
  }
226
+ /**
227
+ * The exclusive `created_at_i` bounds a search sends to Algolia, in Unix
228
+ * seconds: the start rounded down and the end rounded up, as
229
+ * {@link dateBoundToEpochSeconds} describes. A bound left unset is absent. The
230
+ * request and the tool's echo of its criteria both read this one conversion.
231
+ */
232
+ export function dateRangeToEpochSeconds(range) {
233
+ return {
234
+ ...(range.start ? { start: dateBoundToEpochSeconds(range.start) } : {}),
235
+ ...(range.end ? { end: dateBoundToEpochSeconds(range.end, 'ceil') } : {}),
236
+ };
237
+ }
218
238
  /**
219
239
  * Settle one page of a batch for the paged tools: the live items in input
220
240
  * order, and the IDs whose fetch failed. Dead, deleted, and absent slots drop
@@ -242,6 +262,29 @@ export function settlePage(slots) {
242
262
  export function isRateLimited(err) {
243
263
  return err instanceof McpError && err.data?.reason === 'upstream_rate_limited';
244
264
  }
265
+ /**
266
+ * True for a failure a deadline caused: a retry ladder it cut short
267
+ * (`retry_deadline_exceeded`) or a {@link HnService.fetchItems} slot it left
268
+ * unstarted (`deadline_exceeded`). A hung fetch whose whole ladder ran out
269
+ * before the deadline is an ordinary `Timeout`, not a cut.
270
+ */
271
+ export function isDeadlineCut(err) {
272
+ return (err instanceof McpError &&
273
+ err.code === JsonRpcErrorCode.Timeout &&
274
+ (err.data?.reason === 'retry_deadline_exceeded' || err.data?.reason === 'deadline_exceeded'));
275
+ }
276
+ /**
277
+ * The sentence, leading space included, a paged tool's failure notice adds when
278
+ * the call's time budget cut some of the failed `slots`; empty when it cut none.
279
+ */
280
+ export function timeBudgetCutSentence(slots) {
281
+ const failed = slots.filter((slot) => slot.kind === 'failed');
282
+ const cut = failed.filter((slot) => isDeadlineCut(slot.error)).length;
283
+ if (cut === 0)
284
+ return '';
285
+ const which = cut < failed.length ? `${cut} of them` : failed.length === 1 ? 'it' : 'they';
286
+ return ` This call's ${CALL_BUDGET_MS / 1000}-second time budget ran out before ${which} loaded.`;
287
+ }
245
288
  /** Detect HTML error bodies returned by upstream APIs under rate limiting or maintenance. */
246
289
  function isHtmlErrorBody(text) {
247
290
  return /^\s*<(!DOCTYPE\s+html|html[\s>])/i.test(text);
@@ -340,18 +383,24 @@ function upstreamFailureFor(upstream, status, cause) {
340
383
  * (`errorSource: 'FetchHttpError'`) are rewritten. The message drops the
341
384
  * upstream URL and the verbatim response body, which stay on the `cause` chain
342
385
  * for server-side logs.
386
+ *
387
+ * A retry deadline that cuts the ladder short ends it with its own `Timeout`,
388
+ * the last attempt's failure as its cause. When that attempt failed on an HTTP
389
+ * status, the status is classified as an exhausted ladder's would be: a 429
390
+ * still reads as `upstream_rate_limited`, so a batch stops on it.
343
391
  */
344
392
  async function withUpstreamHttpErrors(upstream, fn) {
345
393
  try {
346
394
  return await fn();
347
395
  }
348
396
  catch (err) {
349
- if (!(err instanceof McpError) || err.data?.errorSource !== 'FetchHttpError')
397
+ const failure = err instanceof McpError && err.data?.reason === 'retry_deadline_exceeded' ? err.cause : err;
398
+ if (!(failure instanceof McpError) || failure.data?.errorSource !== 'FetchHttpError')
350
399
  throw err;
351
- const status = err.data.status;
400
+ const status = failure.data.status;
352
401
  if (typeof status !== 'number')
353
402
  throw err;
354
- throw upstreamFailureFor(upstream, status, err);
403
+ throw upstreamFailureFor(upstream, status, failure);
355
404
  }
356
405
  }
357
406
  // ---------------------------------------------------------------------------
@@ -366,32 +415,51 @@ export class HnService {
366
415
  * GET a JSON document from an upstream, with retries around the full
367
416
  * fetch-and-parse pipeline and both failure shapes — non-2xx status and
368
417
  * HTML-instead-of-JSON — classified onto a contract reason.
418
+ *
419
+ * `deadline`, a `Date.now()` instant, bounds every attempt and backoff
420
+ * together; past it the call fails with a `Timeout` whose `data.reason` is
421
+ * `retry_deadline_exceeded`, unless the last attempt failed on an HTTP
422
+ * status, which keeps its contract reason. A caller abort still wins over it.
423
+ *
424
+ * An attempt keeps its full request timeout even with less of the deadline
425
+ * left: the deadline's own signal then ends it, so a cut always reads as the
426
+ * deadline's rather than racing an attempt timer set to the same instant.
369
427
  */
370
- getJson(upstream, operation, url, ctx) {
371
- return withUpstreamHttpErrors(upstream, () => withRetry(async () => {
372
- const res = await fetchWithTimeout(url, REQUEST_TIMEOUT_MS, ctx, {
373
- signal: ctx.signal,
374
- });
428
+ getJson(upstream, operation, url, ctx, deadline) {
429
+ return withUpstreamHttpErrors(upstream, () => withRetry(async ({ signal }) => {
430
+ const res = await fetchWithTimeout(url, REQUEST_TIMEOUT_MS, ctx, { signal });
375
431
  return parseJsonBody(await res.text(), upstream);
376
- }, { operation, context: ctx, signal: ctx.signal }));
432
+ }, {
433
+ operation,
434
+ context: ctx,
435
+ signal: ctx.signal,
436
+ ...(deadline !== undefined && { deadlineMs: Math.max(0, deadline - Date.now()) }),
437
+ }));
377
438
  }
378
- /** Fetch a single item by ID. Returns null when HN reports the item is missing. */
379
- fetchItem(id, ctx) {
380
- return this.getJson('HN API', 'hn.fetchItem', `${HN_API}/item/${id}.json`, ctx);
439
+ /**
440
+ * Fetch a single item by ID. Returns null when HN reports the item is missing.
441
+ * `deadline` bounds its retries as in {@link HnService.getJson}.
442
+ */
443
+ fetchItem(id, ctx, deadline) {
444
+ return this.getJson('HN API', 'hn.fetchItem', `${HN_API}/item/${id}.json`, ctx, deadline);
381
445
  }
382
446
  /**
383
447
  * Fetch a user profile by username. Returns null when the user does not exist.
384
448
  * The username is percent-encoded as a single path segment — an unencoded
385
449
  * value containing `/` or dot-segments would otherwise escape the `/user/`
386
- * route and resolve to a different Firebase resource.
450
+ * route and resolve to a different Firebase resource. `deadline` bounds its
451
+ * retries as in {@link HnService.getJson}.
387
452
  */
388
- fetchUser(username, ctx) {
389
- return this.getJson('HN API', 'hn.fetchUser', `${HN_API}/user/${encodeURIComponent(username)}.json`, ctx);
453
+ fetchUser(username, ctx, deadline) {
454
+ return this.getJson('HN API', 'hn.fetchUser', `${HN_API}/user/${encodeURIComponent(username)}.json`, ctx, deadline);
390
455
  }
391
- /** Fetch a feed's ID array. Throws on upstream failure after retries. */
392
- fetchFeed(type, ctx) {
456
+ /**
457
+ * Fetch a feed's ID array. Throws on upstream failure after retries.
458
+ * `deadline` bounds its retries as in {@link HnService.getJson}.
459
+ */
460
+ fetchFeed(type, ctx, deadline) {
393
461
  const endpoint = type === 'jobs' ? 'jobstories' : `${type}stories`;
394
- return this.getJson('HN API', 'hn.fetchFeed', `${HN_API}/${endpoint}.json`, ctx);
462
+ return this.getJson('HN API', 'hn.fetchFeed', `${HN_API}/${endpoint}.json`, ctx, deadline);
395
463
  }
396
464
  /**
397
465
  * Batch-fetch items with concurrency limiting: one {@link ItemSlot} per ID,
@@ -400,28 +468,38 @@ export class HnService {
400
468
  * not per-item:
401
469
  *
402
470
  * - A caller abort rethrows, ending the batch instead of spending the rest of
403
- * the ID list on fetches nobody is waiting for.
471
+ * the ID list on fetches nobody is waiting for — including one that lands
472
+ * during a retry's backoff, which surfaces as the raw abort reason.
404
473
  * - An `upstream_rate_limited` failure stops the batch. Fetches already in
405
474
  * flight settle, and every slot not yet started is reported `failed` with
406
475
  * that error, rather than each running its own retry ladder against a
407
476
  * throttled upstream.
477
+ *
478
+ * With a `deadline` (a `Date.now()` instant), a fetch still retrying when it
479
+ * passes fails its slot with a `Timeout` instead of finishing its ladder, and
480
+ * no fetch starts past it: every slot not yet started is reported `failed`
481
+ * with one `Timeout` (`data.reason: 'deadline_exceeded'`), or with the rate
482
+ * limit when one also stopped the batch. {@link isDeadlineCut} tells both
483
+ * kinds of cut slot from an ordinary failure.
408
484
  */
409
- async fetchItems(ids, ctx) {
485
+ async fetchItems(ids, ctx, deadline) {
410
486
  const slots = new Array(ids.length);
411
487
  let rateLimit;
412
488
  let next = 0;
489
+ const pastDeadline = () => deadline !== undefined && Date.now() >= deadline;
413
490
  const worker = async () => {
414
- while (!rateLimit && next < ids.length) {
491
+ while (!rateLimit && !pastDeadline() && next < ids.length) {
415
492
  const i = next++;
416
493
  const id = ids[i];
417
494
  if (id == null)
418
495
  continue;
419
496
  try {
420
- const item = await this.fetchItem(id, ctx);
497
+ const item = await this.fetchItem(id, ctx, deadline);
421
498
  slots[i] = item ? { kind: 'item', id, item } : { kind: 'absent', id };
422
499
  }
423
500
  catch (error) {
424
- if (error instanceof McpError && error.code === JsonRpcErrorCode.RequestCancelled) {
501
+ if (ctx.signal.aborted ||
502
+ (error instanceof McpError && error.code === JsonRpcErrorCode.RequestCancelled)) {
425
503
  throw error;
426
504
  }
427
505
  slots[i] = { kind: 'failed', id, error };
@@ -437,12 +515,24 @@ export class HnService {
437
515
  const workerCount = Math.min(this.concurrencyLimit, ids.length);
438
516
  await Promise.all(Array.from({ length: workerCount }, worker));
439
517
  const skipped = ids.length - next;
440
- if (rateLimit && skipped > 0) {
518
+ let unstarted = rateLimit;
519
+ if (skipped > 0 && rateLimit) {
441
520
  ctx.log.warning('Batch stopped: upstream rate-limited, remaining items not fetched', {
442
521
  skipped,
443
522
  });
444
523
  }
445
- return ids.map((id, i) => slots[i] ?? { kind: 'failed', id, error: rateLimit });
524
+ else if (skipped > 0) {
525
+ unstarted = timeout('The time budget ran out before this item was fetched.', {
526
+ reason: 'deadline_exceeded',
527
+ recovery: {
528
+ hint: "The HN API was too slow to answer within this call's time budget. Retry the same call after a short delay.",
529
+ },
530
+ });
531
+ ctx.log.warning('Batch stopped: time budget spent, remaining items not fetched', {
532
+ skipped,
533
+ });
534
+ }
535
+ return ids.map((id, i) => slots[i] ?? { kind: 'failed', id, error: unstarted });
446
536
  }
447
537
  /**
448
538
  * Search HN via Algolia. Throws on upstream failure after retries. An omitted
@@ -468,12 +558,11 @@ export class HnService {
468
558
  const numericFilters = [];
469
559
  if (params.minPoints != null)
470
560
  numericFilters.push(`points>=${params.minPoints}`);
471
- if (params.dateRange?.start) {
472
- numericFilters.push(`created_at_i>${dateBoundToEpochSeconds(params.dateRange.start)}`);
473
- }
474
- if (params.dateRange?.end) {
475
- numericFilters.push(`created_at_i<${dateBoundToEpochSeconds(params.dateRange.end, 'ceil')}`);
476
- }
561
+ const bounds = params.dateRange ? dateRangeToEpochSeconds(params.dateRange) : {};
562
+ if (bounds.start != null)
563
+ numericFilters.push(`created_at_i>${bounds.start}`);
564
+ if (bounds.end != null)
565
+ numericFilters.push(`created_at_i<${bounds.end}`);
477
566
  if (numericFilters.length)
478
567
  url.searchParams.set('numericFilters', numericFilters.join(','));
479
568
  return this.getJson('Algolia', 'hn.search', url, ctx);