@mrfentmen/lobsters-mcp 0.0.0-stage → 1.0.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/README.md CHANGED
@@ -1,3 +1,53 @@
1
- # Temporary Holding Version
1
+ # Lobsters MCP
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Lobsters: hottest and newest stories, tags, story details, user profiles — keyless.
4
+
5
+ Keyless — no signup, no API key, no environment variables.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npx -y @mrfentmen/lobsters-mcp
11
+ ```
12
+
13
+ Or add it to your MCP client:
14
+
15
+ ```json
16
+ {
17
+ "mcpServers": {
18
+ "lobsters-mcp": {
19
+ "command": "npx",
20
+ "args": ["-y", "@mrfentmen/lobsters-mcp"]
21
+ }
22
+ }
23
+ }
24
+ ```
25
+
26
+ ## Tools
27
+
28
+ | Tool | What it does |
29
+ |---|---|
30
+ | `hottest` | Current front page |
31
+ | `newest` | Newest submissions |
32
+ | `by_tag` | Stories under one tag |
33
+ | `story_detail` | One story by short id |
34
+ | `user_info` | Profile, role and links |
35
+ | `domain_breakdown` | Top domains and active tags |
36
+
37
+ ## Example
38
+
39
+ ```
40
+ lobste.rs hottest (3):
41
+
42
+ 1. Websites Should Be Printable [66 pts, 17 comments]
43
+ https://brennenputh.me/blog/printable-websites/
44
+ css, web · zk · 2026-10-26 07:02
45
+ ```
46
+
47
+ ## Notes
48
+
49
+ lobste.rs allows one request per second and answers throttling with a plain-text 429, which this server reports verbatim. User profiles expose identity fields only — lobste.rs publishes no karma or story counts.
50
+
51
+ ## Licence
52
+
53
+ MIT
package/dist/api.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ export declare class LobstersError extends Error {
2
+ }
3
+ export declare function hottest(limit: number): Promise<string>;
4
+ export declare function newest(limit: number): Promise<string>;
5
+ export declare function byTag(tag: string, limit: number): Promise<string>;
6
+ export declare function storyDetail(shortId: string): Promise<string>;
7
+ export declare function userInfo(username: string): Promise<string>;
8
+ export declare function domainBreakdown(limit: number): Promise<string>;
package/dist/api.js ADDED
@@ -0,0 +1,141 @@
1
+ const BASE = "https://lobste.rs";
2
+ export class LobstersError extends Error {
3
+ }
4
+ async function getJson(path, timeoutMs = 20000) {
5
+ const ctrl = new AbortController();
6
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
7
+ try {
8
+ const res = await fetch(`${BASE}${path}`, {
9
+ signal: ctrl.signal,
10
+ headers: { "user-agent": "lobsters-mcp", accept: "application/json" },
11
+ });
12
+ if (res.status === 404)
13
+ throw new LobstersError("Not found on lobste.rs");
14
+ if (res.status === 403)
15
+ throw new LobstersError("lobste.rs refused the request");
16
+ if (!res.ok)
17
+ throw new LobstersError(`lobste.rs responded ${res.status}`);
18
+ return await res.json();
19
+ }
20
+ catch (e) {
21
+ if (e instanceof LobstersError)
22
+ throw e;
23
+ if (e.name === "AbortError")
24
+ throw new LobstersError("lobste.rs request timed out");
25
+ if (e instanceof SyntaxError)
26
+ throw new LobstersError("lobste.rs returned a non-JSON response");
27
+ throw new LobstersError(`lobste.rs request failed: ${e.message}`);
28
+ }
29
+ finally {
30
+ clearTimeout(timer);
31
+ }
32
+ }
33
+ function toStory(s) {
34
+ const user = typeof s.submitter_user === "object" ? s.submitter_user?.username : s.submitter_user;
35
+ return {
36
+ short_id: String(s.short_id ?? ""),
37
+ title: String(s.title ?? "(untitled)"),
38
+ url: String(s.url ?? ""),
39
+ score: Number(s.score ?? 0),
40
+ comment_count: Number(s.comment_count ?? 0),
41
+ created_at: String(s.created_at ?? "").slice(0, 16).replace("T", " "),
42
+ submitter_user: String(user ?? "unknown"),
43
+ tags: Array.isArray(s.tags) ? s.tags.map(String) : [],
44
+ description: String(s.description_plain ?? s.description ?? "").replace(/\s+/g, " ").slice(0, 200),
45
+ };
46
+ }
47
+ function line(s, i) {
48
+ const prefix = typeof i === "number" ? `${i + 1}. ` : "• ";
49
+ const where = s.url ? s.url : `${BASE}/s/${s.short_id}`;
50
+ return `${prefix}${s.title} [${s.score} pts, ${s.comment_count} comments]\n ${where}\n ${s.tags.join(", ") || "no tags"} · ${s.submitter_user} · ${s.created_at}`;
51
+ }
52
+ function take(rows, limit) {
53
+ return rows.slice(0, limit).map(toStory);
54
+ }
55
+ export async function hottest(limit) {
56
+ const rows = await getJson("/hottest.json");
57
+ const stories = take(Array.isArray(rows) ? rows : [], limit);
58
+ if (stories.length === 0)
59
+ return "lobste.rs returned no stories.";
60
+ return [`lobste.rs hottest (${stories.length}):`, "", ...stories.map(line)].join("\n");
61
+ }
62
+ export async function newest(limit) {
63
+ const rows = await getJson("/newest.json");
64
+ const stories = take(Array.isArray(rows) ? rows : [], limit);
65
+ if (stories.length === 0)
66
+ return "lobste.rs returned no stories.";
67
+ return [`lobste.rs newest (${stories.length}):`, "", ...stories.map(line)].join("\n");
68
+ }
69
+ export async function byTag(tag, limit) {
70
+ const t = tag.trim().toLowerCase();
71
+ if (!/^[a-z0-9_-]{2,30}$/.test(t))
72
+ throw new LobstersError(`"${tag}" is not a valid tag`);
73
+ const rows = await getJson(`/t/${encodeURIComponent(t)}.json`);
74
+ const stories = take(Array.isArray(rows) ? rows : [], limit);
75
+ if (stories.length === 0)
76
+ return `No stories tagged "${tag}".`;
77
+ return [`Stories tagged "${tag}" (${stories.length}):`, "", ...stories.map(line)].join("\n");
78
+ }
79
+ export async function storyDetail(shortId) {
80
+ const id = shortId.trim().toLowerCase();
81
+ if (!/^[a-z0-9]{4,12}$/.test(id))
82
+ throw new LobstersError(`"${shortId}" is not a story id`);
83
+ const s = toStory(await getJson(`/s/${id}.json`));
84
+ const rows = [
85
+ `${s.title}`,
86
+ `Score ${s.score} · ${s.comment_count} comments · ${s.created_at} · by ${s.submitter_user}`,
87
+ `Tags: ${s.tags.join(", ") || "none"}`,
88
+ s.url ? `Link: ${s.url}` : "Self post",
89
+ `Discussion: ${BASE}/s/${s.short_id}`,
90
+ ];
91
+ if (s.description)
92
+ rows.push("", s.description);
93
+ return rows.join("\n");
94
+ }
95
+ export async function userInfo(username) {
96
+ const u = username.trim();
97
+ if (!/^[A-Za-z0-9_-]{2,30}$/.test(u))
98
+ throw new LobstersError(`"${username}" is not a valid username`);
99
+ const d = await getJson(`/~${encodeURIComponent(u)}.json`);
100
+ const profile = d.user ?? d;
101
+ // lobste.rs exposes identity fields only — no karma or story counts.
102
+ const roles = [profile.is_admin ? "admin" : "", profile.is_moderator ? "moderator" : ""].filter(Boolean);
103
+ return [
104
+ `User: ${profile.username ?? u}`,
105
+ `Joined: ${String(profile.created_at ?? d.created_at ?? "").slice(0, 10) || "unknown"}`,
106
+ `Role: ${roles.join(", ") || "member"}`,
107
+ `Invited by: ${profile.invited_by_user ?? "unknown"}`,
108
+ `GitHub: ${profile.github_username ?? "not linked"} · Mastodon: ${profile.mastodon_username ?? "not linked"}`,
109
+ `About: ${String(profile.about ?? d.about ?? "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().slice(0, 500) || "no bio"}`,
110
+ ].join("\n");
111
+ }
112
+ export async function domainBreakdown(limit) {
113
+ const rows = await getJson("/hottest.json");
114
+ const stories = take(Array.isArray(rows) ? rows : [], 100);
115
+ if (stories.length === 0)
116
+ return "lobste.rs returned no stories.";
117
+ const counts = new Map();
118
+ for (const s of stories) {
119
+ let host = "self post";
120
+ try {
121
+ host = s.url ? new URL(s.url).hostname.replace(/^www\./, "") : "self post";
122
+ }
123
+ catch {
124
+ host = "unknown";
125
+ }
126
+ counts.set(host, (counts.get(host) ?? 0) + 1);
127
+ }
128
+ const top = [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit);
129
+ const tags = new Map();
130
+ for (const s of stories)
131
+ for (const t of s.tags)
132
+ tags.set(t, (tags.get(t) ?? 0) + 1);
133
+ const topTags = [...tags.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8);
134
+ return [
135
+ `Domains on the hottest page (${stories.length} stories):`,
136
+ "",
137
+ ...top.map(([host, n], i) => `${i + 1}. ${host} — ${n}`),
138
+ "",
139
+ `Active tags: ${topTags.map(([t, n]) => `${t} (${n})`).join(", ")}`,
140
+ ].join("\n");
141
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,12 @@
1
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
2
+ import { createServer } from "./server.js";
3
+ async function main() {
4
+ const server = createServer();
5
+ const transport = new StdioServerTransport();
6
+ await server.connect(transport);
7
+ console.error("lobsters-mcp running on stdio");
8
+ }
9
+ main().catch((err) => {
10
+ console.error("Fatal error:", err);
11
+ process.exit(1);
12
+ });
@@ -0,0 +1,2 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function createServer(): McpServer;
package/dist/server.js ADDED
@@ -0,0 +1,64 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { byTag, domainBreakdown, hottest, LobstersError, newest, storyDetail, userInfo } from "./api.js";
4
+ const text = (t) => ({ content: [{ type: "text", text: t }] });
5
+ function errorMessage(e) {
6
+ if (e instanceof LobstersError || e instanceof Error)
7
+ return `Error: ${e.message}`;
8
+ return `Error: ${String(e)}`;
9
+ }
10
+ export function createServer() {
11
+ const server = new McpServer({ name: "lobsters-mcp", version: "1.0.3" });
12
+ server.tool("hottest", "The current lobste.rs front page.", { limit: z.number().int().min(1).max(25).default(10).describe("Stories to return") }, async ({ limit }) => {
13
+ try {
14
+ return text(await hottest(limit));
15
+ }
16
+ catch (e) {
17
+ return text(errorMessage(e));
18
+ }
19
+ });
20
+ server.tool("newest", "Freshly submitted lobste.rs stories.", { limit: z.number().int().min(1).max(25).default(10).describe("Stories to return") }, async ({ limit }) => {
21
+ try {
22
+ return text(await newest(limit));
23
+ }
24
+ catch (e) {
25
+ return text(errorMessage(e));
26
+ }
27
+ });
28
+ server.tool("by_tag", "Stories under one tag, e.g. rust, ai, security, programming.", {
29
+ tag: z.string().min(2).describe("Tag name"),
30
+ limit: z.number().int().min(1).max(25).default(10).describe("Stories to return"),
31
+ }, async ({ tag, limit }) => {
32
+ try {
33
+ return text(await byTag(tag, limit));
34
+ }
35
+ catch (e) {
36
+ return text(errorMessage(e));
37
+ }
38
+ });
39
+ server.tool("story_detail", "One story by its short id.", { short_id: z.string().min(4).describe("Short id, e.g. olnqh2") }, async ({ short_id }) => {
40
+ try {
41
+ return text(await storyDetail(short_id));
42
+ }
43
+ catch (e) {
44
+ return text(errorMessage(e));
45
+ }
46
+ });
47
+ server.tool("user_info", "Profile, karma and activity counts for a lobste.rs user.", { username: z.string().min(2).describe("Username") }, async ({ username }) => {
48
+ try {
49
+ return text(await userInfo(username));
50
+ }
51
+ catch (e) {
52
+ return text(errorMessage(e));
53
+ }
54
+ });
55
+ server.tool("domain_breakdown", "Which sites and tags dominate the current front page.", { limit: z.number().int().min(1).max(25).default(10).describe("Domains to list") }, async ({ limit }) => {
56
+ try {
57
+ return text(await domainBreakdown(limit));
58
+ }
59
+ catch (e) {
60
+ return text(errorMessage(e));
61
+ }
62
+ });
63
+ return server;
64
+ }
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "@mrfentmen/lobsters-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Lobsters: hottest and newest stories, tags, story details, user profiles \u2014 keyless.",
5
+ "type": "module",
6
+ "mcpName": "io.github.mrfentmen/lobsters-mcp",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/mrfentmen/awesome-servers.git"
10
+ },
11
+ "bin": {
12
+ "lobsters-mcp": "./dist/index.js"
13
+ },
14
+ "main": "./dist/index.js",
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsc -p tsconfig.json",
20
+ "start": "node dist/index.js"
21
+ },
22
+ "keywords": [
23
+ "mcp",
24
+ "lobsters",
25
+ "news",
26
+ "tech",
27
+ "hackernews",
28
+ "links"
29
+ ],
30
+ "license": "MIT",
31
+ "dependencies": {
32
+ "@modelcontextprotocol/sdk": "^1.0.4",
33
+ "zod": "^3.23.8"
34
+ },
35
+ "devDependencies": {
36
+ "@types/node": "^22.0.0",
37
+ "typescript": "^5.6.0"
38
+ },
39
+ "engines": {
40
+ "node": ">=20"
41
+ }
42
+ }