@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.
- package/AGENTS.md +26 -10
- package/CLAUDE.md +26 -10
- package/Dockerfile +10 -4
- package/README.md +31 -19
- package/changelog/0.5.x/0.5.22.md +45 -0
- package/changelog/0.6.x/0.6.0.md +38 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-stories.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/get-stories.tool.js +31 -17
- package/dist/mcp-server/tools/definitions/get-stories.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-thread.tool.d.ts +4 -2
- package/dist/mcp-server/tools/definitions/get-thread.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/get-thread.tool.js +427 -220
- package/dist/mcp-server/tools/definitions/get-thread.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-user.tool.d.ts +1 -0
- package/dist/mcp-server/tools/definitions/get-user.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/get-user.tool.js +176 -106
- package/dist/mcp-server/tools/definitions/get-user.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/search-content.tool.d.ts +106 -0
- package/dist/mcp-server/tools/definitions/search-content.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-content.tool.js +532 -194
- package/dist/mcp-server/tools/definitions/search-content.tool.js.map +1 -1
- package/dist/mcp-server/tools/response-budget.d.ts +111 -0
- package/dist/mcp-server/tools/response-budget.d.ts.map +1 -0
- package/dist/mcp-server/tools/response-budget.js +204 -0
- package/dist/mcp-server/tools/response-budget.js.map +1 -0
- package/dist/mcp-server/tools/served-at-cap.d.ts +14 -0
- package/dist/mcp-server/tools/served-at-cap.d.ts.map +1 -0
- package/dist/mcp-server/tools/served-at-cap.js +16 -0
- package/dist/mcp-server/tools/served-at-cap.js.map +1 -0
- package/dist/mcp-server/tools/thread-cursor.d.ts +68 -0
- package/dist/mcp-server/tools/thread-cursor.d.ts.map +1 -0
- package/dist/mcp-server/tools/thread-cursor.js +200 -0
- package/dist/mcp-server/tools/thread-cursor.js.map +1 -0
- package/dist/services/hn/hn-service.d.ts +73 -12
- package/dist/services/hn/hn-service.d.ts.map +1 -1
- package/dist/services/hn/hn-service.js +127 -38
- package/dist/services/hn/hn-service.js.map +1 -1
- package/dist/services/hn/types.d.ts +9 -0
- package/dist/services/hn/types.d.ts.map +1 -1
- package/package.json +13 -12
- package/server.json +3 -3
- 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
|
|
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,
|
|
54
|
-
* earlier slot was rate-limited
|
|
55
|
-
*
|
|
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
|
-
/**
|
|
91
|
-
|
|
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
|
-
|
|
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":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,wBAAwB,CAAC;AACtD,OAAO,EAGL,QAAQ,
|
|
1
|
+
{"version":3,"file":"hn-service.d.ts","sourceRoot":"","sources":["../../../src/services/hn/hn-service.ts"],"names":[],"mappings":"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"}
|
|
@@ -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
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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 =
|
|
400
|
+
const status = failure.data.status;
|
|
352
401
|
if (typeof status !== 'number')
|
|
353
402
|
throw err;
|
|
354
|
-
throw upstreamFailureFor(upstream, status,
|
|
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
|
-
}, {
|
|
432
|
+
}, {
|
|
433
|
+
operation,
|
|
434
|
+
context: ctx,
|
|
435
|
+
signal: ctx.signal,
|
|
436
|
+
...(deadline !== undefined && { deadlineMs: Math.max(0, deadline - Date.now()) }),
|
|
437
|
+
}));
|
|
377
438
|
}
|
|
378
|
-
/**
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
/**
|
|
392
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
if (
|
|
475
|
-
numericFilters.push(`created_at_i<${
|
|
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);
|