Last active
November 30, 2025 03:48
-
-
Save JenHsuan/d1ba0679d6627abe1571831eeff67344 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { McpAgent } from "agents/mcp"; | |
| import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | |
| import { z } from "zod"; | |
| // Article interface based on the API structure | |
| interface Article { | |
| id: number; | |
| title: string; | |
| subtitle: string; | |
| image: string; | |
| url: string; | |
| name: string; | |
| time: string; | |
| readtime: string; | |
| category: number; | |
| description: string; | |
| shareCount: number; | |
| checkCount: number; | |
| } | |
| // Cloudflare Workers environment bindings | |
| interface Env { | |
| ARTICLES_API_URL: string; | |
| RATE_LIMITER: { | |
| limit: (options: { key: string }) => Promise<{ success: boolean }>; | |
| }; | |
| } | |
| // Define our MCP agent for Alayman articles (exported as Durable Object) | |
| class AlaymanMCP extends McpAgent<Env> { | |
| server = new McpServer({ | |
| name: "Alayman Articles Server", | |
| version: "1.0.0", | |
| }); | |
| private async fetchArticles(): Promise<Article[]> { | |
| try { | |
| const apiUrl = (this.env as Env).ARTICLES_API_URL; | |
| const response = await fetch(apiUrl); | |
| if (!response.ok) { | |
| throw new Error(`HTTP error! status: ${response.status}`); | |
| } | |
| const articles = await response.json(); | |
| return articles as Article[]; | |
| } catch (error) { | |
| console.error("Error fetching articles:", error); | |
| throw error; | |
| } | |
| } | |
| private formatArticle(article: Article): string { | |
| return `ID: ${article.id} | |
| Title: ${article.title} | |
| Subtitle: ${article.subtitle} | |
| Author: ${article.name} | |
| Published: ${article.time} | |
| Reading Time: ${article.readtime} | |
| Category: ${article.category} | |
| URL: ${article.url} | |
| Image: ${article.image} | |
| Shares: ${article.shareCount} | |
| Checks: ${article.checkCount} | |
| ${article.description ? `Description: ${article.description}` : ""}`; | |
| } | |
| private formatArticles(articles: Article[]): string { | |
| return articles.map((article) => this.formatArticle(article)).join("\n\n---\n\n"); | |
| } | |
| async init() { | |
| // Tool 1: Get all articles with pagination | |
| this.server.tool( | |
| "get_all_articles", | |
| { | |
| limit: z.number().int().positive().default(20).describe("Number of articles to return (default: 20)"), | |
| offset: z.number().int().nonnegative().default(0).describe("Number of articles to skip (default: 0)"), | |
| }, | |
| async ({ limit = 20, offset = 0 }) => { | |
| try { | |
| const articles = await this.fetchArticles(); | |
| const total = articles.length; | |
| const paginatedArticles = articles.slice(offset, offset + limit); | |
| const hasMore = offset + limit < total; | |
| const response = { | |
| articles: paginatedArticles, | |
| total, | |
| offset, | |
| limit, | |
| has_more: hasMore, | |
| }; | |
| return { | |
| content: [ | |
| { | |
| type: "text", | |
| text: JSON.stringify(response, null, 2), | |
| }, | |
| ], | |
| }; | |
| } catch (error) { | |
| return { | |
| content: [ | |
| { | |
| type: "text", | |
| text: `Error fetching articles: ${error instanceof Error ? error.message : "Unknown error"}`, | |
| }, | |
| ], | |
| }; | |
| } | |
| }, | |
| ); | |
| // Tool 2: Search articles by title keyword | |
| this.server.tool( | |
| "search_articles", | |
| { | |
| keyword: z.string().min(1).describe("Keyword to search in article titles"), | |
| }, | |
| async ({ keyword }) => { | |
| try { | |
| const articles = await this.fetchArticles(); | |
| const searchTerm = keyword.toLowerCase(); | |
| const matchedArticles = articles.filter((article) => | |
| article.title.toLowerCase().includes(searchTerm), | |
| ); | |
| if (matchedArticles.length === 0) { | |
| return { | |
| content: [ | |
| { | |
| type: "text", | |
| text: `No articles found matching keyword: "${keyword}"`, | |
| }, | |
| ], | |
| }; | |
| } | |
| return { | |
| content: [ | |
| { | |
| type: "text", | |
| text: `Found ${matchedArticles.length} article(s) matching "${keyword}":\n\n${this.formatArticles(matchedArticles)}`, | |
| }, | |
| ], | |
| }; | |
| } catch (error) { | |
| return { | |
| content: [ | |
| { | |
| type: "text", | |
| text: `Error searching articles: ${error instanceof Error ? error.message : "Unknown error"}`, | |
| }, | |
| ], | |
| }; | |
| } | |
| }, | |
| ); | |
| // Prompt: List articles with custom condition | |
| this.server.registerPrompt( | |
| "list_articles", | |
| { | |
| title: "list_alayman_articles", | |
| description: "Generate a prompt to list a specific number of alayman's articles with custom conditions", | |
| argsSchema: { | |
| number: z.coerce.number().int().positive().default(10).describe("Number of articles to list (default: 10)"), | |
| condition: z.string().optional().describe("Custom condition or filter criteria for the articles"), | |
| }, | |
| }, | |
| async ({ number = 10, condition }) => { | |
| const text = `List ${number} alayman's articles${condition ? ` ${condition}` : ""}`; | |
| return { | |
| messages: [ | |
| { | |
| role: "user" as const, | |
| content: { | |
| type: "text" as const, | |
| text: text, | |
| }, | |
| }, | |
| ], | |
| }; | |
| }, | |
| ); | |
| } | |
| } | |
| // Export the Durable Object class | |
| export { AlaymanMCP }; | |
| export default { | |
| async fetch(request: Request, env: Env, ctx: ExecutionContext) { | |
| const url = new URL(request.url); | |
| // Extract rate limiting key from Mcp-session-id header | |
| const sessionId = request.headers.get("Mcp-session-id") || request.headers.get('mcp-session-id') || "unknown"; | |
| // Apply rate limiting to all endpoints | |
| const { success } = await env.RATE_LIMITER.limit({ key: sessionId }); | |
| if (!success) { | |
| return new Response( | |
| JSON.stringify({ | |
| error: "Rate limit exceeded", | |
| message: "You have exceeded the rate limit of 60 requests per 60 seconds. Please try again later.", | |
| limit: 60, | |
| period: 60, | |
| }), | |
| { | |
| status: 429, | |
| headers: { | |
| "Content-Type": "application/json", | |
| "Retry-After": "60", | |
| }, | |
| }, | |
| ); | |
| } | |
| // SSE endpoints | |
| if (url.pathname === "/sse" || url.pathname === "/sse/message") { | |
| return AlaymanMCP.serveSSE("/sse").fetch(request, env, ctx); | |
| } | |
| // MCP endpoint | |
| if (url.pathname === "/mcp") { | |
| return AlaymanMCP.serve("/mcp").fetch(request, env, ctx); | |
| } | |
| // Health check / info endpoint | |
| if (url.pathname === "/") { | |
| return new Response( | |
| JSON.stringify({ | |
| name: "Alayman MCP Server", | |
| version: "1.0.0", | |
| description: "MCP server for fetching articles from alayman.io", | |
| endpoints: { | |
| sse: "/sse", | |
| mcp: "/mcp", | |
| }, | |
| tools: ["get_all_articles", "search_articles"], | |
| prompts: ["list_articles"], | |
| rateLimit: { | |
| limit: 60, | |
| period: 60, | |
| key: "Mcp-session-id header or IP address", | |
| }, | |
| }), | |
| { | |
| status: 200, | |
| headers: { "Content-Type": "application/json" }, | |
| }, | |
| ); | |
| } | |
| return new Response("Not found", { status: 404 }); | |
| }, | |
| }; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment