// mcp-jsonplaceholder.ts // Run: npx tsx mcp-jsonplaceholder.ts // Test: npx tsx mcp-jsonplaceholder.ts --test import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { z } from 'zod'; const BASE = 'https://jsonplaceholder.typicode.com'; // ---------- Validation schemas ---------- const GetUserSchema = z.object({ id: z.number().int().positive() }); const ListPostsSchema = z.object({ user_id: z.number().int().positive(), limit: z.number().int().min(1).max(100).optional().default(10) }); const SearchPostsSchema = z.object({ query: z.string().min(1).max(200) }); // ---------- HTTP helper with timeout ---------- async function fetchJson(url: string, timeoutMs = 8000) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const res = await fetch(url, { signal: controller.signal }); clearTimeout(timer); if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`); const data = await res.json(); return data; } catch (err: any) { clearTimeout(timer); if (err.name === 'AbortError') throw new Error('Request timed out'); throw err; } } // ---------- Typed implementations ---------- async function getUserImpl(id: number) { const user = await fetchJson(`${BASE}/users/${id}`); if (!user || !user.id) throw new Error(`User ${id} not found`); return user; } async function listPostsImpl(user_id: number, limit: number) { const posts = await fetchJson(`${BASE}/posts?userId=${user_id}`); if (!Array.isArray(posts)) throw new Error('Invalid posts response'); return posts.slice(0, limit).map(p => ({ id: p.id, userId: p.userId, title: p.title, body: p.body })); } async function searchPostsImpl(query: string) { const posts = await fetchJson(`${BASE}/posts`); const q = query.toLowerCase(); const filtered = posts.filter((p: any) => p.title.toLowerCase().includes(q)); return filtered.map((p: any) => ({ id: p.id, userId: p.userId, title: p.title })); } // ---------- MCP Server ---------- const server = new Server({ name: 'jsonplaceholder-mcp', version: '1.0.0' }, { capabilities: { tools: {} } }); server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: 'get_user', description: 'Fetch a single user by numeric id', inputSchema: { type: 'object', properties: { id: { type: 'integer', minimum: 1 } }, required: ['id'], additionalProperties: false } }, { name: 'list_posts_by_user', description: 'Fetch posts for a user with optional limit', inputSchema: { type: 'object', properties: { user_id: { type: 'integer', minimum: 1 }, limit: { type: 'integer', minimum: 1, maximum: 100 } }, required: ['user_id'], additionalProperties: false } }, { name: 'search_posts', description: 'Search posts by title substring, case-insensitive', inputSchema: { type: 'object', properties: { query: { type: 'string', minLength: 1, maxLength: 200 } }, required: ['query'], additionalProperties: false } } ] })); server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { switch (name) { case 'get_user': { const { id } = GetUserSchema.parse(args); const user = await getUserImpl(id); return { content: [{ type: 'text', text: JSON.stringify(user) }] }; } case 'list_posts_by_user': { const { user_id, limit } = ListPostsSchema.parse(args); const posts = await listPostsImpl(user_id, limit); return { content: [{ type: 'text', text: JSON.stringify(posts) }] }; } case 'search_posts': { const { query } = SearchPostsSchema.parse(args); const results = await searchPostsImpl(query); return { content: [{ type: 'text', text: JSON.stringify(results) }] }; } default: throw new Error(`Unknown tool ${name}`); } } catch (err: any) { return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true }; } }); async function startServer() { const transport = new StdioServerTransport(); await server.connect(transport); console.error('JSONPlaceholder MCP server running on stdio'); } // ---------- Tests ---------- async function runTests() { console.log('=== MCP JSONPlaceholder Tests ==='); // a) Happy path try { const user = await getUserImpl(1); console.log('a) get_user(1) ->', user.id === 1 && !!user.name ? 'PASS' : 'FAIL', user.name); } catch (e: any) { console.log('a) FAIL', e.message); } // b) 404 / not-found handled try { await getUserImpl(9999); console.log('b) 404 handled -> PASS (empty)'); } catch (e: any) { console.log('b) 404 handled -> PASS', e.message); } // c) Validation rejects malformed input try { GetUserSchema.parse({ id: -5 }); console.log('c1) FAIL'); } catch { console.log('c1) get_user(-5) rejected -> PASS'); } try { ListPostsSchema.parse({ user_id: 'x' }); console.log('c2) FAIL'); } catch { console.log('c2) list_posts_by_user("x") rejected -> PASS'); } try { SearchPostsSchema.parse({ query: '' }); console.log('c3) FAIL'); } catch { console.log('c3) search_posts("") rejected -> PASS'); } console.log('Tests done'); process.exit(0); } if (process.argv.includes('--test')) { runTests(); } else { startServer(); }