import { Server } from '@modelcontextprotocol/sdk/server/index.js' import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js' import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' import { notesData } from '../generated/notes-data.mjs' const MAX_SEARCH_RESULTS = 10 const SNIPPET_RADIUS = 220 const transports = new Map() function normalizeNoteId(noteId = '') { const normalized = String(noteId).trim().replaceAll('\\', '/').replace(/^\/+|\/+$/g, '') if (!normalized || normalized.includes('..')) { return null } return normalized } function resolveNote(noteId) { const normalized = normalizeNoteId(noteId) if (!normalized) { return null } return notesData.find((note) => note.slug === normalized || note.relativePath === normalized || note.relativePath.replace(/\.(md|mdx)$/i, '') === normalized ) ?? null } function buildSnippet(content, index, query) { const start = Math.max(0, index - SNIPPET_RADIUS) const end = Math.min(content.length, index + query.length + SNIPPET_RADIUS) return content .slice(start, end) .replace(/\s+/g, ' ') .trim() } function textResponse(text) { return { content: [{ type: 'text', text }] } } function createServer() { const server = new Server( { name: 'notenextra-notes-worker', version: '1.0.0' }, { capabilities: { tools: {} } } ) server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: 'list_notes', description: 'List available notes from the generated notes knowledge base.', inputSchema: { type: 'object', properties: { course: { type: 'string', description: 'Optional course or directory prefix, for example CSE442T or Math4201.' } } } }, { name: 'read_note', description: 'Read a note by slug or relative path, for example CSE442T/CSE442T_L1.', inputSchema: { type: 'object', properties: { noteId: { type: 'string', description: 'Note slug or relative path inside content/.' } }, required: ['noteId'] } }, { name: 'search_notes', description: 'Search the notes knowledge base using a simple text match over all markdown content.', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Search term or phrase.' }, limit: { type: 'number', description: `Maximum results to return, capped at ${MAX_SEARCH_RESULTS}.` } }, required: ['query'] } } ] })) server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args = {} } = request.params if (name === 'list_notes') { const course = typeof args.course === 'string' ? args.course.trim().toLowerCase() : '' const filtered = course ? notesData.filter((note) => note.relativePath.toLowerCase().startsWith(`${course}/`)) : notesData return textResponse(filtered.map((note) => note.slug).join('\n') || 'No notes found.') } if (name === 'read_note') { const note = resolveNote(args.noteId) if (!note) { return textResponse('Note not found.') } return textResponse(`# ${note.slug}\n\n${note.content}`) } if (name === 'search_notes') { const query = typeof args.query === 'string' ? args.query.trim() : '' if (!query) { return textResponse('Query must be a non-empty string.') } const limit = Math.max(1, Math.min(Number(args.limit) || 5, MAX_SEARCH_RESULTS)) const queryLower = query.toLowerCase() const matches = [] for (const note of notesData) { const haystack = `${note.slug}\n${note.content}` const index = haystack.toLowerCase().indexOf(queryLower) if (index === -1) { continue } matches.push({ note, index, snippet: buildSnippet(haystack, index, query) }) } matches.sort((a, b) => a.index - b.index || a.note.slug.localeCompare(b.note.slug)) return textResponse( matches .slice(0, limit) .map(({ note, snippet }) => `- ${note.slug}\n${snippet}`) .join('\n\n') || 'No matches found.' ) } throw new Error(`Unknown tool: ${name}`) }) return server } async function handleMcpRequest(request) { const sessionId = request.headers.get('mcp-session-id') let transport = sessionId ? transports.get(sessionId) : undefined if (!transport && request.method === 'POST') { transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), enableJsonResponse: true, onsessioninitialized: (newSessionId) => { transports.set(newSessionId, transport) }, onsessionclosed: (closedSessionId) => { transports.delete(closedSessionId) } }) transport.onclose = () => { if (transport.sessionId) { transports.delete(transport.sessionId) } } const server = createServer() await server.connect(transport) } if (!transport) { return new Response('Invalid or missing MCP session.', { status: 400 }) } return transport.handleRequest(request) } export default { async fetch(request) { const url = new URL(request.url) if (url.pathname === '/health') { return Response.json({ status: 'ok', notes: notesData.length }) } if (url.pathname === '/mcp') { return handleMcpRequest(request) } return new Response('Not found.', { status: 404 }) } }