From 24153a40b4bdcaf3d0a9cd885cfee27ee36d03ae Mon Sep 17 00:00:00 2001 From: Zheyuan Wu <60459821+Trance-0@users.noreply.github.com> Date: Thu, 19 Mar 2026 23:46:00 -0500 Subject: [PATCH] mcp addded by codex --- README.md | 79 +++++++++------ mcp-server.mjs | 207 +++++++++++++++++++++++++++++++++++++++ package.json | 5 +- test/test-mcp-server.mjs | 74 ++++++++++++++ 4 files changed, 332 insertions(+), 33 deletions(-) create mode 100644 mcp-server.mjs create mode 100644 test/test-mcp-server.mjs diff --git a/README.md b/README.md index 478c544..c8337e3 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,53 @@ -# NoteNextra - -Static note sharing site with minimum care - -This site is powered by - -- [Next.js](https://nextjs.org/) -- [Nextra](https://nextra.site/) -- [Tailwind CSS](https://tailwindcss.com/) -- [Vercel](https://vercel.com/) - -## Deployment - -### Deploying to Vercel - -[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FTrance-0%2FNotechondria) - -_Warning: This project is not suitable for free Vercel plan. There is insufficient memory for the build process._ - -### Deploying to Cloudflare Pages - -[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button?paid=true)](https://deploy.workers.cloudflare.com/?url=https://github.com/Trance-0/Notechondria) - -### Deploying as separated docker services - -Considering the memory usage for this project, it is better to deploy it as separated docker services. - -```bash -docker-compose up -d -f docker/docker-compose.yaml -``` - +# NoteNextra + +Static note sharing site with minimum care + +This site is powered by + +- [Next.js](https://nextjs.org/) +- [Nextra](https://nextra.site/) +- [Tailwind CSS](https://tailwindcss.com/) +- [Vercel](https://vercel.com/) + +## Deployment + +### Deploying to Vercel + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FTrance-0%2FNotechondria) + +_Warning: This project is not suitable for free Vercel plan. There is insufficient memory for the build process._ + +### Deploying to Cloudflare Pages + +[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button?paid=true)](https://deploy.workers.cloudflare.com/?url=https://github.com/Trance-0/Notechondria) + +### Deploying as separated docker services + +Considering the memory usage for this project, it is better to deploy it as separated docker services. + +```bash +docker-compose up -d -f docker/docker-compose.yaml +``` + ### Snippets Update dependencies ```bash npx npm-check-updates -u -``` \ No newline at end of file +``` + +### MCP access to notes + +This repository includes a minimal MCP server that exposes the existing `content/` notes as a knowledge base for AI tools over stdio. + +```bash +npm install +npm run mcp:notes +``` + +The server exposes: + +- `list_notes` +- `read_note` +- `search_notes` diff --git a/mcp-server.mjs b/mcp-server.mjs new file mode 100644 index 0000000..60a7011 --- /dev/null +++ b/mcp-server.mjs @@ -0,0 +1,207 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import process from 'node:process' + +import { Server } from '@modelcontextprotocol/sdk/server/index.js' +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { + CallToolRequestSchema, + ListToolsRequestSchema +} from '@modelcontextprotocol/sdk/types.js' + +const CONTENT_ROOT = path.join(process.cwd(), 'content') +const NOTE_EXTENSIONS = new Set(['.md', '.mdx']) +const MAX_SEARCH_RESULTS = 10 +const SNIPPET_RADIUS = 220 + +async function walkNotes(dir = CONTENT_ROOT) { + const entries = await fs.readdir(dir, { withFileTypes: true }) + const notes = await Promise.all(entries.map(async (entry) => { + const fullPath = path.join(dir, entry.name) + if (entry.isDirectory()) { + return walkNotes(fullPath) + } + + if (!entry.isFile() || !NOTE_EXTENSIONS.has(path.extname(entry.name))) { + return [] + } + + const relativePath = path.relative(CONTENT_ROOT, fullPath).replaceAll('\\', '/') + const slug = relativePath.replace(/\.(md|mdx)$/i, '') + return [{ + fullPath, + relativePath, + slug, + title: path.basename(slug) + }] + })) + + return notes.flat().sort((a, b) => a.relativePath.localeCompare(b.relativePath)) +} + +function normalizeNoteId(noteId = '') { + const normalized = String(noteId).trim().replaceAll('\\', '/').replace(/^\/+|\/+$/g, '') + if (!normalized || normalized.includes('..')) { + return null + } + + return normalized +} + +async function resolveNote(noteId) { + const normalized = normalizeNoteId(noteId) + if (!normalized) { + return null + } + + const notes = await walkNotes() + return notes.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 }] + } +} + +const server = new Server( + { + name: 'notenextra-notes', + version: '1.0.0' + }, + { + capabilities: { + tools: {} + } + } +) + +server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'list_notes', + description: 'List available notes from the Next.js content directory.', + 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 notes = await walkNotes() + const course = typeof args.course === 'string' + ? args.course.trim().toLowerCase() + : '' + const filtered = course + ? notes.filter((note) => note.relativePath.toLowerCase().startsWith(`${course}/`)) + : notes + + return textResponse(filtered.map((note) => note.slug).join('\n') || 'No notes found.') + } + + if (name === 'read_note') { + const note = await resolveNote(args.noteId) + if (!note) { + return textResponse('Note not found.') + } + + const content = await fs.readFile(note.fullPath, 'utf8') + return textResponse(`# ${note.slug}\n\n${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 notes = await walkNotes() + const matches = [] + + for (const note of notes) { + const content = await fs.readFile(note.fullPath, 'utf8') + const haystack = `${note.slug}\n${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}`) +}) + +const transport = new StdioServerTransport() +await server.connect(transport) diff --git a/package.json b/package.json index e4458ee..cd6f53b 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,12 @@ "build:test": "cross-env ANALYZE=true NODE_OPTIONS='--inspect --max-old-space-size=4096' next build", "build:analyze": "cross-env ANALYZE=true NODE_OPTIONS='--max-old-space-size=16384' next build", "postbuild": "next-sitemap && pagefind --site .next/server/app --output-path out/_pagefind", - "start": "next start" + "start": "next start", + "mcp:notes": "node ./mcp-server.mjs", + "test:mcp": "node ./test/test-mcp-server.mjs" }, "dependencies": { + "@modelcontextprotocol/sdk": "^1.18.1", "@docsearch/css": "^4.3.1", "@docsearch/react": "^4.3.1", "@napi-rs/simple-git": "^0.1.22", diff --git a/test/test-mcp-server.mjs b/test/test-mcp-server.mjs new file mode 100644 index 0000000..30a177a --- /dev/null +++ b/test/test-mcp-server.mjs @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict' +import path from 'node:path' +import process from 'node:process' + +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' + +const transport = new StdioClientTransport({ + command: process.execPath, + args: [path.join(process.cwd(), 'mcp-server.mjs')], + cwd: process.cwd(), + stderr: 'pipe' +}) + +let stderrOutput = '' +transport.stderr?.setEncoding('utf8') +transport.stderr?.on('data', (chunk) => { + stderrOutput += chunk +}) + +const client = new Client({ + name: 'notenextra-mcp-test', + version: '1.0.0' +}) + +async function main() { + await client.connect(transport) + + const toolListResponse = await client.listTools() + const toolNames = toolListResponse.tools.map((tool) => tool.name).sort() + assert.deepEqual(toolNames, ['list_notes', 'read_note', 'search_notes']) + + const listNotesResponse = await client.callTool({ + name: 'list_notes', + arguments: { + course: 'CSE442T' + } + }) + const listedNotes = listNotesResponse.content[0].text + assert.match(listedNotes, /CSE442T\/CSE442T_L1/, 'list_notes should include CSE442T lecture notes') + + const readNoteResponse = await client.callTool({ + name: 'read_note', + arguments: { + noteId: 'about' + } + }) + const aboutText = readNoteResponse.content[0].text + assert.match(aboutText, /# about/i) + assert.match(aboutText, /This is a static server for me to share my notes/i) + + const searchResponse = await client.callTool({ + name: 'search_notes', + arguments: { + query: "Kerckhoffs' principle", + limit: 3 + } + }) + const searchText = searchResponse.content[0].text + assert.match(searchText, /CSE442T\/CSE442T_L1/, 'search_notes should find the cryptography lecture') + assert.match(searchText, /Kerckhoffs/i) +} + +try { + await main() + process.stdout.write('MCP server test passed.\n') +} catch (error) { + const suffix = stderrOutput ? `\nServer stderr:\n${stderrOutput}` : '' + process.stderr.write(`${error.stack || error}${suffix}\n`) + process.exitCode = 1 +} finally { + await client.close().catch(() => {}) + await transport.close().catch(() => {}) +}