@gmickel/gno 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/README.md +256 -0
  2. package/assets/skill/SKILL.md +112 -0
  3. package/assets/skill/cli-reference.md +327 -0
  4. package/assets/skill/examples.md +234 -0
  5. package/assets/skill/mcp-reference.md +159 -0
  6. package/package.json +90 -0
  7. package/src/app/constants.ts +313 -0
  8. package/src/cli/colors.ts +65 -0
  9. package/src/cli/commands/ask.ts +545 -0
  10. package/src/cli/commands/cleanup.ts +105 -0
  11. package/src/cli/commands/collection/add.ts +120 -0
  12. package/src/cli/commands/collection/index.ts +10 -0
  13. package/src/cli/commands/collection/list.ts +108 -0
  14. package/src/cli/commands/collection/remove.ts +64 -0
  15. package/src/cli/commands/collection/rename.ts +95 -0
  16. package/src/cli/commands/context/add.ts +67 -0
  17. package/src/cli/commands/context/check.ts +153 -0
  18. package/src/cli/commands/context/index.ts +10 -0
  19. package/src/cli/commands/context/list.ts +109 -0
  20. package/src/cli/commands/context/rm.ts +52 -0
  21. package/src/cli/commands/doctor.ts +393 -0
  22. package/src/cli/commands/embed.ts +462 -0
  23. package/src/cli/commands/get.ts +356 -0
  24. package/src/cli/commands/index-cmd.ts +119 -0
  25. package/src/cli/commands/index.ts +102 -0
  26. package/src/cli/commands/init.ts +328 -0
  27. package/src/cli/commands/ls.ts +217 -0
  28. package/src/cli/commands/mcp/config.ts +300 -0
  29. package/src/cli/commands/mcp/index.ts +24 -0
  30. package/src/cli/commands/mcp/install.ts +203 -0
  31. package/src/cli/commands/mcp/paths.ts +470 -0
  32. package/src/cli/commands/mcp/status.ts +222 -0
  33. package/src/cli/commands/mcp/uninstall.ts +158 -0
  34. package/src/cli/commands/mcp.ts +20 -0
  35. package/src/cli/commands/models/clear.ts +103 -0
  36. package/src/cli/commands/models/index.ts +32 -0
  37. package/src/cli/commands/models/list.ts +214 -0
  38. package/src/cli/commands/models/path.ts +51 -0
  39. package/src/cli/commands/models/pull.ts +199 -0
  40. package/src/cli/commands/models/use.ts +85 -0
  41. package/src/cli/commands/multi-get.ts +400 -0
  42. package/src/cli/commands/query.ts +220 -0
  43. package/src/cli/commands/ref-parser.ts +108 -0
  44. package/src/cli/commands/reset.ts +191 -0
  45. package/src/cli/commands/search.ts +136 -0
  46. package/src/cli/commands/shared.ts +156 -0
  47. package/src/cli/commands/skill/index.ts +19 -0
  48. package/src/cli/commands/skill/install.ts +197 -0
  49. package/src/cli/commands/skill/paths-cmd.ts +81 -0
  50. package/src/cli/commands/skill/paths.ts +191 -0
  51. package/src/cli/commands/skill/show.ts +73 -0
  52. package/src/cli/commands/skill/uninstall.ts +141 -0
  53. package/src/cli/commands/status.ts +205 -0
  54. package/src/cli/commands/update.ts +68 -0
  55. package/src/cli/commands/vsearch.ts +188 -0
  56. package/src/cli/context.ts +64 -0
  57. package/src/cli/errors.ts +64 -0
  58. package/src/cli/format/search-results.ts +211 -0
  59. package/src/cli/options.ts +183 -0
  60. package/src/cli/program.ts +1330 -0
  61. package/src/cli/run.ts +213 -0
  62. package/src/cli/ui.ts +92 -0
  63. package/src/config/defaults.ts +20 -0
  64. package/src/config/index.ts +55 -0
  65. package/src/config/loader.ts +161 -0
  66. package/src/config/paths.ts +87 -0
  67. package/src/config/saver.ts +153 -0
  68. package/src/config/types.ts +280 -0
  69. package/src/converters/adapters/markitdownTs/adapter.ts +140 -0
  70. package/src/converters/adapters/officeparser/adapter.ts +126 -0
  71. package/src/converters/canonicalize.ts +89 -0
  72. package/src/converters/errors.ts +218 -0
  73. package/src/converters/index.ts +51 -0
  74. package/src/converters/mime.ts +163 -0
  75. package/src/converters/native/markdown.ts +115 -0
  76. package/src/converters/native/plaintext.ts +56 -0
  77. package/src/converters/path.ts +48 -0
  78. package/src/converters/pipeline.ts +159 -0
  79. package/src/converters/registry.ts +74 -0
  80. package/src/converters/types.ts +123 -0
  81. package/src/converters/versions.ts +24 -0
  82. package/src/index.ts +27 -0
  83. package/src/ingestion/chunker.ts +238 -0
  84. package/src/ingestion/index.ts +32 -0
  85. package/src/ingestion/language.ts +276 -0
  86. package/src/ingestion/sync.ts +671 -0
  87. package/src/ingestion/types.ts +219 -0
  88. package/src/ingestion/walker.ts +235 -0
  89. package/src/llm/cache.ts +467 -0
  90. package/src/llm/errors.ts +191 -0
  91. package/src/llm/index.ts +58 -0
  92. package/src/llm/nodeLlamaCpp/adapter.ts +133 -0
  93. package/src/llm/nodeLlamaCpp/embedding.ts +165 -0
  94. package/src/llm/nodeLlamaCpp/generation.ts +88 -0
  95. package/src/llm/nodeLlamaCpp/lifecycle.ts +317 -0
  96. package/src/llm/nodeLlamaCpp/rerank.ts +94 -0
  97. package/src/llm/registry.ts +86 -0
  98. package/src/llm/types.ts +129 -0
  99. package/src/mcp/resources/index.ts +151 -0
  100. package/src/mcp/server.ts +229 -0
  101. package/src/mcp/tools/get.ts +220 -0
  102. package/src/mcp/tools/index.ts +160 -0
  103. package/src/mcp/tools/multi-get.ts +263 -0
  104. package/src/mcp/tools/query.ts +226 -0
  105. package/src/mcp/tools/search.ts +119 -0
  106. package/src/mcp/tools/status.ts +81 -0
  107. package/src/mcp/tools/vsearch.ts +198 -0
  108. package/src/pipeline/chunk-lookup.ts +44 -0
  109. package/src/pipeline/expansion.ts +256 -0
  110. package/src/pipeline/explain.ts +115 -0
  111. package/src/pipeline/fusion.ts +185 -0
  112. package/src/pipeline/hybrid.ts +535 -0
  113. package/src/pipeline/index.ts +64 -0
  114. package/src/pipeline/query-language.ts +118 -0
  115. package/src/pipeline/rerank.ts +223 -0
  116. package/src/pipeline/search.ts +261 -0
  117. package/src/pipeline/types.ts +328 -0
  118. package/src/pipeline/vsearch.ts +348 -0
  119. package/src/store/index.ts +41 -0
  120. package/src/store/migrations/001-initial.ts +196 -0
  121. package/src/store/migrations/index.ts +20 -0
  122. package/src/store/migrations/runner.ts +187 -0
  123. package/src/store/sqlite/adapter.ts +1242 -0
  124. package/src/store/sqlite/index.ts +7 -0
  125. package/src/store/sqlite/setup.ts +129 -0
  126. package/src/store/sqlite/types.ts +28 -0
  127. package/src/store/types.ts +506 -0
  128. package/src/store/vector/index.ts +13 -0
  129. package/src/store/vector/sqlite-vec.ts +373 -0
  130. package/src/store/vector/stats.ts +152 -0
  131. package/src/store/vector/types.ts +115 -0
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Rerank port implementation using node-llama-cpp.
3
+ *
4
+ * @module src/llm/nodeLlamaCpp/rerank
5
+ */
6
+
7
+ import { inferenceFailedError } from '../errors';
8
+ import type { LlmResult, RerankPort, RerankScore } from '../types';
9
+ import type { ModelManager } from './lifecycle';
10
+
11
+ // ─────────────────────────────────────────────────────────────────────────────
12
+ // Types
13
+ // ─────────────────────────────────────────────────────────────────────────────
14
+
15
+ type LlamaModel = Awaited<
16
+ ReturnType<
17
+ Awaited<ReturnType<typeof import('node-llama-cpp').getLlama>>['loadModel']
18
+ >
19
+ >;
20
+
21
+ // ─────────────────────────────────────────────────────────────────────────────
22
+ // Implementation
23
+ // ─────────────────────────────────────────────────────────────────────────────
24
+
25
+ export class NodeLlamaCppRerank implements RerankPort {
26
+ private readonly manager: ModelManager;
27
+ readonly modelUri: string;
28
+ private readonly modelPath: string;
29
+
30
+ constructor(manager: ModelManager, modelUri: string, modelPath: string) {
31
+ this.manager = manager;
32
+ this.modelUri = modelUri;
33
+ this.modelPath = modelPath;
34
+ }
35
+
36
+ async rerank(
37
+ query: string,
38
+ documents: string[]
39
+ ): Promise<LlmResult<RerankScore[]>> {
40
+ if (documents.length === 0) {
41
+ return { ok: true, value: [] };
42
+ }
43
+
44
+ const model = await this.manager.loadModel(
45
+ this.modelPath,
46
+ this.modelUri,
47
+ 'rerank'
48
+ );
49
+ if (!model.ok) {
50
+ return model;
51
+ }
52
+
53
+ // Build index map for O(1) lookups (handles duplicates correctly)
54
+ const indexMap = new Map<string, number[]>();
55
+ for (let i = 0; i < documents.length; i += 1) {
56
+ const doc = documents[i] as string; // Guaranteed by loop bounds
57
+ const indices = indexMap.get(doc) ?? [];
58
+ indices.push(i);
59
+ indexMap.set(doc, indices);
60
+ }
61
+
62
+ const llamaModel = model.value.model as LlamaModel;
63
+ const context = await llamaModel.createRankingContext();
64
+
65
+ try {
66
+ const ranked = await context.rankAndSort(query, documents);
67
+
68
+ // Convert to RerankScore format with O(1) index lookup
69
+ const scores: RerankScore[] = ranked.map((item, rank) => {
70
+ const indices = indexMap.get(item.document) ?? [];
71
+ // Shift to handle duplicates (each duplicate gets next index)
72
+ const index = indices.shift() ?? -1;
73
+ return {
74
+ index,
75
+ score: item.score,
76
+ rank: rank + 1,
77
+ };
78
+ });
79
+
80
+ return { ok: true, value: scores };
81
+ } catch (e) {
82
+ return { ok: false, error: inferenceFailedError(this.modelUri, e) };
83
+ } finally {
84
+ await context.dispose().catch(() => {
85
+ // Ignore disposal errors
86
+ });
87
+ }
88
+ }
89
+
90
+ async dispose(): Promise<void> {
91
+ // Rerank doesn't hold persistent context
92
+ // Model cleanup is handled by ModelManager
93
+ }
94
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Model preset registry.
3
+ * Resolves active preset and model URIs from config.
4
+ *
5
+ * @module src/llm/registry
6
+ */
7
+
8
+ import type { Config, ModelConfig, ModelPreset } from '../config/types';
9
+ import { DEFAULT_MODEL_PRESETS } from '../config/types';
10
+ import type { ModelType } from './types';
11
+
12
+ // ─────────────────────────────────────────────────────────────────────────────
13
+ // Registry Functions
14
+ // ─────────────────────────────────────────────────────────────────────────────
15
+
16
+ /**
17
+ * Get model config with defaults.
18
+ */
19
+ export function getModelConfig(config: Config): ModelConfig {
20
+ return {
21
+ activePreset: config.models?.activePreset ?? 'balanced',
22
+ presets: config.models?.presets ?? DEFAULT_MODEL_PRESETS,
23
+ loadTimeout: config.models?.loadTimeout ?? 60_000,
24
+ inferenceTimeout: config.models?.inferenceTimeout ?? 30_000,
25
+ warmModelTtl: config.models?.warmModelTtl ?? 300_000,
26
+ };
27
+ }
28
+
29
+ /**
30
+ * Get the active preset from config.
31
+ * Falls back to first preset if active not found.
32
+ */
33
+ export function getActivePreset(config: Config): ModelPreset {
34
+ const modelConfig = getModelConfig(config);
35
+ const presetId = modelConfig.activePreset;
36
+ const preset = modelConfig.presets.find((p) => p.id === presetId);
37
+
38
+ if (preset) {
39
+ return preset;
40
+ }
41
+
42
+ // Fallback to first preset
43
+ const fallback = modelConfig.presets[0];
44
+ if (fallback) {
45
+ return fallback;
46
+ }
47
+
48
+ // Return built-in default (guaranteed to exist)
49
+ const builtIn = DEFAULT_MODEL_PRESETS[0];
50
+ if (!builtIn) {
51
+ throw new Error('No default model presets configured');
52
+ }
53
+ return builtIn;
54
+ }
55
+
56
+ /**
57
+ * Resolve a model URI for a given type.
58
+ * Uses override if provided, otherwise from active preset.
59
+ */
60
+ export function resolveModelUri(
61
+ config: Config,
62
+ type: ModelType,
63
+ override?: string
64
+ ): string {
65
+ if (override) {
66
+ return override;
67
+ }
68
+ const preset = getActivePreset(config);
69
+ return preset[type];
70
+ }
71
+
72
+ /**
73
+ * List all available presets.
74
+ */
75
+ export function listPresets(config: Config): ModelPreset[] {
76
+ const modelConfig = getModelConfig(config);
77
+ return modelConfig.presets;
78
+ }
79
+
80
+ /**
81
+ * Get a specific preset by ID.
82
+ */
83
+ export function getPreset(config: Config, id: string): ModelPreset | undefined {
84
+ const modelConfig = getModelConfig(config);
85
+ return modelConfig.presets.find((p) => p.id === id);
86
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * LLM subsystem types.
3
+ * Port interfaces for embedding, generation, and reranking.
4
+ *
5
+ * @module src/llm/types
6
+ */
7
+
8
+ import type { LlmError } from './errors';
9
+
10
+ // ─────────────────────────────────────────────────────────────────────────────
11
+ // Result Type
12
+ // ─────────────────────────────────────────────────────────────────────────────
13
+
14
+ export type LlmResult<T> =
15
+ | { ok: true; value: T }
16
+ | { ok: false; error: LlmError };
17
+
18
+ // ─────────────────────────────────────────────────────────────────────────────
19
+ // Model Types
20
+ // ─────────────────────────────────────────────────────────────────────────────
21
+
22
+ export type ModelType = 'embed' | 'rerank' | 'gen';
23
+
24
+ /** Model URI format: hf:org/repo/file.gguf or file:/path */
25
+ export type ModelUri = string;
26
+
27
+ // ModelPreset is defined in config/types.ts (source of truth)
28
+ // Re-exported from index.ts for convenience
29
+
30
+ export interface ModelCacheEntry {
31
+ uri: ModelUri;
32
+ type: ModelType;
33
+ path: string;
34
+ size: number;
35
+ checksum: string;
36
+ cachedAt: string;
37
+ }
38
+
39
+ export interface ModelStatus {
40
+ uri: ModelUri;
41
+ cached: boolean;
42
+ path: string | null;
43
+ size?: number;
44
+ }
45
+
46
+ // ─────────────────────────────────────────────────────────────────────────────
47
+ // Generation Parameters
48
+ // ─────────────────────────────────────────────────────────────────────────────
49
+
50
+ export interface GenParams {
51
+ /** Temperature (0 = deterministic). Default: 0 */
52
+ temperature?: number;
53
+ /** Random seed for reproducibility. Default: 42 */
54
+ seed?: number;
55
+ /** Max tokens to generate. Default: 256 */
56
+ maxTokens?: number;
57
+ /** Stop sequences */
58
+ stop?: string[];
59
+ }
60
+
61
+ // ─────────────────────────────────────────────────────────────────────────────
62
+ // Rerank Types
63
+ // ─────────────────────────────────────────────────────────────────────────────
64
+
65
+ export interface RerankScore {
66
+ /** Original index in input array */
67
+ index: number;
68
+ /** Relevance score (higher = more relevant) */
69
+ score: number;
70
+ /** Rank position (1 = best) */
71
+ rank: number;
72
+ }
73
+
74
+ // ─────────────────────────────────────────────────────────────────────────────
75
+ // Port Interfaces
76
+ // ─────────────────────────────────────────────────────────────────────────────
77
+
78
+ export interface EmbeddingPort {
79
+ readonly modelUri: string;
80
+ /** Initialize the embedding context (loads model). Call before dimensions(). */
81
+ init(): Promise<LlmResult<void>>;
82
+ embed(text: string): Promise<LlmResult<number[]>>;
83
+ embedBatch(texts: string[]): Promise<LlmResult<number[][]>>;
84
+ /** Returns embedding dimensions. Must call init() first. */
85
+ dimensions(): number;
86
+ dispose(): Promise<void>;
87
+ }
88
+
89
+ export interface GenerationPort {
90
+ readonly modelUri: string;
91
+ generate(prompt: string, params?: GenParams): Promise<LlmResult<string>>;
92
+ dispose(): Promise<void>;
93
+ }
94
+
95
+ export interface RerankPort {
96
+ readonly modelUri: string;
97
+ rerank(query: string, documents: string[]): Promise<LlmResult<RerankScore[]>>;
98
+ dispose(): Promise<void>;
99
+ }
100
+
101
+ // ─────────────────────────────────────────────────────────────────────────────
102
+ // Loaded Model (internal)
103
+ // ─────────────────────────────────────────────────────────────────────────────
104
+
105
+ export interface LoadedModel {
106
+ uri: ModelUri;
107
+ type: ModelType;
108
+ model: unknown; // LlamaModel from node-llama-cpp
109
+ loadedAt: number;
110
+ }
111
+
112
+ // ─────────────────────────────────────────────────────────────────────────────
113
+ // Config Types
114
+ // ─────────────────────────────────────────────────────────────────────────────
115
+
116
+ // ModelConfig is defined in config/types.ts (source of truth)
117
+ // Re-exported from index.ts for convenience
118
+
119
+ // ─────────────────────────────────────────────────────────────────────────────
120
+ // Progress Callback
121
+ // ─────────────────────────────────────────────────────────────────────────────
122
+
123
+ export interface DownloadProgress {
124
+ downloadedBytes: number;
125
+ totalBytes: number;
126
+ percent: number;
127
+ }
128
+
129
+ export type ProgressCallback = (progress: DownloadProgress) => void;
@@ -0,0 +1,151 @@
1
+ /**
2
+ * MCP resource registration for gno:// URIs.
3
+ *
4
+ * @module src/mcp/resources
5
+ */
6
+
7
+ import { join as pathJoin } from 'node:path';
8
+ import {
9
+ type McpServer,
10
+ ResourceTemplate,
11
+ } from '@modelcontextprotocol/sdk/server/mcp.js';
12
+ import { buildUri, parseUri, URI_PREFIX } from '../../app/constants';
13
+ import type { DocumentRow } from '../../store/types';
14
+ import type { ToolContext } from '../server';
15
+
16
+ /**
17
+ * Format document content with header comment and line numbers.
18
+ */
19
+ function formatResourceContent(
20
+ doc: DocumentRow,
21
+ content: string,
22
+ ctx: ToolContext
23
+ ): string {
24
+ // Find collection for absPath
25
+ const uriParsed = parseUri(doc.uri);
26
+ let absPath = doc.relPath;
27
+ if (uriParsed) {
28
+ const collection = ctx.collections.find(
29
+ (c) => c.name === uriParsed.collection
30
+ );
31
+ if (collection) {
32
+ absPath = pathJoin(collection.path, doc.relPath);
33
+ }
34
+ }
35
+
36
+ // Header comment per spec (includes language if available)
37
+ const langLine = doc.languageHint
38
+ ? `\n language: ${doc.languageHint}`
39
+ : '';
40
+ const header = `<!-- ${doc.uri}
41
+ docid: ${doc.docid}
42
+ source: ${absPath}
43
+ mime: ${doc.sourceMime}${langLine}
44
+ -->
45
+
46
+ `;
47
+
48
+ // Line numbers per spec (default ON for agent friendliness)
49
+ const lines = content.split('\n');
50
+ const numbered = lines.map((line, i) => `${i + 1}: ${line}`).join('\n');
51
+
52
+ return header + numbered;
53
+ }
54
+
55
+ /**
56
+ * Register gno:// resources with the MCP server.
57
+ */
58
+ export function registerResources(server: McpServer, ctx: ToolContext): void {
59
+ // Resource template for gno://{collection}/{path} URIs
60
+ const template = new ResourceTemplate(`${URI_PREFIX}{collection}/{+path}`, {
61
+ list: async () => {
62
+ // List all documents as resources
63
+ const listResult = await ctx.store.listDocuments();
64
+ if (!listResult.ok) {
65
+ return { resources: [] };
66
+ }
67
+
68
+ return {
69
+ resources: listResult.value.map((doc) => ({
70
+ uri: doc.uri,
71
+ name: doc.relPath,
72
+ mimeType: doc.sourceMime || 'text/markdown',
73
+ description: doc.title ?? undefined,
74
+ })),
75
+ };
76
+ },
77
+ });
78
+
79
+ // Register the template-based resource handler
80
+ server.resource('gno-document', template, {}, async (uri, _variables) => {
81
+ // Check shutdown before acquiring mutex
82
+ if (ctx.isShuttingDown()) {
83
+ throw new Error('Server is shutting down');
84
+ }
85
+
86
+ // Serialize resource reads same as tools (prevent concurrent DB access + shutdown race)
87
+ const release = await ctx.toolMutex.acquire();
88
+ try {
89
+ // Use parseUri for proper URL decoding (handles %20, etc.)
90
+ const parsed = parseUri(uri.href);
91
+ if (!parsed) {
92
+ throw new Error(`Invalid gno:// URI: ${uri.href}`);
93
+ }
94
+
95
+ const { collection, path } = parsed;
96
+
97
+ // Validate collection exists
98
+ const collectionExists = ctx.collections.some(
99
+ (c) => c.name === collection
100
+ );
101
+ if (!collectionExists) {
102
+ throw new Error(`Collection not found: ${collection}`);
103
+ }
104
+
105
+ // Look up document (path is properly decoded by parseUri)
106
+ const docResult = await ctx.store.getDocument(collection, path);
107
+ if (!docResult.ok) {
108
+ throw new Error(
109
+ `Failed to lookup document: ${docResult.error.message}`
110
+ );
111
+ }
112
+
113
+ const doc = docResult.value;
114
+ if (!doc) {
115
+ throw new Error(`Document not found: ${uri.href}`);
116
+ }
117
+
118
+ // Get content
119
+ if (!doc.mirrorHash) {
120
+ throw new Error(`Document has no indexed content: ${uri.href}`);
121
+ }
122
+
123
+ const contentResult = await ctx.store.getContent(doc.mirrorHash);
124
+ if (!contentResult.ok) {
125
+ throw new Error(
126
+ `Failed to read content: ${contentResult.error.message}`
127
+ );
128
+ }
129
+
130
+ const content = contentResult.value ?? '';
131
+
132
+ // Format with header and line numbers
133
+ const formattedContent = formatResourceContent(doc, content, ctx);
134
+
135
+ // Build canonical URI
136
+ const canonicalUri = buildUri(collection, path);
137
+
138
+ return {
139
+ contents: [
140
+ {
141
+ uri: canonicalUri,
142
+ mimeType: 'text/markdown',
143
+ text: formattedContent,
144
+ },
145
+ ],
146
+ };
147
+ } finally {
148
+ release();
149
+ }
150
+ });
151
+ }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * MCP Server implementation for GNO.
3
+ * Exposes search, retrieval, and status tools over stdio transport.
4
+ *
5
+ * @module src/mcp/server
6
+ */
7
+
8
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
9
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
10
+ import { MCP_SERVER_NAME, VERSION } from '../app/constants';
11
+ import type { Collection, Config } from '../config/types';
12
+ import type { SqliteAdapter } from '../store/sqlite/adapter';
13
+
14
+ // ─────────────────────────────────────────────────────────────────────────────
15
+ // Simple Promise Mutex (avoids async-mutex dependency)
16
+ // ─────────────────────────────────────────────────────────────────────────────
17
+
18
+ class Mutex {
19
+ #locked = false;
20
+ readonly #queue: Array<() => void> = [];
21
+
22
+ acquire(): Promise<() => void> {
23
+ return new Promise((resolve) => {
24
+ const tryAcquire = () => {
25
+ if (this.#locked) {
26
+ this.#queue.push(tryAcquire);
27
+ } else {
28
+ this.#locked = true;
29
+ resolve(() => this.#release());
30
+ }
31
+ };
32
+ tryAcquire();
33
+ });
34
+ }
35
+
36
+ #release(): void {
37
+ this.#locked = false;
38
+ const next = this.#queue.shift();
39
+ if (next) {
40
+ next();
41
+ }
42
+ }
43
+ }
44
+
45
+ // ─────────────────────────────────────────────────────────────────────────────
46
+ // Tool Context
47
+ // ─────────────────────────────────────────────────────────────────────────────
48
+
49
+ export interface ToolContext {
50
+ store: SqliteAdapter;
51
+ config: Config;
52
+ collections: Collection[];
53
+ actualConfigPath: string;
54
+ toolMutex: Mutex;
55
+ isShuttingDown: () => boolean;
56
+ }
57
+
58
+ // ─────────────────────────────────────────────────────────────────────────────
59
+ // Server Options
60
+ // ─────────────────────────────────────────────────────────────────────────────
61
+
62
+ export interface McpServerOptions {
63
+ indexName?: string;
64
+ configPath?: string;
65
+ verbose?: boolean;
66
+ }
67
+
68
+ // ─────────────────────────────────────────────────────────────────────────────
69
+ // Server Lifecycle
70
+ // ─────────────────────────────────────────────────────────────────────────────
71
+
72
+ export async function startMcpServer(options: McpServerOptions): Promise<void> {
73
+ // ========================================
74
+ // STDOUT PURITY GUARD (CRITICAL)
75
+ // ========================================
76
+ // Wrap stdout to catch accidental writes during init
77
+ const originalStdoutWrite = process.stdout.write.bind(process.stdout);
78
+ let protocolMode = false;
79
+
80
+ // Stdout wrapper - redirect to stderr during init
81
+ // biome-ignore lint/suspicious/noExplicitAny: overloaded write signature
82
+ (process.stdout as any).write = (
83
+ chunk: string | Uint8Array,
84
+ encodingOrCb?: BufferEncoding | ((err?: Error | null) => void),
85
+ cb?: (err?: Error | null) => void
86
+ ): boolean => {
87
+ if (!protocolMode) {
88
+ // During init, redirect to stderr
89
+ if (typeof encodingOrCb === 'function') {
90
+ return process.stderr.write(chunk, encodingOrCb);
91
+ }
92
+ return process.stderr.write(chunk, encodingOrCb, cb);
93
+ }
94
+ // After transport connected, allow JSON-RPC only
95
+ if (typeof encodingOrCb === 'function') {
96
+ return originalStdoutWrite(chunk, encodingOrCb);
97
+ }
98
+ return originalStdoutWrite(chunk, encodingOrCb, cb);
99
+ };
100
+
101
+ // Lazy import to avoid pulling in all deps on --help
102
+ const { initStore } = await import('../cli/commands/shared.js');
103
+
104
+ // Open DB once with index/config threading
105
+ const init = await initStore({
106
+ indexName: options.indexName,
107
+ configPath: options.configPath,
108
+ });
109
+
110
+ if (!init.ok) {
111
+ console.error('Failed to initialize:', init.error);
112
+ process.exit(1);
113
+ }
114
+
115
+ const { store, config, collections, actualConfigPath } = init;
116
+
117
+ // Create MCP server
118
+ const server = new McpServer(
119
+ {
120
+ name: MCP_SERVER_NAME,
121
+ version: VERSION,
122
+ },
123
+ {
124
+ capabilities: {
125
+ tools: { listChanged: false },
126
+ resources: { subscribe: false, listChanged: false },
127
+ },
128
+ }
129
+ );
130
+
131
+ // Sequential execution mutex
132
+ const toolMutex = new Mutex();
133
+
134
+ // Shutdown state
135
+ let shuttingDown = false;
136
+
137
+ // Tool context (passed to all handlers)
138
+ const ctx: ToolContext = {
139
+ store,
140
+ config,
141
+ collections,
142
+ actualConfigPath,
143
+ toolMutex,
144
+ isShuttingDown: () => shuttingDown,
145
+ };
146
+
147
+ // Register tools (T10.2)
148
+ const { registerTools } = await import('./tools/index.js');
149
+ registerTools(server, ctx);
150
+
151
+ // Register resources (T10.3)
152
+ const { registerResources } = await import('./resources/index.js');
153
+ registerResources(server, ctx);
154
+
155
+ if (options.verbose) {
156
+ console.error(
157
+ `[MCP] Loaded ${ctx.collections.length} collections from ${ctx.actualConfigPath}`
158
+ );
159
+ }
160
+
161
+ // ========================================
162
+ // GRACEFUL SHUTDOWN (ordered)
163
+ // ========================================
164
+ const shutdown = async () => {
165
+ if (shuttingDown) {
166
+ return;
167
+ }
168
+ shuttingDown = true;
169
+
170
+ console.error('[MCP] Shutting down...');
171
+
172
+ // 1. Wait for current handler (no timeout - correctness over speed)
173
+ // If we timeout and close DB while tool is running, we risk corruption
174
+ const release = await toolMutex.acquire();
175
+ release();
176
+
177
+ // 2. Close MCP server/transport (flush buffers, clean disconnect)
178
+ try {
179
+ await server.close();
180
+ } catch {
181
+ // Best-effort - server may already be closed
182
+ }
183
+
184
+ // 3. Close DB (safe now - no tool is running)
185
+ await store.close();
186
+
187
+ // 4. Exit
188
+ process.exit(0);
189
+ };
190
+
191
+ process.on('SIGTERM', shutdown);
192
+ process.on('SIGINT', shutdown);
193
+
194
+ // ========================================
195
+ // CONSOLE REDIRECT (CRITICAL for stdout purity)
196
+ // ========================================
197
+ // Redirect console.log/info/debug/warn to stderr to prevent JSON-RPC corruption
198
+ // Save originals (prefixed with _ to indicate intentionally unused)
199
+ const _origLog = console.log;
200
+ const _origInfo = console.info;
201
+ const _origDebug = console.debug;
202
+ const _origWarn = console.warn;
203
+ console.log = (...args: unknown[]) => console.error('[log]', ...args);
204
+ console.info = (...args: unknown[]) => console.error('[info]', ...args);
205
+ console.debug = (...args: unknown[]) => console.error('[debug]', ...args);
206
+ console.warn = (...args: unknown[]) => console.error('[warn]', ...args);
207
+
208
+ // Connect transport
209
+ const transport = new StdioServerTransport();
210
+ protocolMode = true; // Enable stdout for JSON-RPC
211
+
212
+ await server.connect(transport);
213
+
214
+ console.error(`[MCP] ${MCP_SERVER_NAME} v${VERSION} ready on stdio`);
215
+
216
+ // Block forever until shutdown signal or stdin closes
217
+ // This prevents the CLI from exiting after startMcpServer() returns
218
+ await new Promise<void>((resolve) => {
219
+ process.stdin.on('end', () => {
220
+ console.error('[MCP] stdin ended');
221
+ resolve();
222
+ });
223
+ process.stdin.on('close', () => {
224
+ console.error('[MCP] stdin closed');
225
+ resolve();
226
+ });
227
+ // Also resolve on SIGTERM/SIGINT (already handled by shutdown())
228
+ });
229
+ }