208 lines
5.5 KiB
JavaScript
208 lines
5.5 KiB
JavaScript
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)
|