|
@@ -20,12 +20,12 @@ import {
|
|
|
createStore,
|
|
createStore,
|
|
|
extractSnippet,
|
|
extractSnippet,
|
|
|
addLineNumbers,
|
|
addLineNumbers,
|
|
|
- structuredSearch,
|
|
|
|
|
|
|
+ getDefaultDbPath,
|
|
|
DEFAULT_MULTI_GET_MAX_BYTES,
|
|
DEFAULT_MULTI_GET_MAX_BYTES,
|
|
|
-} from "./store.js";
|
|
|
|
|
-import type { Store, ExpandedQuery } from "./store.js";
|
|
|
|
|
-import { getCollection, getGlobalContext, getDefaultCollectionNames } from "./collections.js";
|
|
|
|
|
-import { disposeDefaultLlamaCpp } from "./llm.js";
|
|
|
|
|
|
|
+ type QMDStore,
|
|
|
|
|
+ type ExpandedQuery,
|
|
|
|
|
+ type IndexStatus,
|
|
|
|
|
+} from "../index.js";
|
|
|
|
|
|
|
|
// =============================================================================
|
|
// =============================================================================
|
|
|
// Types for structured content
|
|
// Types for structured content
|
|
@@ -46,8 +46,8 @@ type StatusResult = {
|
|
|
hasVectorIndex: boolean;
|
|
hasVectorIndex: boolean;
|
|
|
collections: {
|
|
collections: {
|
|
|
name: string;
|
|
name: string;
|
|
|
- path: string;
|
|
|
|
|
- pattern: string;
|
|
|
|
|
|
|
+ path: string | null;
|
|
|
|
|
+ pattern: string | null;
|
|
|
documents: number;
|
|
documents: number;
|
|
|
lastUpdated: string;
|
|
lastUpdated: string;
|
|
|
}[];
|
|
}[];
|
|
@@ -89,12 +89,13 @@ function formatSearchSummary(results: SearchResultItem[], query: string): string
|
|
|
* Injected into the LLM's system prompt via MCP initialize response —
|
|
* Injected into the LLM's system prompt via MCP initialize response —
|
|
|
* gives the LLM immediate context about what's searchable without a tool call.
|
|
* gives the LLM immediate context about what's searchable without a tool call.
|
|
|
*/
|
|
*/
|
|
|
-function buildInstructions(store: Store): string {
|
|
|
|
|
- const status = store.getStatus();
|
|
|
|
|
|
|
+async function buildInstructions(store: QMDStore): Promise<string> {
|
|
|
|
|
+ const status = await store.getStatus();
|
|
|
|
|
+ const contexts = await store.listContexts();
|
|
|
|
|
+ const globalCtx = await store.getGlobalContext();
|
|
|
const lines: string[] = [];
|
|
const lines: string[] = [];
|
|
|
|
|
|
|
|
// --- What is this? ---
|
|
// --- What is this? ---
|
|
|
- const globalCtx = getGlobalContext();
|
|
|
|
|
lines.push(`QMD is your local search engine over ${status.totalDocuments} markdown documents.`);
|
|
lines.push(`QMD is your local search engine over ${status.totalDocuments} markdown documents.`);
|
|
|
if (globalCtx) lines.push(`Context: ${globalCtx}`);
|
|
if (globalCtx) lines.push(`Context: ${globalCtx}`);
|
|
|
|
|
|
|
@@ -103,9 +104,9 @@ function buildInstructions(store: Store): string {
|
|
|
lines.push("");
|
|
lines.push("");
|
|
|
lines.push("Collections (scope with `collection` parameter):");
|
|
lines.push("Collections (scope with `collection` parameter):");
|
|
|
for (const col of status.collections) {
|
|
for (const col of status.collections) {
|
|
|
- const collConfig = getCollection(col.name);
|
|
|
|
|
- const rootCtx = collConfig?.context?.[""] || collConfig?.context?.["/"];
|
|
|
|
|
- const desc = rootCtx ? ` — ${rootCtx}` : "";
|
|
|
|
|
|
|
+ // Find root context for this collection
|
|
|
|
|
+ const rootCtx = contexts.find(c => c.collection === col.name && (c.path === "" || c.path === "/"));
|
|
|
|
|
+ const desc = rootCtx ? ` — ${rootCtx.context}` : "";
|
|
|
lines.push(` - "${col.name}" (${col.documents} docs)${desc}`);
|
|
lines.push(` - "${col.name}" (${col.documents} docs)${desc}`);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -154,12 +155,15 @@ function buildInstructions(store: Store): string {
|
|
|
* Create an MCP server with all QMD tools, resources, and prompts registered.
|
|
* Create an MCP server with all QMD tools, resources, and prompts registered.
|
|
|
* Shared by both stdio and HTTP transports.
|
|
* Shared by both stdio and HTTP transports.
|
|
|
*/
|
|
*/
|
|
|
-function createMcpServer(store: Store): McpServer {
|
|
|
|
|
|
|
+async function createMcpServer(store: QMDStore): Promise<McpServer> {
|
|
|
const server = new McpServer(
|
|
const server = new McpServer(
|
|
|
{ name: "qmd", version: "0.9.9" },
|
|
{ name: "qmd", version: "0.9.9" },
|
|
|
- { instructions: buildInstructions(store) },
|
|
|
|
|
|
|
+ { instructions: await buildInstructions(store) },
|
|
|
);
|
|
);
|
|
|
|
|
|
|
|
|
|
+ // Pre-fetch default collection names for search tools
|
|
|
|
|
+ const defaultCollectionNames = await store.getDefaultCollectionNames();
|
|
|
|
|
+
|
|
|
// ---------------------------------------------------------------------------
|
|
// ---------------------------------------------------------------------------
|
|
|
// Resource: qmd://{path} - read-only access to documents by path
|
|
// Resource: qmd://{path} - read-only access to documents by path
|
|
|
// Note: No list() - documents are discovered via search tools
|
|
// Note: No list() - documents are discovered via search tools
|
|
@@ -178,49 +182,23 @@ function createMcpServer(store: Store): McpServer {
|
|
|
const pathStr = Array.isArray(path) ? path.join('/') : (path || '');
|
|
const pathStr = Array.isArray(path) ? path.join('/') : (path || '');
|
|
|
const decodedPath = decodeURIComponent(pathStr);
|
|
const decodedPath = decodeURIComponent(pathStr);
|
|
|
|
|
|
|
|
- // Parse virtual path: collection/relative/path
|
|
|
|
|
- const parts = decodedPath.split('/');
|
|
|
|
|
- const collection = parts[0] || '';
|
|
|
|
|
- const relativePath = parts.slice(1).join('/');
|
|
|
|
|
-
|
|
|
|
|
- // Find document by collection and path, join with content table
|
|
|
|
|
- let doc = store.db.prepare(`
|
|
|
|
|
- SELECT d.collection, d.path, d.title, c.doc as body
|
|
|
|
|
- FROM documents d
|
|
|
|
|
- JOIN content c ON c.hash = d.hash
|
|
|
|
|
- WHERE d.collection = ? AND d.path = ? AND d.active = 1
|
|
|
|
|
- `).get(collection, relativePath) as { collection: string; path: string; title: string; body: string } | null;
|
|
|
|
|
-
|
|
|
|
|
- // Try suffix match if exact match fails
|
|
|
|
|
- if (!doc) {
|
|
|
|
|
- doc = store.db.prepare(`
|
|
|
|
|
- SELECT d.collection, d.path, d.title, c.doc as body
|
|
|
|
|
- FROM documents d
|
|
|
|
|
- JOIN content c ON c.hash = d.hash
|
|
|
|
|
- WHERE d.path LIKE ? AND d.active = 1
|
|
|
|
|
- LIMIT 1
|
|
|
|
|
- `).get(`%${relativePath}`) as { collection: string; path: string; title: string; body: string } | null;
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ // Use SDK to find document — findDocument handles collection/path resolution
|
|
|
|
|
+ const result = await store.get(decodedPath, { includeBody: true });
|
|
|
|
|
|
|
|
- if (!doc) {
|
|
|
|
|
|
|
+ if ("error" in result) {
|
|
|
return { contents: [{ uri: uri.href, text: `Document not found: ${decodedPath}` }] };
|
|
return { contents: [{ uri: uri.href, text: `Document not found: ${decodedPath}` }] };
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // Construct virtual path for context lookup
|
|
|
|
|
- const virtualPath = `qmd://${doc.collection}/${doc.path}`;
|
|
|
|
|
- const context = store.getContextForFile(virtualPath);
|
|
|
|
|
-
|
|
|
|
|
- let text = addLineNumbers(doc.body); // Default to line numbers
|
|
|
|
|
- if (context) {
|
|
|
|
|
- text = `<!-- Context: ${context} -->\n\n` + text;
|
|
|
|
|
|
|
+ let text = addLineNumbers(result.body || ""); // Default to line numbers
|
|
|
|
|
+ if (result.context) {
|
|
|
|
|
+ text = `<!-- Context: ${result.context} -->\n\n` + text;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- const displayName = `${doc.collection}/${doc.path}`;
|
|
|
|
|
return {
|
|
return {
|
|
|
contents: [{
|
|
contents: [{
|
|
|
uri: uri.href,
|
|
uri: uri.href,
|
|
|
- name: displayName,
|
|
|
|
|
- title: doc.title || doc.path,
|
|
|
|
|
|
|
+ name: result.displayPath,
|
|
|
|
|
+ title: result.title || result.displayPath,
|
|
|
mimeType: "text/markdown",
|
|
mimeType: "text/markdown",
|
|
|
text,
|
|
text,
|
|
|
}],
|
|
}],
|
|
@@ -322,19 +300,19 @@ Intent-aware lex (C++ performance, not sports):
|
|
|
},
|
|
},
|
|
|
async ({ searches, limit, minScore, candidateLimit, collections, intent }) => {
|
|
async ({ searches, limit, minScore, candidateLimit, collections, intent }) => {
|
|
|
// Map to internal format
|
|
// Map to internal format
|
|
|
- const subSearches: ExpandedQuery[] = searches.map(s => ({
|
|
|
|
|
|
|
+ const queries: ExpandedQuery[] = searches.map(s => ({
|
|
|
type: s.type,
|
|
type: s.type,
|
|
|
query: s.query,
|
|
query: s.query,
|
|
|
}));
|
|
}));
|
|
|
|
|
|
|
|
// Use default collections if none specified
|
|
// Use default collections if none specified
|
|
|
- const effectiveCollections = collections ?? getDefaultCollectionNames();
|
|
|
|
|
|
|
+ const effectiveCollections = collections ?? defaultCollectionNames;
|
|
|
|
|
|
|
|
- const results = await structuredSearch(store, subSearches, {
|
|
|
|
|
|
|
+ const results = await store.search({
|
|
|
|
|
+ queries,
|
|
|
collections: effectiveCollections.length > 0 ? effectiveCollections : undefined,
|
|
collections: effectiveCollections.length > 0 ? effectiveCollections : undefined,
|
|
|
limit,
|
|
limit,
|
|
|
minScore,
|
|
minScore,
|
|
|
- candidateLimit,
|
|
|
|
|
intent,
|
|
intent,
|
|
|
});
|
|
});
|
|
|
|
|
|
|
@@ -389,7 +367,7 @@ Intent-aware lex (C++ performance, not sports):
|
|
|
lookup = lookup.slice(0, -colonMatch[0].length);
|
|
lookup = lookup.slice(0, -colonMatch[0].length);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- const result = store.findDocument(lookup, { includeBody: false });
|
|
|
|
|
|
|
+ const result = await store.get(lookup, { includeBody: false });
|
|
|
|
|
|
|
|
if ("error" in result) {
|
|
if ("error" in result) {
|
|
|
let msg = `Document not found: ${file}`;
|
|
let msg = `Document not found: ${file}`;
|
|
@@ -402,7 +380,7 @@ Intent-aware lex (C++ performance, not sports):
|
|
|
};
|
|
};
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- const body = store.getDocumentBody(result, parsedFromLine, maxLines) ?? "";
|
|
|
|
|
|
|
+ const body = await store.getDocumentBody(result.filepath, { fromLine: parsedFromLine, maxLines }) ?? "";
|
|
|
let text = body;
|
|
let text = body;
|
|
|
if (lineNumbers) {
|
|
if (lineNumbers) {
|
|
|
const startLine = parsedFromLine || 1;
|
|
const startLine = parsedFromLine || 1;
|
|
@@ -445,7 +423,7 @@ Intent-aware lex (C++ performance, not sports):
|
|
|
},
|
|
},
|
|
|
},
|
|
},
|
|
|
async ({ pattern, maxLines, maxBytes, lineNumbers }) => {
|
|
async ({ pattern, maxLines, maxBytes, lineNumbers }) => {
|
|
|
- const { docs, errors } = store.findDocuments(pattern, { includeBody: true, maxBytes: maxBytes || DEFAULT_MULTI_GET_MAX_BYTES });
|
|
|
|
|
|
|
+ const { docs, errors } = await store.multiGet(pattern, { includeBody: true, maxBytes: maxBytes || DEFAULT_MULTI_GET_MAX_BYTES });
|
|
|
|
|
|
|
|
if (docs.length === 0 && errors.length === 0) {
|
|
if (docs.length === 0 && errors.length === 0) {
|
|
|
return {
|
|
return {
|
|
@@ -513,7 +491,7 @@ Intent-aware lex (C++ performance, not sports):
|
|
|
inputSchema: {},
|
|
inputSchema: {},
|
|
|
},
|
|
},
|
|
|
async () => {
|
|
async () => {
|
|
|
- const status: StatusResult = store.getStatus();
|
|
|
|
|
|
|
+ const status: StatusResult = await store.getStatus();
|
|
|
|
|
|
|
|
const summary = [
|
|
const summary = [
|
|
|
`QMD Index Status:`,
|
|
`QMD Index Status:`,
|
|
@@ -542,8 +520,8 @@ Intent-aware lex (C++ performance, not sports):
|
|
|
// =============================================================================
|
|
// =============================================================================
|
|
|
|
|
|
|
|
export async function startMcpServer(): Promise<void> {
|
|
export async function startMcpServer(): Promise<void> {
|
|
|
- const store = createStore();
|
|
|
|
|
- const server = createMcpServer(store);
|
|
|
|
|
|
|
+ const store = await createStore({ dbPath: getDefaultDbPath() });
|
|
|
|
|
+ const server = await createMcpServer(store);
|
|
|
const transport = new StdioServerTransport();
|
|
const transport = new StdioServerTransport();
|
|
|
await server.connect(transport);
|
|
await server.connect(transport);
|
|
|
}
|
|
}
|
|
@@ -563,7 +541,10 @@ export type HttpServerHandle = {
|
|
|
* Binds to localhost only. Returns a handle for shutdown and port discovery.
|
|
* Binds to localhost only. Returns a handle for shutdown and port discovery.
|
|
|
*/
|
|
*/
|
|
|
export async function startMcpHttpServer(port: number, options?: { quiet?: boolean }): Promise<HttpServerHandle> {
|
|
export async function startMcpHttpServer(port: number, options?: { quiet?: boolean }): Promise<HttpServerHandle> {
|
|
|
- const store = createStore();
|
|
|
|
|
|
|
+ const store = await createStore({ dbPath: getDefaultDbPath() });
|
|
|
|
|
+
|
|
|
|
|
+ // Pre-fetch default collection names for REST endpoint
|
|
|
|
|
+ const defaultCollectionNames = await store.getDefaultCollectionNames();
|
|
|
|
|
|
|
|
// Session map: each client gets its own McpServer + Transport pair (MCP spec requirement).
|
|
// Session map: each client gets its own McpServer + Transport pair (MCP spec requirement).
|
|
|
// The store is shared — it's stateless SQLite, safe for concurrent access.
|
|
// The store is shared — it's stateless SQLite, safe for concurrent access.
|
|
@@ -578,7 +559,7 @@ export async function startMcpHttpServer(port: number, options?: { quiet?: boole
|
|
|
log(`${ts()} New session ${sessionId} (${sessions.size} active)`);
|
|
log(`${ts()} New session ${sessionId} (${sessions.size} active)`);
|
|
|
},
|
|
},
|
|
|
});
|
|
});
|
|
|
- const server = createMcpServer(store);
|
|
|
|
|
|
|
+ const server = await createMcpServer(store);
|
|
|
await server.connect(transport);
|
|
await server.connect(transport);
|
|
|
|
|
|
|
|
transport.onclose = () => {
|
|
transport.onclose = () => {
|
|
@@ -645,7 +626,7 @@ export async function startMcpHttpServer(port: number, options?: { quiet?: boole
|
|
|
if ((pathname === "/query" || pathname === "/search") && nodeReq.method === "POST") {
|
|
if ((pathname === "/query" || pathname === "/search") && nodeReq.method === "POST") {
|
|
|
const rawBody = await collectBody(nodeReq);
|
|
const rawBody = await collectBody(nodeReq);
|
|
|
const params = JSON.parse(rawBody);
|
|
const params = JSON.parse(rawBody);
|
|
|
-
|
|
|
|
|
|
|
+
|
|
|
// Validate required fields
|
|
// Validate required fields
|
|
|
if (!params.searches || !Array.isArray(params.searches)) {
|
|
if (!params.searches || !Array.isArray(params.searches)) {
|
|
|
nodeRes.writeHead(400, { "Content-Type": "application/json" });
|
|
nodeRes.writeHead(400, { "Content-Type": "application/json" });
|
|
@@ -654,19 +635,20 @@ export async function startMcpHttpServer(port: number, options?: { quiet?: boole
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// Map to internal format
|
|
// Map to internal format
|
|
|
- const subSearches: ExpandedQuery[] = params.searches.map((s: any) => ({
|
|
|
|
|
|
|
+ const queries: ExpandedQuery[] = params.searches.map((s: any) => ({
|
|
|
type: s.type as 'lex' | 'vec' | 'hyde',
|
|
type: s.type as 'lex' | 'vec' | 'hyde',
|
|
|
query: String(s.query || ""),
|
|
query: String(s.query || ""),
|
|
|
}));
|
|
}));
|
|
|
|
|
|
|
|
// Use default collections if none specified
|
|
// Use default collections if none specified
|
|
|
- const effectiveCollections = params.collections ?? getDefaultCollectionNames();
|
|
|
|
|
|
|
+ const effectiveCollections = params.collections ?? defaultCollectionNames;
|
|
|
|
|
|
|
|
- const results = await structuredSearch(store, subSearches, {
|
|
|
|
|
|
|
+ const results = await store.search({
|
|
|
|
|
+ queries,
|
|
|
collections: effectiveCollections.length > 0 ? effectiveCollections : undefined,
|
|
collections: effectiveCollections.length > 0 ? effectiveCollections : undefined,
|
|
|
limit: params.limit ?? 10,
|
|
limit: params.limit ?? 10,
|
|
|
minScore: params.minScore ?? 0,
|
|
minScore: params.minScore ?? 0,
|
|
|
- candidateLimit: params.candidateLimit,
|
|
|
|
|
|
|
+ intent: params.intent,
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
// Use first lex or vec query for snippet extraction
|
|
// Use first lex or vec query for snippet extraction
|
|
@@ -801,8 +783,7 @@ export async function startMcpHttpServer(port: number, options?: { quiet?: boole
|
|
|
}
|
|
}
|
|
|
sessions.clear();
|
|
sessions.clear();
|
|
|
httpServer.close();
|
|
httpServer.close();
|
|
|
- store.close();
|
|
|
|
|
- await disposeDefaultLlamaCpp();
|
|
|
|
|
|
|
+ await store.close();
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
process.on("SIGTERM", async () => {
|
|
process.on("SIGTERM", async () => {
|
|
@@ -821,6 +802,6 @@ export async function startMcpHttpServer(port: number, options?: { quiet?: boole
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// Run if this is the main module
|
|
// Run if this is the main module
|
|
|
-if (fileURLToPath(import.meta.url) === process.argv[1] || process.argv[1]?.endsWith("/mcp.ts") || process.argv[1]?.endsWith("/mcp.js")) {
|
|
|
|
|
|
|
+if (fileURLToPath(import.meta.url) === process.argv[1] || process.argv[1]?.endsWith("/server.ts") || process.argv[1]?.endsWith("/server.js")) {
|
|
|
startMcpServer().catch(console.error);
|
|
startMcpServer().catch(console.error);
|
|
|
}
|
|
}
|