@universal-mcp-toolkit/server-hackernews 0.1.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.
@@ -0,0 +1,33 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/json",
3
+ "name": "hackernews",
4
+ "title": "Hacker News MCP Server",
5
+ "description": "Top stories, search, and thread tools for Hacker News.",
6
+ "version": "0.1.0",
7
+ "packageName": "@universal-mcp-toolkit/server-hackernews",
8
+ "homepage": "https://github.com/universal-mcp-toolkit/universal-mcp-toolkit",
9
+ "transports": [
10
+ "stdio",
11
+ "sse"
12
+ ],
13
+ "authentication": {
14
+ "mode": "environment-variables",
15
+ "required": []
16
+ },
17
+ "capabilities": {
18
+ "tools": true,
19
+ "resources": true,
20
+ "prompts": true
21
+ },
22
+ "tools": [
23
+ "get_top_stories",
24
+ "search_stories",
25
+ "get_item_thread"
26
+ ],
27
+ "resources": [
28
+ "trends"
29
+ ],
30
+ "prompts": [
31
+ "community-digest"
32
+ ]
33
+ }
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 universal-mcp-toolkit
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,61 @@
1
+ import * as _universal_mcp_toolkit_core from '@universal-mcp-toolkit/core';
2
+ import { ToolkitServer, ToolkitServerMetadata } from '@universal-mcp-toolkit/core';
3
+ import { z } from 'zod';
4
+
5
+ declare const metadata: ToolkitServerMetadata;
6
+ declare const serverCard: _universal_mcp_toolkit_core.ToolkitServerCard;
7
+ declare const storySummarySchema: z.ZodObject<{
8
+ id: z.ZodNumber;
9
+ title: z.ZodString;
10
+ url: z.ZodNullable<z.ZodString>;
11
+ author: z.ZodNullable<z.ZodString>;
12
+ score: z.ZodNullable<z.ZodNumber>;
13
+ commentCount: z.ZodNullable<z.ZodNumber>;
14
+ publishedAt: z.ZodNullable<z.ZodString>;
15
+ text: z.ZodNullable<z.ZodString>;
16
+ }, z.core.$strip>;
17
+ type HackerNewsStorySummary = z.infer<typeof storySummarySchema>;
18
+ interface HackerNewsThreadComment {
19
+ id: number;
20
+ author: string | null;
21
+ text: string | null;
22
+ publishedAt: string | null;
23
+ replies: ReadonlyArray<HackerNewsThreadComment>;
24
+ }
25
+ declare const threadSchema: z.ZodObject<{
26
+ id: z.ZodNumber;
27
+ title: z.ZodString;
28
+ url: z.ZodNullable<z.ZodString>;
29
+ author: z.ZodNullable<z.ZodString>;
30
+ score: z.ZodNullable<z.ZodNumber>;
31
+ commentCount: z.ZodNullable<z.ZodNumber>;
32
+ publishedAt: z.ZodNullable<z.ZodString>;
33
+ text: z.ZodNullable<z.ZodString>;
34
+ replies: z.ZodArray<z.ZodType<HackerNewsThreadComment, unknown, z.core.$ZodTypeInternals<HackerNewsThreadComment, unknown>>>;
35
+ }, z.core.$strip>;
36
+ type HackerNewsThread = z.infer<typeof threadSchema>;
37
+ interface HackerNewsClient {
38
+ getTopStories(limit: number): Promise<ReadonlyArray<HackerNewsStorySummary>>;
39
+ searchStories(input: {
40
+ query: string;
41
+ limit: number;
42
+ }): Promise<ReadonlyArray<HackerNewsStorySummary>>;
43
+ getThread(input: {
44
+ itemId: number;
45
+ depth: number;
46
+ maxChildren: number;
47
+ }): Promise<HackerNewsThread>;
48
+ }
49
+ interface CreateHackerNewsServerOptions {
50
+ client?: HackerNewsClient;
51
+ env?: NodeJS.ProcessEnv;
52
+ fetch?: typeof fetch;
53
+ }
54
+ declare class HackerNewsServer extends ToolkitServer {
55
+ private readonly client;
56
+ constructor(client: HackerNewsClient);
57
+ }
58
+ declare function createServer(options?: CreateHackerNewsServerOptions): Promise<HackerNewsServer>;
59
+ declare function main(argv?: readonly string[]): Promise<void>;
60
+
61
+ export { type CreateHackerNewsServerOptions, type HackerNewsClient, HackerNewsServer, type HackerNewsStorySummary, type HackerNewsThread, type HackerNewsThreadComment, createServer, main, metadata, serverCard };
package/dist/index.js ADDED
@@ -0,0 +1,372 @@
1
+ // src/index.ts
2
+ import {
3
+ createServerCard,
4
+ defineTool,
5
+ ExternalServiceError,
6
+ loadEnv,
7
+ parseRuntimeOptions,
8
+ runToolkitServer,
9
+ ToolkitServer
10
+ } from "@universal-mcp-toolkit/core";
11
+ import { resolve } from "path";
12
+ import { fileURLToPath } from "url";
13
+ import { z } from "zod";
14
+ var toolNames = ["get_top_stories", "search_stories", "get_item_thread"];
15
+ var resourceNames = ["trends"];
16
+ var promptNames = ["community-digest"];
17
+ var metadata = {
18
+ id: "hackernews",
19
+ title: "Hacker News MCP Server",
20
+ description: "Top stories, search, and thread tools for Hacker News.",
21
+ version: "0.1.0",
22
+ packageName: "@universal-mcp-toolkit/server-hackernews",
23
+ homepage: "https://github.com/universal-mcp-toolkit/universal-mcp-toolkit",
24
+ repositoryUrl: "https://github.com/universal-mcp-toolkit/universal-mcp-toolkit",
25
+ documentationUrl: "https://github.com/universal-mcp-toolkit/universal-mcp-toolkit/tree/main/servers/hackernews",
26
+ envVarNames: [],
27
+ transports: ["stdio", "sse"],
28
+ toolNames,
29
+ resourceNames,
30
+ promptNames
31
+ };
32
+ var serverCard = createServerCard(metadata);
33
+ var envShape = {
34
+ HACKERNEWS_API_BASE_URL: z.string().url().optional(),
35
+ HACKERNEWS_SEARCH_BASE_URL: z.string().url().optional()
36
+ };
37
+ var storySummarySchema = z.object({
38
+ id: z.number().int().nonnegative(),
39
+ title: z.string(),
40
+ url: z.string().nullable(),
41
+ author: z.string().nullable(),
42
+ score: z.number().int().nonnegative().nullable(),
43
+ commentCount: z.number().int().nonnegative().nullable(),
44
+ publishedAt: z.string().nullable(),
45
+ text: z.string().nullable()
46
+ });
47
+ var threadCommentSchema = z.lazy(
48
+ () => z.object({
49
+ id: z.number().int().nonnegative(),
50
+ author: z.string().nullable(),
51
+ text: z.string().nullable(),
52
+ publishedAt: z.string().nullable(),
53
+ replies: z.array(threadCommentSchema)
54
+ })
55
+ );
56
+ var threadSchema = storySummarySchema.extend({
57
+ replies: z.array(threadCommentSchema)
58
+ });
59
+ var hnItemSchema = z.object({
60
+ id: z.number().int().nonnegative(),
61
+ type: z.string().optional(),
62
+ title: z.string().optional(),
63
+ url: z.string().optional(),
64
+ by: z.string().optional(),
65
+ score: z.number().int().nonnegative().optional(),
66
+ descendants: z.number().int().nonnegative().optional(),
67
+ time: z.number().int().nonnegative().optional(),
68
+ text: z.string().optional(),
69
+ kids: z.array(z.number().int().nonnegative()).optional(),
70
+ deleted: z.boolean().optional(),
71
+ dead: z.boolean().optional()
72
+ });
73
+ var hnSearchResponseSchema = z.object({
74
+ hits: z.array(
75
+ z.object({
76
+ objectID: z.string(),
77
+ title: z.string().nullable().optional(),
78
+ url: z.string().nullable().optional(),
79
+ author: z.string().nullable().optional(),
80
+ points: z.number().int().nonnegative().nullable().optional(),
81
+ num_comments: z.number().int().nonnegative().nullable().optional(),
82
+ created_at: z.string().nullable().optional(),
83
+ story_text: z.string().nullable().optional()
84
+ })
85
+ )
86
+ });
87
+ function isHnItem(value) {
88
+ return value !== null;
89
+ }
90
+ function resolveEnv(source = process.env) {
91
+ return loadEnv(envShape, source);
92
+ }
93
+ function toNullableString(value) {
94
+ return typeof value === "string" && value.length > 0 ? value : null;
95
+ }
96
+ function toNullableTimestamp(value) {
97
+ return typeof value === "number" ? new Date(value * 1e3).toISOString() : null;
98
+ }
99
+ var FetchHackerNewsClient = class {
100
+ apiBaseUrl;
101
+ searchBaseUrl;
102
+ fetchImpl;
103
+ constructor(options) {
104
+ this.apiBaseUrl = (options.apiBaseUrl ?? "https://hacker-news.firebaseio.com/v0").replace(/\/+$/, "");
105
+ this.searchBaseUrl = (options.searchBaseUrl ?? "https://hn.algolia.com/api/v1").replace(/\/+$/, "");
106
+ this.fetchImpl = options.fetch;
107
+ }
108
+ async getTopStories(limit) {
109
+ const ids = await this.fetchJson(new URL(`${this.apiBaseUrl}/topstories.json`), z.array(z.number().int().nonnegative()));
110
+ const items = await Promise.all(ids.slice(0, limit).map((id) => this.fetchItem(id)));
111
+ return items.filter((item) => isHnItem(item) && item.type === "story").map((item) => this.toStorySummary(item));
112
+ }
113
+ async searchStories(input) {
114
+ const url = new URL(`${this.searchBaseUrl}/search`);
115
+ url.searchParams.set("tags", "story");
116
+ url.searchParams.set("query", input.query);
117
+ url.searchParams.set("hitsPerPage", String(input.limit));
118
+ const payload = await this.fetchJson(url, hnSearchResponseSchema);
119
+ return payload.hits.map((hit) => ({
120
+ id: Number.parseInt(hit.objectID, 10),
121
+ title: hit.title ?? "Untitled story",
122
+ url: toNullableString(hit.url),
123
+ author: toNullableString(hit.author),
124
+ score: hit.points ?? null,
125
+ commentCount: hit.num_comments ?? null,
126
+ publishedAt: toNullableString(hit.created_at),
127
+ text: toNullableString(hit.story_text)
128
+ }));
129
+ }
130
+ async getThread(input) {
131
+ const root = await this.fetchItem(input.itemId);
132
+ if (!root) {
133
+ throw new ExternalServiceError(`Hacker News item ${input.itemId} was not found.`, {
134
+ statusCode: 404
135
+ });
136
+ }
137
+ return {
138
+ ...this.toStorySummary(root),
139
+ replies: await this.fetchReplies(root.kids ?? [], input.depth, input.maxChildren)
140
+ };
141
+ }
142
+ async fetchReplies(ids, depth, maxChildren) {
143
+ if (depth <= 0) {
144
+ return [];
145
+ }
146
+ const children = await Promise.all(ids.slice(0, maxChildren).map((id) => this.fetchItem(id)));
147
+ const comments = children.filter((item) => isHnItem(item) && item.type === "comment");
148
+ return Promise.all(
149
+ comments.map(async (comment) => ({
150
+ id: comment.id,
151
+ author: toNullableString(comment.by),
152
+ text: toNullableString(comment.text),
153
+ publishedAt: toNullableTimestamp(comment.time),
154
+ replies: await this.fetchReplies(comment.kids ?? [], depth - 1, maxChildren)
155
+ }))
156
+ );
157
+ }
158
+ toStorySummary(item) {
159
+ return {
160
+ id: item.id,
161
+ title: item.title ?? "Untitled story",
162
+ url: toNullableString(item.url),
163
+ author: toNullableString(item.by),
164
+ score: item.score ?? null,
165
+ commentCount: item.descendants ?? null,
166
+ publishedAt: toNullableTimestamp(item.time),
167
+ text: toNullableString(item.text)
168
+ };
169
+ }
170
+ async fetchItem(id) {
171
+ const url = new URL(`${this.apiBaseUrl}/item/${id}.json`);
172
+ const response = await this.fetchImpl(url, {
173
+ method: "GET",
174
+ headers: {
175
+ accept: "application/json"
176
+ }
177
+ });
178
+ if (!response.ok) {
179
+ throw new ExternalServiceError(`Hacker News request failed with status ${response.status}.`, {
180
+ statusCode: response.status,
181
+ details: await response.text()
182
+ });
183
+ }
184
+ const payload = await response.json();
185
+ if (payload === null) {
186
+ return null;
187
+ }
188
+ const parsed = hnItemSchema.safeParse(payload);
189
+ if (!parsed.success) {
190
+ throw new ExternalServiceError("Hacker News item returned an unexpected response shape.", {
191
+ details: parsed.error.flatten()
192
+ });
193
+ }
194
+ if (parsed.data.deleted || parsed.data.dead) {
195
+ return null;
196
+ }
197
+ return parsed.data;
198
+ }
199
+ async fetchJson(url, schema) {
200
+ const response = await this.fetchImpl(url, {
201
+ method: "GET",
202
+ headers: {
203
+ accept: "application/json"
204
+ }
205
+ });
206
+ if (!response.ok) {
207
+ const body = await response.text();
208
+ throw new ExternalServiceError(`Hacker News request failed with status ${response.status}.`, {
209
+ statusCode: response.status,
210
+ details: body
211
+ });
212
+ }
213
+ const payload = await response.json();
214
+ const parsed = schema.safeParse(payload);
215
+ if (!parsed.success) {
216
+ throw new ExternalServiceError("Hacker News returned an unexpected response shape.", {
217
+ details: parsed.error.flatten()
218
+ });
219
+ }
220
+ return parsed.data;
221
+ }
222
+ };
223
+ var HackerNewsServer = class extends ToolkitServer {
224
+ client;
225
+ constructor(client) {
226
+ super(metadata);
227
+ this.client = client;
228
+ this.registerTool(
229
+ defineTool({
230
+ name: "get_top_stories",
231
+ title: "Get top stories",
232
+ description: "Get the current top Hacker News stories.",
233
+ inputSchema: {
234
+ limit: z.number().int().min(1).max(30).default(10)
235
+ },
236
+ outputSchema: {
237
+ stories: z.array(storySummarySchema),
238
+ returned: z.number().int()
239
+ },
240
+ handler: async ({ limit }, context) => {
241
+ await context.log("info", "Fetching top Hacker News stories");
242
+ const stories = await this.client.getTopStories(limit);
243
+ return {
244
+ stories: [...stories],
245
+ returned: stories.length
246
+ };
247
+ },
248
+ renderText: ({ stories }) => stories.map((story) => `${story.title} (${story.score ?? 0} points)`).join("\n")
249
+ })
250
+ );
251
+ this.registerTool(
252
+ defineTool({
253
+ name: "search_stories",
254
+ title: "Search stories",
255
+ description: "Search Hacker News stories by keyword.",
256
+ inputSchema: {
257
+ query: z.string().trim().min(1),
258
+ limit: z.number().int().min(1).max(30).default(10)
259
+ },
260
+ outputSchema: {
261
+ stories: z.array(storySummarySchema),
262
+ returned: z.number().int()
263
+ },
264
+ handler: async ({ query, limit }, context) => {
265
+ await context.log("info", `Searching Hacker News for ${query}`);
266
+ const stories = await this.client.searchStories({ query, limit });
267
+ return {
268
+ stories: [...stories],
269
+ returned: stories.length
270
+ };
271
+ },
272
+ renderText: ({ stories, returned }) => {
273
+ if (returned === 0) {
274
+ return "No matching Hacker News stories found.";
275
+ }
276
+ return stories.map((story) => `${story.title} by ${story.author ?? "unknown"}`).join("\n");
277
+ }
278
+ })
279
+ );
280
+ this.registerTool(
281
+ defineTool({
282
+ name: "get_item_thread",
283
+ title: "Get item thread",
284
+ description: "Fetch a Hacker News story and its comment thread.",
285
+ inputSchema: {
286
+ itemId: z.number().int().nonnegative(),
287
+ depth: z.number().int().min(1).max(6).default(2),
288
+ maxChildren: z.number().int().min(1).max(50).default(20)
289
+ },
290
+ outputSchema: {
291
+ thread: threadSchema
292
+ },
293
+ handler: async ({ itemId, depth, maxChildren }, context) => {
294
+ await context.log("info", `Fetching Hacker News thread ${itemId}`);
295
+ return {
296
+ thread: await this.client.getThread({ itemId, depth, maxChildren })
297
+ };
298
+ },
299
+ renderText: ({ thread }) => `${thread.title} with ${thread.replies.length} top-level replies.`
300
+ })
301
+ );
302
+ this.registerStaticResource(
303
+ "trends",
304
+ "hackernews://trends/top",
305
+ {
306
+ title: "Top story trend resource",
307
+ description: "A quick snapshot of current top Hacker News stories.",
308
+ mimeType: "application/json"
309
+ },
310
+ async (uri) => this.createJsonResource(uri.toString(), {
311
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
312
+ stories: await this.client.getTopStories(10)
313
+ })
314
+ );
315
+ this.registerPrompt(
316
+ "community-digest",
317
+ {
318
+ title: "Community digest prompt",
319
+ description: "Draft a digest of noteworthy Hacker News activity.",
320
+ argsSchema: {
321
+ theme: z.string().trim().min(1),
322
+ audience: z.string().trim().min(1),
323
+ storyCount: z.number().int().min(1).max(20).default(5)
324
+ }
325
+ },
326
+ async ({ theme, audience, storyCount }) => this.createTextPrompt(
327
+ [
328
+ `Prepare a Hacker News community digest for ${audience}.`,
329
+ `Focus on the theme "${theme}" and summarize roughly ${storyCount} stories.`,
330
+ "Highlight debates, notable launches, practical takeaways, and unresolved questions."
331
+ ].join(" ")
332
+ )
333
+ );
334
+ }
335
+ };
336
+ async function createServer(options = {}) {
337
+ if (options.client) {
338
+ return new HackerNewsServer(options.client);
339
+ }
340
+ const env = resolveEnv(options.env);
341
+ return new HackerNewsServer(
342
+ new FetchHackerNewsClient({
343
+ fetch: options.fetch ?? globalThis.fetch,
344
+ ...env.HACKERNEWS_API_BASE_URL ? { apiBaseUrl: env.HACKERNEWS_API_BASE_URL } : {},
345
+ ...env.HACKERNEWS_SEARCH_BASE_URL ? { searchBaseUrl: env.HACKERNEWS_SEARCH_BASE_URL } : {}
346
+ })
347
+ );
348
+ }
349
+ function isMainModule(metaUrl) {
350
+ const entry = process.argv[1];
351
+ return typeof entry === "string" && fileURLToPath(metaUrl) === resolve(entry);
352
+ }
353
+ async function main(argv = process.argv.slice(2)) {
354
+ const runtimeOptions = parseRuntimeOptions(argv);
355
+ await runToolkitServer(
356
+ {
357
+ createServer: () => createServer(),
358
+ serverCard
359
+ },
360
+ runtimeOptions
361
+ );
362
+ }
363
+ if (isMainModule(import.meta.url)) {
364
+ await main();
365
+ }
366
+ export {
367
+ HackerNewsServer,
368
+ createServer,
369
+ main,
370
+ metadata,
371
+ serverCard
372
+ };
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@universal-mcp-toolkit/server-hackernews",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Trending story and thread tools for Hacker News.",
6
+ "license": "MIT",
7
+ "bin": {
8
+ "server-hackernews": "./dist/index.js",
9
+ "umt-hackernews": "./dist/index.js"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/Markgatcha/universal-mcp-toolkit.git"
14
+ },
15
+ "homepage": "https://github.com/Markgatcha/universal-mcp-toolkit#readme",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.js"
20
+ },
21
+ "./package.json": "./package.json"
22
+ },
23
+ "files": [
24
+ "dist",
25
+ ".well-known"
26
+ ],
27
+ "keywords": [
28
+ "mcp",
29
+ "model-context-protocol",
30
+ "ai",
31
+ "developer-tools",
32
+ "typescript",
33
+ "hackernews",
34
+ "news",
35
+ "community",
36
+ "search"
37
+ ],
38
+ "dependencies": {
39
+ "@universal-mcp-toolkit/core": "0.1.0",
40
+ "zod": "^4.3.6"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "scripts": {
46
+ "build": "tsup src/index.ts --format esm --dts --clean",
47
+ "dev": "tsx watch src/index.ts",
48
+ "lint": "tsc --noEmit",
49
+ "typecheck": "tsc --noEmit",
50
+ "test": "vitest run --passWithNoTests",
51
+ "clean": "rimraf dist"
52
+ }
53
+ }