server.ts•6.06 kB
import { FastMCP } from "fastmcp";
import { z } from "zod";
import Parser from "rss-parser";
import fs from "fs/promises";
import path from "path";
import axios from "axios";
import os from "os";
// ------------------------------------------------------
// 🔐 Stable, cross-machine location for last_seen.json
// ------------------------------------------------------
const CONFIG_DIR = path.join(os.homedir(), ".config", "goose", "content-fetcher-mcp");
const LAST_SEEN_FILE = path.join(CONFIG_DIR, "last_seen.json");
// Create the directory safely (no top-level await)
async function ensureConfigDir() {
try {
await fs.mkdir(CONFIG_DIR, { recursive: true });
} catch {}
}
const YOUTUBE_CHANNEL_ID = "UCVLuT_AS687XAJ__-COCRFw";
const GOOSE_BLOG_RSS = "https://block.github.io/goose/blog/rss.xml";
const rssParser = new Parser();
/* --------------------------------------------------
Types
-------------------------------------------------- */
interface ContentItem {
id: string;
title: string;
url: string;
published_at: string;
type: "video" | "blog" | "release";
}
/* --------------------------------------------------
Last Seen Helpers (reading & writing ONLY)
-------------------------------------------------- */
async function loadLastSeen() {
try {
const raw = await fs.readFile(LAST_SEEN_FILE, "utf-8");
return JSON.parse(raw);
} catch {
return { seen: { youtube: [], blog: [], release: [] } };
}
}
async function saveLastSeen(data: any) {
await fs.writeFile(LAST_SEEN_FILE, JSON.stringify(data, null, 2));
}
async function hasSeen(type: string, id: string) {
const data = await loadLastSeen();
return data.seen?.[type]?.includes(id);
}
async function markSeen(type: string, id: string) {
const data = await loadLastSeen();
if (!data.seen[type]) data.seen[type] = [];
if (!data.seen[type].includes(id)) data.seen[type].push(id);
await saveLastSeen(data);
}
/* --------------------------------------------------
Fetch YouTube (NO writing to last_seen here!)
-------------------------------------------------- */
async function fetchYoutube(): Promise<ContentItem[]> {
const feed = await rssParser.parseURL(
`https://www.youtube.com/feeds/videos.xml?channel_id=${YOUTUBE_CHANNEL_ID}`
);
return feed.items.map((item) => ({
id: item.id || item.link || "",
title: item.title || "",
url: item.link || "",
published_at: item.pubDate || "",
type: "video" as const,
}));
}
/* --------------------------------------------------
Generic RSS Fetcher (NO writing to last_seen)
-------------------------------------------------- */
async function fetchRss(url: string): Promise<ContentItem[]> {
const feed = await rssParser.parseURL(url);
return feed.items.map((item) => ({
id: item.guid || item.link || "",
title: item.title || "",
url: item.link || "",
published_at: item.pubDate || "",
type: "blog" as const,
}));
}
/* --------------------------------------------------
Hard-coded Goose Blog fetcher
-------------------------------------------------- */
async function fetchGooseBlog(): Promise<ContentItem[]> {
return await fetchRss(GOOSE_BLOG_RSS);
}
/* --------------------------------------------------
GitHub Releases (NO writing to last_seen)
-------------------------------------------------- */
async function fetchGithubReleases(): Promise<ContentItem[]> {
const response = await axios.get(
"https://api.github.com/repos/block/goose/releases",
{ headers: { Accept: "application/vnd.github+json" } }
);
return response.data.map((rel: any) => ({
id: rel.tag_name,
title: rel.name || rel.tag_name,
url: rel.html_url,
published_at: rel.published_at || rel.created_at,
type: "release" as const,
}));
}
/* --------------------------------------------------
FastMCP Server
-------------------------------------------------- */
const server = new FastMCP({
name: "content-fetcher-mcp",
version: "3.0.0",
});
/* ---------------- YouTube Tool ---------------- */
server.addTool({
name: "fetchYoutube",
description: "Fetch ALL YouTube videos from the Goose channel.",
parameters: z.object({}),
execute: async () => JSON.stringify(await fetchYoutube()),
});
/* ---------------- Generic RSS Tool ---------------- */
server.addTool({
name: "fetchRss",
description: "Fetch ALL blog posts from any RSS feed.",
parameters: z.object({
url: z.string().describe("RSS feed URL"),
}),
execute: async ({ url }) => JSON.stringify(await fetchRss(url)),
});
/* ---------------- Goose Blog Tool ---------------- */
server.addTool({
name: "fetchGooseBlog",
description: "Fetch ALL Goose blog posts.",
parameters: z.object({}),
execute: async () => JSON.stringify(await fetchGooseBlog()),
});
/* ---------------- GitHub Releases Tool ---------------- */
server.addTool({
name: "fetchGithubReleases",
description: "Fetch ALL GitHub releases from the Goose repo.",
parameters: z.object({}),
execute: async () => JSON.stringify(await fetchGithubReleases()),
});
/* ---------------- isNewContent Tool ---------------- */
server.addTool({
name: "isNewContent",
description: "Return true if this content item has NOT been seen before.",
parameters: z.object({
id: z.string(),
type: z.enum(["youtube", "blog", "release"]),
}),
execute: async ({ id, type }) =>
JSON.stringify({
is_new: !(await hasSeen(type, id)),
}),
});
/* ---------------- markContentSeen Tool ---------------- */
server.addTool({
name: "markContentSeen",
description: "Mark a content item as seen AFTER posting.",
parameters: z.object({
id: z.string(),
type: z.enum(["youtube", "blog", "release"]),
}),
execute: async ({ id, type }) => {
await markSeen(type, id);
return JSON.stringify({ success: true });
},
});
/* --------------------------------------------------
Start the Server
-------------------------------------------------- */
ensureConfigDir().then(() => {
server.start({ transportType: "stdio" });
});