修复快照完整性:raw/llm_wiki 由 gitlink 转为普通目录(.git 备份为 .git.bak),新增 .gitignore 排除 __pycache__/pyc 与子仓库元数据

This commit is contained in:
AAsige
2026-08-07 04:42:28 +08:00
parent ee302f615c
commit c6ec760e71
443 changed files with 147453 additions and 142 deletions
+77
View File
@@ -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.
File diff suppressed because it is too large Load Diff
+32
View File
@@ -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"
}
}
+458
View File
@@ -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),
}
}
+515
View File
@@ -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}`
}
+25
View File
@@ -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)
})
+18
View File
@@ -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"]
}