修复快照完整性:raw/llm_wiki 由 gitlink 转为普通目录(.git 备份为 .git.bak),新增 .gitignore 排除 __pycache__/pyc 与子仓库元数据
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
# LLM Wiki MCP Server
|
||||
|
||||
This package exposes the running LLM Wiki desktop app as a Model Context Protocol server.
|
||||
|
||||
It does **not** scan project folders directly and does **not** copy the app's search or graph logic. Every tool calls the local desktop API at `http://127.0.0.1:19828/api/v1`, so MCP clients use the same project registry, file permissions, search backend, graph backend, and Source Watch rules as the app.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 20+
|
||||
- LLM Wiki desktop app running
|
||||
- Settings → API + MCP → "Enable local HTTP API"
|
||||
- Settings → API + MCP → "Enable MCP access"
|
||||
- Either:
|
||||
- Settings → API + MCP → "Allow access without a token", or
|
||||
- `LLM_WIKI_API_TOKEN` set to the configured API token
|
||||
|
||||
Optional:
|
||||
|
||||
- `LLM_WIKI_API_BASE_URL` to override the default API base URL.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
cd mcp-server
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
LLM_WIKI_API_TOKEN=your-token node dist/src/index.js
|
||||
```
|
||||
|
||||
Example MCP client config:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"llm-wiki": {
|
||||
"command": "node",
|
||||
"args": ["/absolute/path/to/llm_wiki/mcp-server/dist/src/index.js"],
|
||||
"env": {
|
||||
"LLM_WIKI_API_TOKEN": "your-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When API unauthenticated mode is enabled, omit `LLM_WIKI_API_TOKEN`. If MCP access is disabled in Settings, `llm_wiki_status` still works for diagnosis but other tools return an explicit disabled error.
|
||||
|
||||
## Tools
|
||||
|
||||
- `llm_wiki_status`: health and current project summary.
|
||||
- `llm_wiki_projects`: known projects and active project.
|
||||
- `llm_wiki_set_project`: pin the MCP process session to a project. Once pinned, other project tools reject attempts to access a different project.
|
||||
- `llm_wiki_files`: list project files. `project_id` can be a project UUID, a project filesystem path, or `current`.
|
||||
- `llm_wiki_read_file`: read an allowed text file such as `wiki/index.md`.
|
||||
- `llm_wiki_reviews`: list Review tab items. Defaults to unresolved items and supports `status`, `type`, and `limit` filters.
|
||||
- `llm_wiki_search`: search with the app's shared keyword/vector backend.
|
||||
- `llm_wiki_chat`: ask the backend Agent chat endpoint and receive answer text, references, usage, and tool events. `mode: deep` broadens backend evidence collection; full Deep Research workflows still live in the desktop app.
|
||||
- `llm_wiki_graph`: query the app's knowledge graph endpoint.
|
||||
- `llm_wiki_rescan_sources`: trigger a Source Watch rescan using the user's configured rules.
|
||||
|
||||
## Security model
|
||||
|
||||
The MCP server inherits the desktop API's security model:
|
||||
|
||||
- It only talks to `127.0.0.1` by default.
|
||||
- It uses the same API token or unauthenticated setting as Settings → API + MCP.
|
||||
- File reads go through the API path allow-list. Internal app state files are not exposed.
|
||||
- Review data is exposed only through the dedicated Review endpoint/tool, which defaults to unresolved items rather than opening internal state files directly.
|
||||
- Search and graph tools operate on projects known to the app; use `project_id: "current"` for the active project.
|
||||
- For multi-project use, call `llm_wiki_set_project` once. The resolved project ID remains fixed for the lifetime of the MCP subprocess even if the desktop UI switches projects, and every project-tool response includes an `activeProject` marker.
|
||||
|
||||
Do not pass API tokens via command-line arguments. Prefer environment variables so they do not appear in shell history.
|
||||
+1197
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "llm-wiki-mcp-server",
|
||||
"version": "0.4.25",
|
||||
"description": "MCP server for LLM Wiki local API",
|
||||
"type": "module",
|
||||
"main": "dist/src/index.js",
|
||||
"bin": {
|
||||
"llm-wiki-mcp": "dist/src/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"start": "node dist/src/index.js",
|
||||
"test": "npm run build && node --test dist/test/*.test.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"keywords": [
|
||||
"mcp",
|
||||
"llm-wiki",
|
||||
"knowledge-base"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
export const DEFAULT_API_BASE_URL = "http://127.0.0.1:19828"
|
||||
|
||||
export interface LlmWikiApiClientOptions {
|
||||
baseUrl?: string
|
||||
token?: string
|
||||
fetchImpl?: typeof fetch
|
||||
}
|
||||
|
||||
export interface ApiProject {
|
||||
id: string
|
||||
name: string
|
||||
path: string
|
||||
current: boolean
|
||||
}
|
||||
|
||||
export interface ApiFileNode {
|
||||
name: string
|
||||
path: string
|
||||
isDir: boolean
|
||||
children?: ApiFileNode[]
|
||||
}
|
||||
|
||||
export interface ApiSearchResult {
|
||||
path: string
|
||||
title: string
|
||||
snippet: string
|
||||
score: number
|
||||
titleMatch?: boolean
|
||||
images?: Array<{ url: string; alt: string }>
|
||||
vectorScore?: number | null
|
||||
}
|
||||
|
||||
export interface ApiSearchResponse {
|
||||
results: ApiSearchResult[]
|
||||
mode?: string
|
||||
tokenHits?: number
|
||||
vectorHits?: number
|
||||
}
|
||||
|
||||
export interface ApiChatReference {
|
||||
title: string
|
||||
path: string
|
||||
kind: string
|
||||
snippet?: string
|
||||
score?: number
|
||||
}
|
||||
|
||||
export interface ApiChatToolEvent {
|
||||
tool: string
|
||||
status: string
|
||||
detail?: string
|
||||
}
|
||||
|
||||
export interface ApiChatEvent {
|
||||
type: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface ApiChatUsage {
|
||||
promptChars?: number
|
||||
completionChars?: number
|
||||
referenceCount?: number
|
||||
toolEventCount?: number
|
||||
}
|
||||
|
||||
export interface ApiChatResponse {
|
||||
projectId?: string
|
||||
sessionId: string
|
||||
mode?: string
|
||||
message: {
|
||||
role: string
|
||||
content: string
|
||||
}
|
||||
references: ApiChatReference[]
|
||||
toolEvents: ApiChatToolEvent[]
|
||||
events: ApiChatEvent[]
|
||||
usage?: ApiChatUsage
|
||||
}
|
||||
|
||||
export interface ApiGraphNode {
|
||||
id: string
|
||||
label: string
|
||||
type: string
|
||||
path?: string
|
||||
linkCount?: number
|
||||
weight?: number
|
||||
}
|
||||
|
||||
export interface ApiGraphEdge {
|
||||
source: string
|
||||
target: string
|
||||
weight?: number
|
||||
}
|
||||
|
||||
export type ApiReviewStatus = "unresolved" | "resolved" | "all"
|
||||
|
||||
export interface ApiReviewOption {
|
||||
label: string
|
||||
action: string
|
||||
}
|
||||
|
||||
export interface ApiReviewItem {
|
||||
id: string
|
||||
type: string
|
||||
title: string
|
||||
description: string
|
||||
sourcePath?: string
|
||||
affectedPages?: string[]
|
||||
searchQueries?: string[]
|
||||
options: ApiReviewOption[]
|
||||
resolved: boolean
|
||||
resolvedAction?: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export interface ApiReviewsResponse {
|
||||
projectId?: string
|
||||
status: ApiReviewStatus
|
||||
count: number
|
||||
reviews: ApiReviewItem[]
|
||||
}
|
||||
|
||||
export interface ApiFilesResponse {
|
||||
files: ApiFileNode[]
|
||||
truncated?: boolean
|
||||
}
|
||||
|
||||
export interface ApiHealth {
|
||||
ok?: boolean
|
||||
status?: string
|
||||
enabled?: boolean
|
||||
mcpEnabled?: boolean
|
||||
authRequired?: boolean
|
||||
authConfigured?: boolean
|
||||
allowUnauthenticated?: boolean
|
||||
tokenSource?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export function normalizeBaseUrl(value?: string): string {
|
||||
const raw = (value ?? DEFAULT_API_BASE_URL).trim() || DEFAULT_API_BASE_URL
|
||||
return raw.replace(/\/+$/, "")
|
||||
}
|
||||
|
||||
function apiPath(path: string): string {
|
||||
return path.startsWith("/api/v1") ? path : `/api/v1${path.startsWith("/") ? path : `/${path}`}`
|
||||
}
|
||||
|
||||
function requireObject(value: unknown, context: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error(`${context}: expected JSON object`)
|
||||
}
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
function numberOrUndefined(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined
|
||||
}
|
||||
|
||||
export class LlmWikiApiClient {
|
||||
private readonly baseUrl: string
|
||||
private readonly token?: string
|
||||
private readonly fetchImpl: typeof fetch
|
||||
|
||||
constructor(options: LlmWikiApiClientOptions = {}) {
|
||||
this.baseUrl = normalizeBaseUrl(options.baseUrl ?? process.env.LLM_WIKI_API_BASE_URL)
|
||||
this.token = options.token ?? process.env.LLM_WIKI_API_TOKEN
|
||||
this.fetchImpl = options.fetchImpl ?? fetch
|
||||
}
|
||||
|
||||
async health(): Promise<ApiHealth> {
|
||||
return this.request("/health", { auth: false }) as Promise<ApiHealth>
|
||||
}
|
||||
|
||||
async projects(): Promise<{ projects: ApiProject[]; currentProject: ApiProject | null }> {
|
||||
const json = await this.request("/projects")
|
||||
const projects = Array.isArray(json.projects) ? json.projects.map(parseProject) : []
|
||||
const currentProject = json.currentProject ? parseProject(json.currentProject) : null
|
||||
return { projects, currentProject }
|
||||
}
|
||||
|
||||
async files(projectId = "current", options: { root?: "wiki" | "sources" | "all"; recursive?: boolean; maxFiles?: number } = {}): Promise<ApiFilesResponse> {
|
||||
const params = new URLSearchParams()
|
||||
params.set("root", options.root ?? "wiki")
|
||||
if (options.recursive !== undefined) params.set("recursive", String(options.recursive))
|
||||
if (options.maxFiles !== undefined) params.set("maxFiles", String(options.maxFiles))
|
||||
const json = await this.request(`/projects/${encodeURIComponent(projectId)}/files?${params.toString()}`)
|
||||
return {
|
||||
files: Array.isArray(json.files) ? json.files.map(parseFileNode) : [],
|
||||
truncated: json.truncated === true,
|
||||
}
|
||||
}
|
||||
|
||||
async fileContent(projectId = "current", path: string): Promise<{ path: string; content: string }> {
|
||||
const params = new URLSearchParams({ path })
|
||||
const json = await this.request(`/projects/${encodeURIComponent(projectId)}/files/content?${params.toString()}`)
|
||||
return {
|
||||
path: typeof json.path === "string" ? json.path : path,
|
||||
content: typeof json.content === "string" ? json.content : "",
|
||||
}
|
||||
}
|
||||
|
||||
async reviews(projectId = "current", options: { status?: ApiReviewStatus; type?: string; limit?: number } = {}): Promise<ApiReviewsResponse> {
|
||||
const params = new URLSearchParams()
|
||||
if (options.status) params.set("status", options.status)
|
||||
if (options.type) params.set("type", options.type)
|
||||
if (options.limit !== undefined) params.set("limit", String(options.limit))
|
||||
const suffix = params.toString() ? `?${params.toString()}` : ""
|
||||
const json = await this.request(`/projects/${encodeURIComponent(projectId)}/reviews${suffix}`)
|
||||
const reviews = Array.isArray(json.reviews) ? json.reviews.map(parseReviewItem) : []
|
||||
return {
|
||||
projectId: typeof json.projectId === "string" ? json.projectId : undefined,
|
||||
status: parseReviewStatus(json.status),
|
||||
count: numberOrUndefined(json.count) ?? reviews.length,
|
||||
reviews,
|
||||
}
|
||||
}
|
||||
|
||||
async search(projectId = "current", query: string, options: { topK?: number; includeContent?: boolean } = {}): Promise<ApiSearchResponse> {
|
||||
const json = await this.request(`/projects/${encodeURIComponent(projectId)}/search`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
query,
|
||||
topK: options.topK,
|
||||
includeContent: options.includeContent,
|
||||
},
|
||||
})
|
||||
return {
|
||||
results: Array.isArray(json.results) ? json.results.map(parseSearchResult) : [],
|
||||
mode: typeof json.mode === "string" ? json.mode : undefined,
|
||||
tokenHits: numberOrUndefined(json.tokenHits),
|
||||
vectorHits: numberOrUndefined(json.vectorHits),
|
||||
}
|
||||
}
|
||||
|
||||
async chat(projectId = "current", message: string, options: { sessionId?: string; mode?: string; topK?: number; includeContent?: boolean; wiki?: boolean; web?: boolean; anytxt?: boolean; skills?: string[]; persistSession?: boolean } = {}): Promise<ApiChatResponse> {
|
||||
const json = await this.request(`/projects/${encodeURIComponent(projectId)}/chat`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
message,
|
||||
sessionId: options.sessionId,
|
||||
persistSession: options.persistSession,
|
||||
mode: options.mode,
|
||||
topK: options.topK,
|
||||
includeContent: options.includeContent,
|
||||
tools: {
|
||||
wiki: options.wiki ?? true,
|
||||
web: options.web ?? false,
|
||||
anytxt: options.anytxt ?? false,
|
||||
},
|
||||
skills: options.skills,
|
||||
},
|
||||
})
|
||||
const msg = requireObject(json.message, "chat message")
|
||||
return {
|
||||
projectId: typeof json.projectId === "string" ? json.projectId : undefined,
|
||||
sessionId: typeof json.sessionId === "string" ? json.sessionId : "",
|
||||
mode: typeof json.mode === "string" ? json.mode : undefined,
|
||||
message: {
|
||||
role: typeof msg.role === "string" ? msg.role : "assistant",
|
||||
content: typeof msg.content === "string" ? msg.content : "",
|
||||
},
|
||||
references: Array.isArray(json.references) ? json.references.map(parseChatReference) : [],
|
||||
toolEvents: Array.isArray(json.toolEvents) ? json.toolEvents.map(parseChatToolEvent) : [],
|
||||
events: Array.isArray(json.events) ? json.events.map(parseChatEvent) : [],
|
||||
usage: parseChatUsage(json.usage),
|
||||
}
|
||||
}
|
||||
|
||||
async cancelChat(projectId = "current", sessionId: string): Promise<{ sessionId: string; cancelled: boolean }> {
|
||||
const json = await this.request(`/projects/${encodeURIComponent(projectId)}/chat/${encodeURIComponent(sessionId)}/cancel`, {
|
||||
method: "POST",
|
||||
})
|
||||
return {
|
||||
sessionId: typeof json.sessionId === "string" ? json.sessionId : sessionId,
|
||||
cancelled: json.cancelled === true,
|
||||
}
|
||||
}
|
||||
|
||||
async graph(projectId = "current", options: { q?: string; nodeType?: string; limit?: number } = {}): Promise<{ nodes: ApiGraphNode[]; edges: ApiGraphEdge[] }> {
|
||||
const params = new URLSearchParams()
|
||||
if (options.q) params.set("q", options.q)
|
||||
if (options.nodeType) params.set("nodeType", options.nodeType)
|
||||
if (options.limit !== undefined) params.set("limit", String(options.limit))
|
||||
const suffix = params.toString() ? `?${params.toString()}` : ""
|
||||
const json = await this.request(`/projects/${encodeURIComponent(projectId)}/graph${suffix}`)
|
||||
return {
|
||||
nodes: Array.isArray(json.nodes) ? json.nodes.map(parseGraphNode) : [],
|
||||
edges: Array.isArray(json.edges) ? json.edges.map(parseGraphEdge) : [],
|
||||
}
|
||||
}
|
||||
|
||||
async rescan(projectId = "current"): Promise<Record<string, unknown>> {
|
||||
return this.request(`/projects/${encodeURIComponent(projectId)}/sources/rescan`, {
|
||||
method: "POST",
|
||||
})
|
||||
}
|
||||
|
||||
private async request(path: string, options: { method?: "GET" | "POST"; body?: unknown; auth?: boolean } = {}): Promise<Record<string, unknown>> {
|
||||
const url = `${this.baseUrl}${apiPath(path)}`
|
||||
const headers: Record<string, string> = { Accept: "application/json" }
|
||||
if (options.auth !== false && this.token?.trim()) {
|
||||
headers.Authorization = `Bearer ${this.token.trim()}`
|
||||
}
|
||||
if (options.body !== undefined) headers["Content-Type"] = "application/json"
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await this.fetchImpl(url, {
|
||||
method: options.method ?? (options.body === undefined ? "GET" : "POST"),
|
||||
headers,
|
||||
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||
})
|
||||
} catch (err) {
|
||||
throw new Error(`LLM Wiki API request failed. Is the desktop app running? ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
|
||||
const text = await response.text()
|
||||
let json: Record<string, unknown>
|
||||
try {
|
||||
json = text ? requireObject(JSON.parse(text), "LLM Wiki API response") : {}
|
||||
} catch (err) {
|
||||
throw new Error(`LLM Wiki API returned non-JSON response (${response.status}): ${text.slice(0, 300)}${err instanceof Error ? ` (${err.message})` : ""}`)
|
||||
}
|
||||
|
||||
if (!response.ok || json.ok === false) {
|
||||
const message = typeof json.error === "string" ? json.error : response.statusText
|
||||
throw new Error(`LLM Wiki API ${response.status}: ${message}`)
|
||||
}
|
||||
return json
|
||||
}
|
||||
}
|
||||
|
||||
function parseProject(value: unknown): ApiProject {
|
||||
const obj = requireObject(value, "project")
|
||||
return {
|
||||
id: String(obj.id ?? ""),
|
||||
name: String(obj.name ?? ""),
|
||||
path: String(obj.path ?? ""),
|
||||
current: obj.current === true,
|
||||
}
|
||||
}
|
||||
|
||||
function parseFileNode(value: unknown): ApiFileNode {
|
||||
const obj = requireObject(value, "file node")
|
||||
const children = Array.isArray(obj.children) ? obj.children.map(parseFileNode) : undefined
|
||||
return {
|
||||
name: String(obj.name ?? ""),
|
||||
path: String(obj.path ?? ""),
|
||||
isDir: obj.isDir === true || obj.is_dir === true,
|
||||
...(children ? { children } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function parseSearchResult(value: unknown): ApiSearchResult {
|
||||
const obj = requireObject(value, "search result")
|
||||
return {
|
||||
path: String(obj.path ?? ""),
|
||||
title: String(obj.title ?? ""),
|
||||
snippet: String(obj.snippet ?? ""),
|
||||
score: numberOrUndefined(obj.score) ?? 0,
|
||||
titleMatch: obj.titleMatch === true,
|
||||
images: Array.isArray(obj.images) ? obj.images.map((image) => {
|
||||
const item = requireObject(image, "image")
|
||||
return { url: String(item.url ?? ""), alt: String(item.alt ?? "") }
|
||||
}) : [],
|
||||
vectorScore: numberOrUndefined(obj.vectorScore) ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function parseChatReference(value: unknown): ApiChatReference {
|
||||
const obj = requireObject(value, "chat reference")
|
||||
return {
|
||||
title: String(obj.title ?? ""),
|
||||
path: String(obj.path ?? ""),
|
||||
kind: String(obj.kind ?? "wiki"),
|
||||
snippet: typeof obj.snippet === "string" ? obj.snippet : undefined,
|
||||
score: numberOrUndefined(obj.score),
|
||||
}
|
||||
}
|
||||
|
||||
function parseChatToolEvent(value: unknown): ApiChatToolEvent {
|
||||
const obj = requireObject(value, "chat tool event")
|
||||
return {
|
||||
tool: String(obj.tool ?? ""),
|
||||
status: String(obj.status ?? ""),
|
||||
detail: typeof obj.detail === "string" ? obj.detail : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function parseChatEvent(value: unknown): ApiChatEvent {
|
||||
const obj = requireObject(value, "chat event")
|
||||
return {
|
||||
...obj,
|
||||
type: String(obj.type ?? ""),
|
||||
}
|
||||
}
|
||||
|
||||
function parseChatUsage(value: unknown): ApiChatUsage | undefined {
|
||||
if (value === undefined || value === null) return undefined
|
||||
const obj = requireObject(value, "chat usage")
|
||||
return {
|
||||
promptChars: numberOrUndefined(obj.promptChars),
|
||||
completionChars: numberOrUndefined(obj.completionChars),
|
||||
referenceCount: numberOrUndefined(obj.referenceCount),
|
||||
toolEventCount: numberOrUndefined(obj.toolEventCount),
|
||||
}
|
||||
}
|
||||
|
||||
function parseReviewStatus(value: unknown): ApiReviewStatus {
|
||||
return value === "resolved" || value === "all" ? value : "unresolved"
|
||||
}
|
||||
|
||||
function stringArray(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined
|
||||
return value.map((item) => String(item))
|
||||
}
|
||||
|
||||
function parseReviewItem(value: unknown): ApiReviewItem {
|
||||
const obj = requireObject(value, "review item")
|
||||
return {
|
||||
id: String(obj.id ?? ""),
|
||||
type: String(obj.type ?? ""),
|
||||
title: String(obj.title ?? ""),
|
||||
description: String(obj.description ?? ""),
|
||||
sourcePath: typeof obj.sourcePath === "string" ? obj.sourcePath : undefined,
|
||||
affectedPages: stringArray(obj.affectedPages),
|
||||
searchQueries: stringArray(obj.searchQueries),
|
||||
options: Array.isArray(obj.options) ? obj.options.map((option) => {
|
||||
const item = requireObject(option, "review option")
|
||||
return { label: String(item.label ?? ""), action: String(item.action ?? "") }
|
||||
}) : [],
|
||||
resolved: obj.resolved === true,
|
||||
resolvedAction: typeof obj.resolvedAction === "string" ? obj.resolvedAction : undefined,
|
||||
createdAt: numberOrUndefined(obj.createdAt) ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
function parseGraphNode(value: unknown): ApiGraphNode {
|
||||
const obj = requireObject(value, "graph node")
|
||||
return {
|
||||
id: String(obj.id ?? ""),
|
||||
label: String(obj.label ?? ""),
|
||||
type: String(obj.nodeType ?? obj.type ?? "other"),
|
||||
path: typeof obj.path === "string" ? obj.path : undefined,
|
||||
linkCount: numberOrUndefined(obj.linkCount),
|
||||
weight: numberOrUndefined(obj.weight),
|
||||
}
|
||||
}
|
||||
|
||||
function parseGraphEdge(value: unknown): ApiGraphEdge {
|
||||
const obj = requireObject(value, "graph edge")
|
||||
return {
|
||||
source: String(obj.source ?? ""),
|
||||
target: String(obj.target ?? ""),
|
||||
weight: numberOrUndefined(obj.weight),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
#!/usr/bin/env node
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ErrorCode,
|
||||
ListToolsRequestSchema,
|
||||
McpError,
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
import {
|
||||
LlmWikiApiClient,
|
||||
type ApiFileNode,
|
||||
type ApiGraphNode,
|
||||
type ApiReviewItem,
|
||||
type ApiReviewsResponse,
|
||||
type ApiChatResponse,
|
||||
type ApiSearchResult,
|
||||
type ApiProject,
|
||||
} from "./api-client.js"
|
||||
import { VERSION } from "./version.js"
|
||||
import { McpProjectBinding, withActiveProject } from "./project-binding.js"
|
||||
|
||||
const DEFAULT_PROJECT_ID = "current"
|
||||
const MAX_TEXT_BYTES = 120_000
|
||||
|
||||
const client = new LlmWikiApiClient()
|
||||
const projectBinding = new McpProjectBinding()
|
||||
|
||||
const server = new Server(
|
||||
{ name: "llm-wiki", version: VERSION },
|
||||
{ capabilities: { tools: {} } },
|
||||
)
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: [
|
||||
{
|
||||
name: "llm_wiki_status",
|
||||
description: "Check whether the LLM Wiki desktop local API is reachable and list the current project.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "llm_wiki_projects",
|
||||
description: "List known LLM Wiki projects. The response includes currentProject when the desktop app has an active project.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "llm_wiki_set_project",
|
||||
description: "Pin this MCP process session to one LLM Wiki project. Once pinned, project tools cannot access another project until this tool changes the binding.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
project_id: { type: "string", description: "Project UUID, exact filesystem path, or 'current'." },
|
||||
},
|
||||
required: ["project_id"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "llm_wiki_files",
|
||||
description: "List files from a project using the desktop app's API permissions. project_id may be a UUID, filesystem path, or 'current'.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
project_id: { type: "string", description: "Project UUID, project path, or 'current'. Defaults to current." },
|
||||
root: { type: "string", enum: ["wiki", "sources", "all"], description: "Tree root to list. Defaults to wiki." },
|
||||
recursive: { type: "boolean", description: "Whether to list recursively. Defaults to true." },
|
||||
max_files: { type: "number", description: "Maximum files returned by the local API. Max 10000." },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "llm_wiki_read_file",
|
||||
description: "Read a text file from a project through the desktop app API. Only public project paths such as wiki/ and raw/sources/ are allowed by the API.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
project_id: { type: "string", description: "Project UUID, project path, or 'current'. Defaults to current." },
|
||||
path: { type: "string", description: "Project-relative file path, for example wiki/index.md." },
|
||||
},
|
||||
required: ["path"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "llm_wiki_reviews",
|
||||
description: "List Review tab items from a project. Defaults to unresolved items so agent clients can help manage pending wiki review work.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
project_id: { type: "string", description: "Project UUID, project path, or 'current'. Defaults to current." },
|
||||
status: { type: "string", enum: ["unresolved", "resolved", "all"], description: "Review status filter. Defaults to unresolved." },
|
||||
type: { type: "string", description: "Optional Review item type filter, for example missing-page, duplicate, contradiction, confirm, or suggestion." },
|
||||
limit: { type: "number", description: "Maximum review items returned. The local API clamps to its configured maximum." },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "llm_wiki_search",
|
||||
description: "Search a project using the same backend keyword/vector retrieval used by the desktop API.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
project_id: { type: "string", description: "Project UUID, project path, or 'current'. Defaults to current." },
|
||||
query: { type: "string", description: "Search query." },
|
||||
top_k: { type: "number", description: "Maximum results. The local API clamps to its configured maximum." },
|
||||
include_content: { type: "boolean", description: "Include full page content in results when supported by the API." },
|
||||
},
|
||||
required: ["query"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "llm_wiki_chat",
|
||||
description: "Ask the LLM Wiki backend Agent a question about a project. This initial backend Agent uses the desktop API's shared retrieval service and returns references.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
project_id: { type: "string", description: "Project UUID, project path, or 'current'. Defaults to current." },
|
||||
message: { type: "string", description: "User message or question." },
|
||||
session_id: { type: "string", description: "Optional caller-managed session id." },
|
||||
mode: { type: "string", enum: ["fast", "standard", "deep", "local_first"], description: "Agent mode. Defaults to standard." },
|
||||
top_k: { type: "number", description: "Maximum wiki references to retrieve. The API clamps to its configured maximum." },
|
||||
include_content: { type: "boolean", description: "Include full page content in retrieval when supported by the API. Defaults to false." },
|
||||
wiki: { type: "boolean", description: "Enable wiki retrieval. Defaults to true." },
|
||||
web: { type: "boolean", description: "Enable backend web.search when the Agent router decides external search is useful. Defaults to false." },
|
||||
anytxt: { type: "boolean", description: "Enable backend anytxt.search for source/local-file questions when AnyTXT is configured. Defaults to false." },
|
||||
skills: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Optional project skills to inject from .llm-wiki/skills.",
|
||||
},
|
||||
},
|
||||
required: ["message"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "llm_wiki_graph",
|
||||
description: "Query the project knowledge graph through the desktop app API.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
project_id: { type: "string", description: "Project UUID, project path, or 'current'. Defaults to current." },
|
||||
q: { type: "string", description: "Optional text filter." },
|
||||
node_type: { type: "string", description: "Optional node type filter." },
|
||||
limit: { type: "number", description: "Maximum nodes. The local API clamps to its configured maximum." },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "llm_wiki_rescan_sources",
|
||||
description: "Trigger the desktop app's source folder rescan for a project, using the user's Source Watch rules.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
project_id: { type: "string", description: "Project UUID, project path, or 'current'. Defaults to current." },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const args = asObject(request.params.arguments ?? {})
|
||||
try {
|
||||
switch (request.params.name) {
|
||||
case "llm_wiki_status": {
|
||||
const [health, projects] = await Promise.all([
|
||||
client.health(),
|
||||
client.projects().catch(() => ({ projects: [], currentProject: null })),
|
||||
])
|
||||
return textResult(JSON.stringify({ ...health, ...projects, sessionProject: projectBinding.project }, null, 2))
|
||||
}
|
||||
case "llm_wiki_projects": {
|
||||
await assertMcpEnabled()
|
||||
return textResult(JSON.stringify({ ...(await client.projects()), sessionProject: projectBinding.project }, null, 2))
|
||||
}
|
||||
case "llm_wiki_set_project": {
|
||||
await assertMcpEnabled()
|
||||
const requested = stringArg(args.project_id, "project_id")
|
||||
const projects = await client.projects()
|
||||
let pinned: ApiProject
|
||||
try {
|
||||
pinned = projectBinding.pin(requested, projects.projects, projects.currentProject)
|
||||
} catch (error) {
|
||||
throw new McpError(ErrorCode.InvalidParams, scopedErrorMessage(error))
|
||||
}
|
||||
return textResult(JSON.stringify({ activeProject: pinned, pinned: true }, null, 2))
|
||||
}
|
||||
case "llm_wiki_files": {
|
||||
await assertMcpEnabled()
|
||||
const scope = await resolveProjectScope(args)
|
||||
const response = await client.files(scope.id, {
|
||||
root: enumArg(args.root, ["wiki", "sources", "all"] as const, "wiki"),
|
||||
recursive: boolArg(args.recursive, true),
|
||||
maxFiles: numberArg(args.max_files),
|
||||
})
|
||||
return textResult(withActiveProject(formatFileTree(response.files, response.truncated), scope.project, scope.id))
|
||||
}
|
||||
case "llm_wiki_read_file": {
|
||||
await assertMcpEnabled()
|
||||
const relPath = stringArg(args.path, "path")
|
||||
const scope = await resolveProjectScope(args)
|
||||
const { path, content } = await client.fileContent(scope.id, relPath)
|
||||
return textResult(withActiveProject(`# ${path}\n\n${truncateText(content, MAX_TEXT_BYTES)}`, scope.project, scope.id))
|
||||
}
|
||||
case "llm_wiki_reviews": {
|
||||
await assertMcpEnabled()
|
||||
const scope = await resolveProjectScope(args)
|
||||
const reviews = await client.reviews(scope.id, {
|
||||
status: enumArg(args.status, ["unresolved", "resolved", "all"] as const, "unresolved"),
|
||||
type: optionalStringArg(args.type),
|
||||
limit: numberArg(args.limit),
|
||||
})
|
||||
return textResult(withActiveProject(formatReviews(reviews), scope.project, scope.id))
|
||||
}
|
||||
case "llm_wiki_search": {
|
||||
await assertMcpEnabled()
|
||||
const query = stringArg(args.query, "query")
|
||||
const scope = await resolveProjectScope(args)
|
||||
const search = await client.search(scope.id, query, {
|
||||
topK: numberArg(args.top_k),
|
||||
includeContent: boolArg(args.include_content, false),
|
||||
})
|
||||
return textResult(withActiveProject(formatSearchResults(query, search), scope.project, scope.id))
|
||||
}
|
||||
case "llm_wiki_chat": {
|
||||
await assertMcpEnabled()
|
||||
const message = stringArg(args.message, "message")
|
||||
const scope = await resolveProjectScope(args)
|
||||
const chat = await client.chat(scope.id, message, {
|
||||
sessionId: optionalStringArg(args.session_id),
|
||||
mode: enumArg(args.mode, ["fast", "standard", "deep", "local_first"] as const, "standard"),
|
||||
topK: numberArg(args.top_k),
|
||||
includeContent: boolArg(args.include_content, false),
|
||||
wiki: boolArg(args.wiki, true),
|
||||
web: boolArg(args.web, false),
|
||||
anytxt: boolArg(args.anytxt, false),
|
||||
skills: stringArrayArg(args.skills),
|
||||
persistSession: optionalStringArg(args.session_id) !== undefined,
|
||||
})
|
||||
return textResult(withActiveProject(formatChatResponse(chat), scope.project, scope.id))
|
||||
}
|
||||
case "llm_wiki_graph": {
|
||||
await assertMcpEnabled()
|
||||
const scope = await resolveProjectScope(args)
|
||||
const graph = await client.graph(scope.id, {
|
||||
q: optionalStringArg(args.q),
|
||||
nodeType: optionalStringArg(args.node_type),
|
||||
limit: numberArg(args.limit),
|
||||
})
|
||||
return textResult(withActiveProject(formatGraph(graph.nodes, graph.edges), scope.project, scope.id))
|
||||
}
|
||||
case "llm_wiki_rescan_sources": {
|
||||
await assertMcpEnabled()
|
||||
const scope = await resolveProjectScope(args)
|
||||
return textResult(withActiveProject(JSON.stringify(await client.rescan(scope.id), null, 2), scope.project, scope.id))
|
||||
}
|
||||
default:
|
||||
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`)
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof McpError) {
|
||||
throw new McpError(err.code, scopedErrorMessage(err.message))
|
||||
}
|
||||
throw new McpError(
|
||||
ErrorCode.InternalError,
|
||||
scopedErrorMessage(err),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
async function assertMcpEnabled(): Promise<void> {
|
||||
const health = await client.health()
|
||||
if (health.mcpEnabled === false) {
|
||||
throw new McpError(
|
||||
ErrorCode.InvalidRequest,
|
||||
"LLM Wiki MCP access is disabled. Enable Settings -> API + MCP -> Enable MCP access in the desktop app.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function textResult(text: string) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text }],
|
||||
}
|
||||
}
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return {}
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
async function resolveProjectScope(args: Record<string, unknown>): Promise<{ id: string; project: ApiProject | null }> {
|
||||
let id: string
|
||||
try {
|
||||
id = projectBinding.resolve(optionalStringArg(args.project_id) ?? undefined)
|
||||
} catch (error) {
|
||||
throw new McpError(ErrorCode.InvalidParams, scopedErrorMessage(error))
|
||||
}
|
||||
if (projectBinding.project) return { id, project: projectBinding.project }
|
||||
const projects = await client.projects()
|
||||
const project = id === DEFAULT_PROJECT_ID
|
||||
? projects.currentProject
|
||||
: projects.projects.find((candidate) => candidate.id === id || candidate.path === id) ?? null
|
||||
return { id, project }
|
||||
}
|
||||
|
||||
function scopedErrorMessage(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
const project = projectBinding.project
|
||||
if (!project || message.includes("[activeProject:")) return message
|
||||
return `[activeProject: ${project.name} (${project.id})] ${message}`
|
||||
}
|
||||
|
||||
function stringArg(value: unknown, name: string): string {
|
||||
if (typeof value !== "string" || value.trim() === "") {
|
||||
throw new McpError(ErrorCode.InvalidParams, `${name} is required`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function optionalStringArg(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() !== "" ? value : undefined
|
||||
}
|
||||
|
||||
function boolArg(value: unknown, fallback: boolean): boolean {
|
||||
return typeof value === "boolean" ? value : fallback
|
||||
}
|
||||
|
||||
function numberArg(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined
|
||||
}
|
||||
|
||||
function enumArg<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
|
||||
return typeof value === "string" && allowed.includes(value as T) ? value as T : fallback
|
||||
}
|
||||
|
||||
function stringArrayArg(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined
|
||||
return value.filter((item): item is string => typeof item === "string" && item.trim() !== "")
|
||||
}
|
||||
|
||||
function truncateText(value: string, maxBytes: number): string {
|
||||
const bytes = Buffer.byteLength(value, "utf8")
|
||||
if (bytes <= maxBytes) return value
|
||||
let out = ""
|
||||
let used = 0
|
||||
for (const ch of value) {
|
||||
const size = Buffer.byteLength(ch, "utf8")
|
||||
if (used + size > maxBytes) break
|
||||
out += ch
|
||||
used += size
|
||||
}
|
||||
return `${out}\n\n[truncated: ${bytes - used} bytes omitted]`
|
||||
}
|
||||
|
||||
function formatFileTree(files: ApiFileNode[], truncated = false): string {
|
||||
if (files.length === 0) return "No files found."
|
||||
const lines: string[] = truncated
|
||||
? ["[warning] File tree was truncated by the LLM Wiki API maxFiles limit.", ""]
|
||||
: []
|
||||
const walk = (nodes: ApiFileNode[], depth: number) => {
|
||||
for (const node of nodes) {
|
||||
const prefix = " ".repeat(depth)
|
||||
lines.push(`${prefix}${node.isDir ? "📁" : "📄"} ${node.path}`)
|
||||
if (node.children) walk(node.children, depth + 1)
|
||||
}
|
||||
}
|
||||
walk(files, 0)
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
function formatSearchResults(query: string, search: { results: ApiSearchResult[]; mode?: string; tokenHits?: number; vectorHits?: number }): string {
|
||||
const { results } = search
|
||||
if (results.length === 0) return `No results for "${query}".`
|
||||
const meta = [
|
||||
search.mode ? `Mode: ${search.mode}` : null,
|
||||
typeof search.tokenHits === "number" ? `Token hits: ${search.tokenHits}` : null,
|
||||
typeof search.vectorHits === "number" ? `Vector hits: ${search.vectorHits}` : null,
|
||||
].filter(Boolean)
|
||||
const lines = [`# Search results for "${query}"`, ...(meta.length > 0 ? [meta.join(" | ")] : []), ""]
|
||||
results.forEach((result, index) => {
|
||||
lines.push(`## ${index + 1}. ${result.title}`)
|
||||
lines.push(`Path: ${result.path}`)
|
||||
lines.push(`Score: ${result.score.toFixed(6)}${typeof result.vectorScore === "number" ? ` | Vector score: ${result.vectorScore.toFixed(6)}` : ""}`)
|
||||
if (result.snippet) lines.push(`Snippet: ${result.snippet}`)
|
||||
if (result.images && result.images.length > 0) {
|
||||
lines.push(`Images: ${result.images.map((image) => image.url).join(", ")}`)
|
||||
}
|
||||
lines.push("")
|
||||
})
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
function formatChatResponse(chat: ApiChatResponse): string {
|
||||
const lines = [
|
||||
"# LLM Wiki Agent response",
|
||||
"",
|
||||
`Session: ${chat.sessionId || "(none)"}`,
|
||||
chat.mode ? `Mode: ${chat.mode}` : null,
|
||||
chat.projectId ? `Project: ${chat.projectId}` : null,
|
||||
chat.usage
|
||||
? `Usage: promptChars=${chat.usage.promptChars ?? 0}, completionChars=${chat.usage.completionChars ?? 0}, references=${chat.usage.referenceCount ?? chat.references.length}`
|
||||
: null,
|
||||
"",
|
||||
chat.message.content || "(empty response)",
|
||||
"",
|
||||
].filter((line): line is string => line !== null)
|
||||
|
||||
if (chat.references.length > 0) {
|
||||
lines.push("## References")
|
||||
chat.references.forEach((reference, index) => {
|
||||
lines.push(`${index + 1}. ${reference.title || reference.path}`)
|
||||
lines.push(` Kind: ${reference.kind}`)
|
||||
lines.push(` Path: ${reference.path}`)
|
||||
if (typeof reference.score === "number") lines.push(` Score: ${reference.score.toFixed(6)}`)
|
||||
if (reference.snippet) lines.push(` Snippet: ${reference.snippet}`)
|
||||
})
|
||||
lines.push("")
|
||||
}
|
||||
|
||||
if (chat.toolEvents.length > 0) {
|
||||
lines.push("## Tool events")
|
||||
chat.toolEvents.forEach((event) => {
|
||||
lines.push(`- ${event.tool}: ${event.status}${event.detail ? ` (${event.detail})` : ""}`)
|
||||
})
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
function formatReviews(response: ApiReviewsResponse): string {
|
||||
const { reviews } = response
|
||||
if (reviews.length === 0) return `No ${response.status} review items found.`
|
||||
const lines = [
|
||||
"# Review items",
|
||||
"",
|
||||
`Status: ${response.status}`,
|
||||
`Count: ${response.count}`,
|
||||
"",
|
||||
]
|
||||
reviews.forEach((review, index) => {
|
||||
lines.push(`## ${index + 1}. ${review.title || review.id}`)
|
||||
lines.push(`ID: ${review.id}`)
|
||||
lines.push(`Type: ${review.type}`)
|
||||
lines.push(`Resolved: ${review.resolved ? "yes" : "no"}`)
|
||||
if (review.sourcePath) lines.push(`Source: ${review.sourcePath}`)
|
||||
if (review.affectedPages && review.affectedPages.length > 0) {
|
||||
lines.push(`Affected pages: ${review.affectedPages.join(", ")}`)
|
||||
}
|
||||
if (review.searchQueries && review.searchQueries.length > 0) {
|
||||
lines.push(`Search queries: ${review.searchQueries.join(", ")}`)
|
||||
}
|
||||
if (review.description) lines.push(`Description: ${review.description}`)
|
||||
const optionSummary = formatReviewOptions(review)
|
||||
if (optionSummary) lines.push(`Options: ${optionSummary}`)
|
||||
lines.push("")
|
||||
})
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
function formatReviewOptions(review: ApiReviewItem): string {
|
||||
if (!review.options || review.options.length === 0) return ""
|
||||
return review.options
|
||||
.map((option) => option.label ? `${option.label} (${option.action})` : option.action)
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
function formatGraph(nodes: ApiGraphNode[], edges: Array<{ source: string; target: string; weight?: number }>): string {
|
||||
const typeCounts = new Map<string, number>()
|
||||
for (const node of nodes) typeCounts.set(node.type, (typeCounts.get(node.type) ?? 0) + 1)
|
||||
const lines = [
|
||||
"# Knowledge graph",
|
||||
"",
|
||||
`Nodes: ${nodes.length}`,
|
||||
`Edges: ${edges.length}`,
|
||||
"",
|
||||
"## Node types",
|
||||
...[...typeCounts.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([type, count]) => `- ${type}: ${count}`),
|
||||
"",
|
||||
"## Top nodes",
|
||||
...nodes
|
||||
.slice()
|
||||
.sort((a, b) => (b.linkCount ?? 0) - (a.linkCount ?? 0))
|
||||
.slice(0, 30)
|
||||
.map((node) => `- ${node.label} (${node.type}, ${node.linkCount ?? 0} links)${node.path ? ` — ${node.path}` : ""}`),
|
||||
]
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const transport = new StdioServerTransport()
|
||||
await server.connect(transport)
|
||||
console.error(`LLM Wiki MCP server v${VERSION} connected to ${process.env.LLM_WIKI_API_BASE_URL ?? "http://127.0.0.1:19828"}`)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Failed to start LLM Wiki MCP server:", err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ApiProject } from "./api-client.js"
|
||||
|
||||
export class McpProjectBinding {
|
||||
private pinned: ApiProject | null = null
|
||||
|
||||
get project(): ApiProject | null {
|
||||
return this.pinned
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.pinned = null
|
||||
}
|
||||
|
||||
pin(requested: string, projects: ApiProject[], current: ApiProject | null): ApiProject {
|
||||
const candidate = requested === "current"
|
||||
? current
|
||||
: projects.find((project) => project.id === requested || project.path === requested) ?? null
|
||||
if (!candidate) throw new Error(`Unknown LLM Wiki project: ${requested}`)
|
||||
this.pinned = candidate
|
||||
return candidate
|
||||
}
|
||||
|
||||
resolve(requested?: string): string {
|
||||
if (!this.pinned) return requested ?? "current"
|
||||
if (
|
||||
requested &&
|
||||
requested !== "current" &&
|
||||
requested !== this.pinned.id &&
|
||||
requested !== this.pinned.path
|
||||
) {
|
||||
throw new Error(
|
||||
`This MCP session is pinned to ${this.pinned.name} (${this.pinned.id}); ` +
|
||||
`project override ${requested} was rejected. Call llm_wiki_set_project to change scope.`,
|
||||
)
|
||||
}
|
||||
return this.pinned.id
|
||||
}
|
||||
}
|
||||
|
||||
export function withActiveProject(text: string, project: ApiProject | null, requestedId: string): string {
|
||||
const scope = project
|
||||
? `${project.name} (${project.id})`
|
||||
: requestedId
|
||||
return `[activeProject: ${scope}]\n\n${text}`
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { readFileSync } from "node:fs"
|
||||
|
||||
export const FALLBACK_VERSION = "0.0.0"
|
||||
|
||||
export function loadMcpServerVersion(metaUrl: string = import.meta.url): string {
|
||||
// These layouts are mutually exclusive: source/dev execution resolves via
|
||||
// ../package.json, while compiled dist/src execution resolves via
|
||||
// ../../package.json.
|
||||
for (const relativePackageJson of ["../package.json", "../../package.json"]) {
|
||||
try {
|
||||
const candidate = new URL(relativePackageJson, metaUrl)
|
||||
const parsed = JSON.parse(readFileSync(candidate, "utf8")) as { version?: unknown }
|
||||
if (typeof parsed.version === "string" && parsed.version.trim()) {
|
||||
return parsed.version
|
||||
}
|
||||
} catch {
|
||||
// Try the next layout.
|
||||
}
|
||||
}
|
||||
|
||||
process.stderr.write("[llm-wiki-mcp] package.json version not found; using fallback 0.0.0\n")
|
||||
return FALLBACK_VERSION
|
||||
}
|
||||
|
||||
export const VERSION = loadMcpServerVersion()
|
||||
@@ -0,0 +1,233 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import { LlmWikiApiClient, normalizeBaseUrl } from "../src/api-client.js"
|
||||
|
||||
test("normalizeBaseUrl trims trailing slashes and falls back to localhost", () => {
|
||||
assert.equal(normalizeBaseUrl("http://127.0.0.1:19828///"), "http://127.0.0.1:19828")
|
||||
assert.equal(normalizeBaseUrl(""), "http://127.0.0.1:19828")
|
||||
})
|
||||
|
||||
test("projects sends bearer token and parses current project", async () => {
|
||||
const calls: Array<{ url: string; init?: RequestInit }> = []
|
||||
const fetchImpl = async (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
||||
calls.push({ url: String(url), init })
|
||||
return new Response(JSON.stringify({
|
||||
ok: true,
|
||||
projects: [{ id: "p1", name: "Demo", path: "/tmp/demo", current: true }],
|
||||
currentProject: { id: "p1", name: "Demo", path: "/tmp/demo", current: true },
|
||||
}), { status: 200 })
|
||||
}
|
||||
|
||||
const client = new LlmWikiApiClient({
|
||||
baseUrl: "http://localhost:19828/",
|
||||
token: "secret",
|
||||
fetchImpl,
|
||||
})
|
||||
const result = await client.projects()
|
||||
|
||||
assert.equal(calls[0]?.url, "http://localhost:19828/api/v1/projects")
|
||||
assert.equal((calls[0]?.init?.headers as Record<string, string>).Authorization, "Bearer secret")
|
||||
assert.equal(result.currentProject?.id, "p1")
|
||||
assert.equal(result.projects[0]?.current, true)
|
||||
})
|
||||
|
||||
test("health does not send authorization", async () => {
|
||||
const calls: Array<RequestInit | undefined> = []
|
||||
const fetchImpl = async (_url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
||||
calls.push(init)
|
||||
return new Response(JSON.stringify({ ok: true, status: "running" }), { status: 200 })
|
||||
}
|
||||
|
||||
const client = new LlmWikiApiClient({ token: "secret", fetchImpl })
|
||||
await client.health()
|
||||
|
||||
assert.equal((calls[0]?.headers as Record<string, string> | undefined)?.Authorization, undefined)
|
||||
})
|
||||
|
||||
test("search posts JSON body to current project", async () => {
|
||||
let body = ""
|
||||
const fetchImpl = async (_url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
||||
body = String(init?.body ?? "")
|
||||
return new Response(JSON.stringify({
|
||||
ok: true,
|
||||
mode: "hybrid",
|
||||
tokenHits: 2,
|
||||
vectorHits: 1,
|
||||
results: [{ path: "wiki/a.md", title: "A", snippet: "hit", score: 0.5, vectorScore: 0.9 }],
|
||||
}), { status: 200 })
|
||||
}
|
||||
|
||||
const client = new LlmWikiApiClient({ fetchImpl })
|
||||
const results = await client.search("current", "query", { topK: 3, includeContent: true })
|
||||
|
||||
assert.deepEqual(JSON.parse(body), { query: "query", topK: 3, includeContent: true })
|
||||
assert.equal(results.mode, "hybrid")
|
||||
assert.equal(results.tokenHits, 2)
|
||||
assert.equal(results.vectorHits, 1)
|
||||
assert.equal(results.results[0]?.vectorScore, 0.9)
|
||||
})
|
||||
|
||||
test("chat posts agent request and parses references", async () => {
|
||||
let url = ""
|
||||
let body = ""
|
||||
const fetchImpl = async (requestUrl: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
||||
url = String(requestUrl)
|
||||
body = String(init?.body ?? "")
|
||||
return new Response(JSON.stringify({
|
||||
ok: true,
|
||||
projectId: "p1",
|
||||
sessionId: "s1",
|
||||
mode: "standard",
|
||||
message: { role: "assistant", content: "answer" },
|
||||
references: [{ title: "A", path: "wiki/a.md", kind: "wiki", snippet: "hit", score: 0.5 }],
|
||||
toolEvents: [{ tool: "wiki.search", status: "completed", detail: "1 result" }],
|
||||
events: [{ type: "toolEnd", tool: "wiki.search" }],
|
||||
usage: { promptChars: 100, completionChars: 6, referenceCount: 1, toolEventCount: 1 },
|
||||
}), { status: 200 })
|
||||
}
|
||||
|
||||
const client = new LlmWikiApiClient({ baseUrl: "http://localhost:19828", fetchImpl })
|
||||
const response = await client.chat("current", "question", {
|
||||
sessionId: "s1",
|
||||
mode: "standard",
|
||||
topK: 4,
|
||||
includeContent: true,
|
||||
wiki: true,
|
||||
web: false,
|
||||
anytxt: true,
|
||||
skills: ["reviewer"],
|
||||
})
|
||||
|
||||
assert.equal(url, "http://localhost:19828/api/v1/projects/current/chat")
|
||||
assert.deepEqual(JSON.parse(body), {
|
||||
message: "question",
|
||||
sessionId: "s1",
|
||||
mode: "standard",
|
||||
topK: 4,
|
||||
includeContent: true,
|
||||
tools: { wiki: true, web: false, anytxt: true },
|
||||
skills: ["reviewer"],
|
||||
})
|
||||
assert.equal(response.sessionId, "s1")
|
||||
assert.equal(response.message.content, "answer")
|
||||
assert.equal(response.references[0]?.path, "wiki/a.md")
|
||||
assert.equal(response.toolEvents[0]?.tool, "wiki.search")
|
||||
assert.equal(response.events[0]?.type, "toolEnd")
|
||||
assert.equal(response.usage?.promptChars, 100)
|
||||
})
|
||||
|
||||
test("cancelChat posts to the chat cancellation endpoint", async () => {
|
||||
let url = ""
|
||||
let method = ""
|
||||
const fetchImpl = async (requestUrl: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
||||
url = String(requestUrl)
|
||||
method = String(init?.method ?? "")
|
||||
return new Response(JSON.stringify({
|
||||
ok: true,
|
||||
sessionId: "s1",
|
||||
cancelled: true,
|
||||
}), { status: 200 })
|
||||
}
|
||||
|
||||
const client = new LlmWikiApiClient({ baseUrl: "http://localhost:19828", fetchImpl })
|
||||
const response = await client.cancelChat("current", "s1")
|
||||
|
||||
assert.equal(url, "http://localhost:19828/api/v1/projects/current/chat/s1/cancel")
|
||||
assert.equal(method, "POST")
|
||||
assert.deepEqual(response, { sessionId: "s1", cancelled: true })
|
||||
})
|
||||
|
||||
test("graph parses nodeType from API graph nodes", async () => {
|
||||
const fetchImpl = async (): Promise<Response> => (
|
||||
new Response(JSON.stringify({
|
||||
ok: true,
|
||||
nodes: [{ id: "n1", label: "Node", nodeType: "concept", path: "wiki/concepts/n1.md", linkCount: 4 }],
|
||||
edges: [{ source: "n1", target: "n2", weight: 0.75 }],
|
||||
}), { status: 200 })
|
||||
)
|
||||
|
||||
const client = new LlmWikiApiClient({ fetchImpl })
|
||||
const graph = await client.graph("current")
|
||||
|
||||
assert.equal(graph.nodes[0]?.type, "concept")
|
||||
assert.equal(graph.nodes[0]?.linkCount, 4)
|
||||
assert.equal(graph.edges[0]?.weight, 0.75)
|
||||
})
|
||||
|
||||
test("files exposes truncated flag", async () => {
|
||||
const fetchImpl = async (): Promise<Response> => (
|
||||
new Response(JSON.stringify({
|
||||
ok: true,
|
||||
files: [{ name: "index.md", path: "wiki/index.md", isDir: false }],
|
||||
truncated: true,
|
||||
}), { status: 200 })
|
||||
)
|
||||
|
||||
const client = new LlmWikiApiClient({ fetchImpl })
|
||||
const files = await client.files("current")
|
||||
|
||||
assert.equal(files.truncated, true)
|
||||
assert.equal(files.files[0]?.path, "wiki/index.md")
|
||||
})
|
||||
|
||||
test("reviews requests unresolved review items with filters", async () => {
|
||||
const calls: string[] = []
|
||||
const fetchImpl = async (url: string | URL | Request): Promise<Response> => {
|
||||
calls.push(String(url))
|
||||
return new Response(JSON.stringify({
|
||||
ok: true,
|
||||
projectId: "p1",
|
||||
status: "unresolved",
|
||||
count: 1,
|
||||
reviews: [{
|
||||
id: "r1",
|
||||
type: "missing-page",
|
||||
title: "Missing page: Attention",
|
||||
description: "Add the Attention page",
|
||||
options: [],
|
||||
resolved: false,
|
||||
createdAt: 1,
|
||||
}],
|
||||
}), { status: 200 })
|
||||
}
|
||||
|
||||
const client = new LlmWikiApiClient({ baseUrl: "http://localhost:19828", fetchImpl })
|
||||
const result = await client.reviews("current", {
|
||||
status: "unresolved",
|
||||
type: "missing-page",
|
||||
limit: 5,
|
||||
})
|
||||
|
||||
assert.equal(calls[0], "http://localhost:19828/api/v1/projects/current/reviews?status=unresolved&type=missing-page&limit=5")
|
||||
assert.equal(result.status, "unresolved")
|
||||
assert.equal(result.count, 1)
|
||||
assert.equal(result.reviews[0]?.id, "r1")
|
||||
assert.equal(result.reviews[0]?.resolved, false)
|
||||
})
|
||||
|
||||
test("network failures include desktop app hint", async () => {
|
||||
const fetchImpl = async (): Promise<Response> => {
|
||||
throw new Error("ECONNREFUSED")
|
||||
}
|
||||
|
||||
const client = new LlmWikiApiClient({ fetchImpl })
|
||||
await assert.rejects(() => client.projects(), /Is the desktop app running\? ECONNREFUSED/)
|
||||
})
|
||||
|
||||
test("non-JSON responses include status and body preview", async () => {
|
||||
const fetchImpl = async (): Promise<Response> => (
|
||||
new Response("not json", { status: 502, statusText: "Bad Gateway" })
|
||||
)
|
||||
|
||||
const client = new LlmWikiApiClient({ fetchImpl })
|
||||
await assert.rejects(() => client.projects(), /non-JSON response \(502\): not json/)
|
||||
})
|
||||
|
||||
test("API errors include status and server message", async () => {
|
||||
const fetchImpl = async (): Promise<Response> => (
|
||||
new Response(JSON.stringify({ ok: false, error: "Unauthorized" }), { status: 401 })
|
||||
)
|
||||
|
||||
const client = new LlmWikiApiClient({ fetchImpl })
|
||||
await assert.rejects(() => client.projects(), /LLM Wiki API 401: Unauthorized/)
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import { McpProjectBinding, withActiveProject } from "../src/project-binding.js"
|
||||
|
||||
const alpha = { id: "p1", name: "Alpha", path: "/wiki/alpha", current: true }
|
||||
const beta = { id: "p2", name: "Beta", path: "/wiki/beta", current: false }
|
||||
|
||||
test("pin resolves current to a stable project id", () => {
|
||||
const binding = new McpProjectBinding()
|
||||
binding.pin("current", [alpha, beta], alpha)
|
||||
assert.equal(binding.resolve(), "p1")
|
||||
assert.equal(binding.resolve("current"), "p1")
|
||||
})
|
||||
|
||||
test("pinned sessions reject cross-project overrides", () => {
|
||||
const binding = new McpProjectBinding()
|
||||
binding.pin("p1", [alpha, beta], alpha)
|
||||
assert.equal(binding.resolve("/wiki/alpha"), "p1")
|
||||
assert.throws(() => binding.resolve("p2"), /override p2 was rejected/)
|
||||
})
|
||||
|
||||
test("unbound sessions preserve the current-project compatibility default", () => {
|
||||
const binding = new McpProjectBinding()
|
||||
assert.equal(binding.resolve(), "current")
|
||||
assert.equal(binding.resolve("p2"), "p2")
|
||||
})
|
||||
|
||||
test("responses carry a structural active-project reminder", () => {
|
||||
assert.match(withActiveProject("result", alpha, "p1"), /^\[activeProject: Alpha \(p1\)\]/)
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { readFileSync } from "node:fs"
|
||||
import { test } from "node:test"
|
||||
import { FALLBACK_VERSION, VERSION, loadMcpServerVersion } from "../src/version.js"
|
||||
|
||||
const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")) as {
|
||||
version: string
|
||||
}
|
||||
|
||||
test("MCP server version is read from package.json", () => {
|
||||
assert.equal(VERSION, pkg.version)
|
||||
})
|
||||
|
||||
test("MCP server version supports source-layout execution", () => {
|
||||
assert.equal(
|
||||
loadMcpServerVersion(new URL("../../src/version.ts", import.meta.url).href),
|
||||
pkg.version,
|
||||
)
|
||||
})
|
||||
|
||||
test("MCP server version falls back when package.json cannot be found", () => {
|
||||
assert.equal(loadMcpServerVersion("file:///tmp/llm-wiki-missing/dist/src/version.js"), FALLBACK_VERSION)
|
||||
})
|
||||
|
||||
test("MCP server version falls back for invalid meta URLs", () => {
|
||||
assert.equal(loadMcpServerVersion("not a url"), FALLBACK_VERSION)
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user