bluera-knowledge 0.11.12 → 0.11.13

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bluera-knowledge",
3
- "version": "0.11.12",
3
+ "version": "0.11.13",
4
4
  "description": "Clone repos, crawl docs, search locally. Fast, authoritative answers for AI coding agents.",
5
5
  "mcpServers": {
6
6
  "bluera-knowledge": {
package/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines.
4
4
 
5
+ ## [0.11.13](https://github.com/blueraai/bluera-knowledge/compare/v0.11.6...v0.11.13) (2026-01-10)
6
+
7
+
8
+ ### Features
9
+
10
+ * **scripts:** add post-release npm validation script ([e4c29a0](https://github.com/blueraai/bluera-knowledge/commit/e4c29a0c83907de4bc293a69a58412629457fb22))
11
+ * **scripts:** add suggest, sync, serve, mcp tests to npm validation ([49d85da](https://github.com/blueraai/bluera-knowledge/commit/49d85dad1a89691060c12f152d644844baf6e6e6))
12
+ * **scripts:** log expected vs installed version in validation script ([c77d039](https://github.com/blueraai/bluera-knowledge/commit/c77d039b27a3ccf54d50006af161ac4dcfea7b21))
13
+
14
+
15
+ ### Bug Fixes
16
+
17
+ * **cli:** plugin-api commands now respect global options ([d3cca02](https://github.com/blueraai/bluera-knowledge/commit/d3cca02ffc679ffc187b76c7682f3cc177eabdea))
18
+ * **plugin:** properly close services after command execution ([eeaf743](https://github.com/blueraai/bluera-knowledge/commit/eeaf743750be73fd9c7a9e72440b2fd0fb5a53fa))
19
+ * **scripts:** show real-time output in validation script ([8a4bdec](https://github.com/blueraai/bluera-knowledge/commit/8a4bdec8b63c504d34ba35bfe19da795f7f7fd07))
20
+ * **scripts:** use mktemp for temp directories in validation script ([3107861](https://github.com/blueraai/bluera-knowledge/commit/3107861bd7a966016fde2a121469dd84756f39be))
21
+ * **search:** add defaults for env vars so CLI works standalone ([b2d2ce5](https://github.com/blueraai/bluera-knowledge/commit/b2d2ce534e8cd2ba0fc0abdac505c912a1a76035))
22
+
5
23
  ## [0.11.12](https://github.com/blueraai/bluera-knowledge/compare/v0.11.6...v0.11.12) (2026-01-10)
6
24
 
7
25
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bluera-knowledge",
3
- "version": "0.11.12",
3
+ "version": "0.11.13",
4
4
  "description": "CLI tool for managing knowledge stores with semantic search",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,250 @@
1
+ import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
2
+ import { spawn } from 'node:child_process';
3
+ import { rm, mkdtemp, writeFile, mkdir } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import * as readline from 'node:readline';
7
+
8
+ /**
9
+ * MCP Server Integration Tests
10
+ *
11
+ * Tests that the MCP server actually starts and accepts JSON-RPC messages.
12
+ * Unlike unit tests that mock runMCPServer, these tests spawn a real
13
+ * MCP server process and communicate via stdin/stdout.
14
+ */
15
+ describe('MCP Integration', () => {
16
+ let tempDir: string;
17
+ let testFilesDir: string;
18
+
19
+ beforeAll(async () => {
20
+ tempDir = await mkdtemp(join(tmpdir(), 'mcp-test-'));
21
+ testFilesDir = join(tempDir, 'files');
22
+ await mkdir(testFilesDir, { recursive: true });
23
+ await writeFile(
24
+ join(testFilesDir, 'test.md'),
25
+ '# Test Document\n\nContent for MCP integration testing.'
26
+ );
27
+ }, 30000);
28
+
29
+ afterAll(async () => {
30
+ await rm(tempDir, { recursive: true, force: true });
31
+ });
32
+
33
+ interface JSONRPCResponse {
34
+ jsonrpc: string;
35
+ id: number;
36
+ result?: unknown;
37
+ error?: { code: number; message: string };
38
+ }
39
+
40
+ interface MCPClient {
41
+ proc: ChildProcess;
42
+ sendRequest: (method: string, params?: Record<string, unknown>) => Promise<JSONRPCResponse>;
43
+ close: () => void;
44
+ }
45
+
46
+ /**
47
+ * Start the MCP server and return a client with message helpers
48
+ */
49
+ function startMCPServer(): MCPClient {
50
+ const proc = spawn('node', ['dist/mcp/server.js'], {
51
+ stdio: ['pipe', 'pipe', 'pipe'],
52
+ env: {
53
+ ...process.env,
54
+ PROJECT_ROOT: tempDir,
55
+ DATA_DIR: join(tempDir, 'data'),
56
+ CONFIG_PATH: join(tempDir, 'config.json'),
57
+ },
58
+ });
59
+
60
+ // Set up a single readline interface for the entire process lifetime
61
+ const rl = readline.createInterface({ input: proc.stdout! });
62
+ const pendingRequests = new Map<
63
+ number,
64
+ { resolve: (r: JSONRPCResponse) => void; reject: (e: Error) => void }
65
+ >();
66
+ let requestId = 0;
67
+
68
+ rl.on('line', (line: string) => {
69
+ try {
70
+ const response = JSON.parse(line) as JSONRPCResponse;
71
+ const pending = pendingRequests.get(response.id);
72
+ if (pending !== undefined) {
73
+ pendingRequests.delete(response.id);
74
+ pending.resolve(response);
75
+ }
76
+ } catch {
77
+ // Ignore non-JSON lines (like log output)
78
+ }
79
+ });
80
+
81
+ return {
82
+ proc,
83
+ sendRequest: (
84
+ method: string,
85
+ params: Record<string, unknown> = {}
86
+ ): Promise<JSONRPCResponse> => {
87
+ return new Promise((resolve, reject) => {
88
+ if (proc.stdin === null) {
89
+ reject(new Error('Process stdin not available'));
90
+ return;
91
+ }
92
+
93
+ const id = ++requestId;
94
+ const timeout = setTimeout(() => {
95
+ pendingRequests.delete(id);
96
+ reject(new Error(`Timeout waiting for response to ${method}`));
97
+ }, 30000);
98
+
99
+ pendingRequests.set(id, {
100
+ resolve: (r) => {
101
+ clearTimeout(timeout);
102
+ resolve(r);
103
+ },
104
+ reject: (e) => {
105
+ clearTimeout(timeout);
106
+ reject(e);
107
+ },
108
+ });
109
+
110
+ const request = JSON.stringify({
111
+ jsonrpc: '2.0',
112
+ id,
113
+ method,
114
+ params,
115
+ });
116
+
117
+ proc.stdin.write(request + '\n');
118
+ });
119
+ },
120
+ close: (): void => {
121
+ rl.close();
122
+ proc.kill('SIGTERM');
123
+ },
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Wait for process to be ready
129
+ */
130
+ async function waitForReady(client: MCPClient, timeoutMs = 10000): Promise<void> {
131
+ return new Promise((resolve, reject) => {
132
+ const timeout = setTimeout(() => {
133
+ reject(new Error('Timeout waiting for MCP server to start'));
134
+ }, timeoutMs);
135
+
136
+ // MCP servers typically become ready quickly
137
+ // Give it a moment to initialize
138
+ setTimeout(() => {
139
+ clearTimeout(timeout);
140
+ resolve();
141
+ }, 1000);
142
+
143
+ client.proc.on('error', (err) => {
144
+ clearTimeout(timeout);
145
+ reject(err);
146
+ });
147
+
148
+ client.proc.on('exit', (code) => {
149
+ clearTimeout(timeout);
150
+ if (code !== 0 && code !== null) {
151
+ reject(new Error(`MCP server exited with code ${code}`));
152
+ }
153
+ });
154
+ });
155
+ }
156
+
157
+ let mcpClient: MCPClient | null = null;
158
+
159
+ afterEach(async () => {
160
+ // Clean up MCP client if still running
161
+ if (mcpClient !== null) {
162
+ mcpClient.close();
163
+ await new Promise<void>((resolve) => {
164
+ if (mcpClient !== null) {
165
+ mcpClient.proc.on('exit', () => resolve());
166
+ setTimeout(() => {
167
+ if (mcpClient !== null) {
168
+ mcpClient.proc.kill('SIGKILL');
169
+ }
170
+ resolve();
171
+ }, 2000);
172
+ } else {
173
+ resolve();
174
+ }
175
+ });
176
+ mcpClient = null;
177
+ }
178
+ });
179
+
180
+ it('starts, lists tools, and handles tool execution', async () => {
181
+ mcpClient = startMCPServer();
182
+ await waitForReady(mcpClient);
183
+
184
+ // 1. Initialize
185
+ const initResponse = await mcpClient.sendRequest('initialize', {
186
+ protocolVersion: '2024-11-05',
187
+ capabilities: {},
188
+ clientInfo: { name: 'test-client', version: '1.0.0' },
189
+ });
190
+
191
+ expect(initResponse.jsonrpc).toBe('2.0');
192
+ expect(initResponse.error).toBeUndefined();
193
+ expect(initResponse.result).toBeDefined();
194
+
195
+ const initResult = initResponse.result as {
196
+ protocolVersion: string;
197
+ serverInfo: { name: string; version: string };
198
+ capabilities: { tools: Record<string, unknown> };
199
+ };
200
+ expect(initResult.serverInfo.name).toBe('bluera-knowledge');
201
+ expect(initResult.capabilities.tools).toBeDefined();
202
+
203
+ // 2. List tools
204
+ const toolsResponse = await mcpClient.sendRequest('tools/list', {});
205
+
206
+ expect(toolsResponse.error).toBeUndefined();
207
+ expect(toolsResponse.result).toBeDefined();
208
+
209
+ const toolsResult = toolsResponse.result as {
210
+ tools: Array<{ name: string; description: string }>;
211
+ };
212
+ expect(Array.isArray(toolsResult.tools)).toBe(true);
213
+
214
+ const toolNames = toolsResult.tools.map((t) => t.name);
215
+ expect(toolNames).toContain('search');
216
+ expect(toolNames).toContain('get_full_context');
217
+ expect(toolNames).toContain('execute');
218
+
219
+ // 3. Call search tool
220
+ const searchResponse = await mcpClient.sendRequest('tools/call', {
221
+ name: 'search',
222
+ arguments: {
223
+ query: 'test query',
224
+ limit: 5,
225
+ },
226
+ });
227
+
228
+ expect(searchResponse.error).toBeUndefined();
229
+ expect(searchResponse.result).toBeDefined();
230
+
231
+ const searchResult = searchResponse.result as {
232
+ content: Array<{ type: string; text: string }>;
233
+ };
234
+ expect(Array.isArray(searchResult.content)).toBe(true);
235
+
236
+ // 4. Call execute with help command
237
+ const helpResponse = await mcpClient.sendRequest('tools/call', {
238
+ name: 'execute',
239
+ arguments: {
240
+ command: 'help',
241
+ },
242
+ });
243
+
244
+ expect(helpResponse.error).toBeUndefined();
245
+ expect(helpResponse.result).toBeDefined();
246
+
247
+ const helpResult = helpResponse.result as { content: Array<{ type: string; text: string }> };
248
+ expect(helpResult.content[0]?.text).toContain('Available commands');
249
+ }, 120000);
250
+ });
@@ -0,0 +1,260 @@
1
+ import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
2
+ import { spawn, type ChildProcess } from 'node:child_process';
3
+ import { rm, mkdtemp, writeFile, mkdir } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ /**
8
+ * Serve Command Integration Tests
9
+ *
10
+ * Tests that the HTTP server actually starts, responds to requests,
11
+ * and shuts down cleanly. Unlike unit tests that mock the serve function,
12
+ * these tests spawn a real server process and make actual HTTP requests.
13
+ */
14
+ describe('Serve Integration', () => {
15
+ let tempDir: string;
16
+ let testFilesDir: string;
17
+ let serverProcess: ChildProcess | null = null;
18
+ const TEST_PORT = 19877; // Use a unique port to avoid conflicts
19
+
20
+ beforeAll(async () => {
21
+ tempDir = await mkdtemp(join(tmpdir(), 'serve-test-'));
22
+ testFilesDir = join(tempDir, 'files');
23
+ await mkdir(testFilesDir, { recursive: true });
24
+ await writeFile(
25
+ join(testFilesDir, 'test.md'),
26
+ '# Test Document\n\nContent for serve integration testing.'
27
+ );
28
+ }, 30000);
29
+
30
+ afterAll(async () => {
31
+ await rm(tempDir, { recursive: true, force: true });
32
+ });
33
+
34
+ afterEach(async () => {
35
+ // Clean up server process if still running
36
+ if (serverProcess !== null) {
37
+ serverProcess.kill('SIGTERM');
38
+ await new Promise<void>((resolve) => {
39
+ if (serverProcess !== null) {
40
+ serverProcess.on('exit', () => resolve());
41
+ // Force kill after timeout
42
+ setTimeout(() => {
43
+ if (serverProcess !== null) {
44
+ serverProcess.kill('SIGKILL');
45
+ }
46
+ resolve();
47
+ }, 2000);
48
+ } else {
49
+ resolve();
50
+ }
51
+ });
52
+ serverProcess = null;
53
+ }
54
+ });
55
+
56
+ /**
57
+ * Start the serve command and wait for it to be ready
58
+ */
59
+ async function startServer(port: number): Promise<ChildProcess> {
60
+ return new Promise((resolve, reject) => {
61
+ const proc = spawn(
62
+ 'node',
63
+ ['dist/index.js', 'serve', '--port', String(port), '--data-dir', tempDir],
64
+ {
65
+ stdio: ['pipe', 'pipe', 'pipe'],
66
+ }
67
+ );
68
+
69
+ let resolved = false;
70
+ const timeout = setTimeout(() => {
71
+ if (!resolved) {
72
+ reject(new Error('Server startup timeout'));
73
+ proc.kill();
74
+ }
75
+ }, 30000);
76
+
77
+ proc.stdout?.on('data', (data: Buffer) => {
78
+ const output = data.toString();
79
+ if (output.includes('Starting server')) {
80
+ resolved = true;
81
+ clearTimeout(timeout);
82
+ // Give it a moment to actually start listening
83
+ setTimeout(() => resolve(proc), 500);
84
+ }
85
+ });
86
+
87
+ proc.stderr?.on('data', (data: Buffer) => {
88
+ // Log stderr but don't fail - some warnings are expected
89
+ console.error('Server stderr:', data.toString());
90
+ });
91
+
92
+ proc.on('error', (err) => {
93
+ if (!resolved) {
94
+ clearTimeout(timeout);
95
+ reject(err);
96
+ }
97
+ });
98
+
99
+ proc.on('exit', (code) => {
100
+ if (!resolved) {
101
+ clearTimeout(timeout);
102
+ reject(new Error(`Server exited with code ${String(code)}`));
103
+ }
104
+ });
105
+ });
106
+ }
107
+
108
+ /**
109
+ * Make an HTTP request with timeout
110
+ */
111
+ async function fetchWithTimeout(
112
+ url: string,
113
+ options: RequestInit = {},
114
+ timeoutMs = 5000
115
+ ): Promise<Response> {
116
+ const controller = new AbortController();
117
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
118
+ try {
119
+ return await fetch(url, { ...options, signal: controller.signal });
120
+ } finally {
121
+ clearTimeout(timeoutId);
122
+ }
123
+ }
124
+
125
+ describe('Server Startup and Health', () => {
126
+ it('starts server and responds to /health', async () => {
127
+ serverProcess = await startServer(TEST_PORT);
128
+
129
+ const response = await fetchWithTimeout(`http://127.0.0.1:${TEST_PORT}/health`);
130
+ expect(response.ok).toBe(true);
131
+
132
+ const body = (await response.json()) as { status: string };
133
+ expect(body.status).toBe('ok');
134
+ }, 60000);
135
+
136
+ it('responds with CORS headers', async () => {
137
+ serverProcess = await startServer(TEST_PORT + 1);
138
+
139
+ const response = await fetchWithTimeout(`http://127.0.0.1:${TEST_PORT + 1}/health`, {
140
+ method: 'OPTIONS',
141
+ headers: {
142
+ Origin: 'http://localhost:3000',
143
+ 'Access-Control-Request-Method': 'GET',
144
+ },
145
+ });
146
+
147
+ // CORS preflight should succeed
148
+ expect(response.ok).toBe(true);
149
+ }, 60000);
150
+ });
151
+
152
+ describe('Store API', () => {
153
+ beforeEach(async () => {
154
+ serverProcess = await startServer(TEST_PORT + 2);
155
+ });
156
+
157
+ it('lists stores via GET /api/stores', async () => {
158
+ const response = await fetchWithTimeout(`http://127.0.0.1:${TEST_PORT + 2}/api/stores`);
159
+ expect(response.ok).toBe(true);
160
+
161
+ const body = (await response.json()) as unknown[];
162
+ expect(Array.isArray(body)).toBe(true);
163
+ }, 60000);
164
+
165
+ it('creates store via POST /api/stores', async () => {
166
+ const response = await fetchWithTimeout(`http://127.0.0.1:${TEST_PORT + 2}/api/stores`, {
167
+ method: 'POST',
168
+ headers: { 'Content-Type': 'application/json' },
169
+ body: JSON.stringify({
170
+ name: 'serve-test-store',
171
+ type: 'file',
172
+ path: testFilesDir,
173
+ }),
174
+ });
175
+
176
+ expect(response.status).toBe(201);
177
+ const body = (await response.json()) as { id: string; name: string };
178
+ expect(body.name).toBe('serve-test-store');
179
+ expect(body.id).toBeDefined();
180
+ }, 60000);
181
+
182
+ it('returns 400 for invalid store creation', async () => {
183
+ const response = await fetchWithTimeout(`http://127.0.0.1:${TEST_PORT + 2}/api/stores`, {
184
+ method: 'POST',
185
+ headers: { 'Content-Type': 'application/json' },
186
+ body: JSON.stringify({
187
+ name: 'invalid-store',
188
+ type: 'file',
189
+ // Missing required 'path' for file type
190
+ }),
191
+ });
192
+
193
+ expect(response.status).toBe(400);
194
+ }, 60000);
195
+ });
196
+
197
+ describe('Search API', () => {
198
+ it('handles search request via POST /api/search', async () => {
199
+ serverProcess = await startServer(TEST_PORT + 3);
200
+
201
+ // Search with no stores indexed - should return empty results
202
+ const response = await fetchWithTimeout(`http://127.0.0.1:${TEST_PORT + 3}/api/search`, {
203
+ method: 'POST',
204
+ headers: { 'Content-Type': 'application/json' },
205
+ body: JSON.stringify({
206
+ query: 'test query',
207
+ limit: 5,
208
+ }),
209
+ });
210
+
211
+ expect(response.ok).toBe(true);
212
+ const body = (await response.json()) as { results: unknown[] };
213
+ expect(body.results).toBeDefined();
214
+ expect(Array.isArray(body.results)).toBe(true);
215
+ }, 60000);
216
+
217
+ it('returns 400 for invalid search request', async () => {
218
+ serverProcess = await startServer(TEST_PORT + 4);
219
+
220
+ const response = await fetchWithTimeout(`http://127.0.0.1:${TEST_PORT + 4}/api/search`, {
221
+ method: 'POST',
222
+ headers: { 'Content-Type': 'application/json' },
223
+ body: JSON.stringify({
224
+ // Missing required 'query'
225
+ }),
226
+ });
227
+
228
+ expect(response.status).toBe(400);
229
+ }, 60000);
230
+ });
231
+
232
+ describe('Graceful Shutdown', () => {
233
+ it('shuts down cleanly on SIGTERM', async () => {
234
+ serverProcess = await startServer(TEST_PORT + 5);
235
+
236
+ // Verify server is running
237
+ const response = await fetchWithTimeout(`http://127.0.0.1:${TEST_PORT + 5}/health`);
238
+ expect(response.ok).toBe(true);
239
+
240
+ // Send SIGTERM
241
+ serverProcess.kill('SIGTERM');
242
+
243
+ // Wait for process to exit
244
+ const exitCode = await new Promise<number | null>((resolve) => {
245
+ if (serverProcess !== null) {
246
+ serverProcess.on('exit', (code) => resolve(code));
247
+ // Allow more time for graceful shutdown
248
+ setTimeout(() => resolve(null), 10000);
249
+ } else {
250
+ resolve(null);
251
+ }
252
+ });
253
+
254
+ // Process should exit cleanly (code 0) or be killed (null)
255
+ // On some systems the process may not exit with 0 due to async cleanup
256
+ expect(exitCode === 0 || exitCode === null).toBe(true);
257
+ serverProcess = null; // Already exited
258
+ }, 60000);
259
+ });
260
+ });