mcp addded by codex
This commit is contained in:
79
README.md
79
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
|
||||
|
||||
[](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
|
||||
|
||||
[](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
|
||||
|
||||
[](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
|
||||
|
||||
[](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
|
||||
```
|
||||
```
|
||||
|
||||
### 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`
|
||||
|
||||
207
mcp-server.mjs
Normal file
207
mcp-server.mjs
Normal file
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
74
test/test-mcp-server.mjs
Normal file
74
test/test-mcp-server.mjs
Normal file
@@ -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(() => {})
|
||||
}
|
||||
Reference in New Issue
Block a user