discoclaw 0.2.4 → 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.
- package/.context/pa.md +1 -1
- package/.context/runtime.md +48 -4
- package/.env.example +6 -0
- package/.env.example.full +7 -0
- package/README.md +5 -1
- package/dist/config.js +2 -0
- package/dist/cron/cron-sync-coordinator.js +4 -0
- package/dist/cron/cron-sync-coordinator.test.js +8 -0
- package/dist/cron/executor.js +36 -1
- package/dist/cron/executor.test.js +157 -0
- package/dist/cron/forum-sync.js +47 -0
- package/dist/cron/forum-sync.test.js +234 -0
- package/dist/cron/run-stats.js +10 -3
- package/dist/cron/run-stats.test.js +67 -3
- package/dist/discord/actions-config.js +41 -8
- package/dist/discord/actions-config.test.js +130 -8
- package/dist/discord/actions-crons.js +18 -0
- package/dist/discord/actions-crons.test.js +12 -0
- package/dist/discord/models-command.js +5 -0
- package/dist/index.js +28 -0
- package/dist/mcp-detect.js +74 -0
- package/dist/mcp-detect.test.js +160 -0
- package/dist/runtime/openai-compat.js +224 -90
- package/dist/runtime/openai-compat.test.js +409 -2
- package/dist/runtime/openai-tool-exec.js +433 -0
- package/dist/runtime/openai-tool-exec.test.js +267 -0
- package/dist/runtime/openai-tool-schemas.js +174 -0
- package/dist/runtime/openai-tool-schemas.test.js +74 -0
- package/dist/runtime/tools/fs-glob.js +102 -0
- package/dist/runtime/tools/fs-glob.test.js +67 -0
- package/dist/runtime/tools/fs-read-file.js +49 -0
- package/dist/runtime/tools/fs-read-file.test.js +51 -0
- package/dist/runtime/tools/fs-realpath.js +51 -0
- package/dist/runtime/tools/fs-realpath.test.js +72 -0
- package/dist/runtime/tools/fs-write-file.js +45 -0
- package/dist/runtime/tools/fs-write-file.test.js +56 -0
- package/dist/runtime/tools/image-download.js +138 -0
- package/dist/runtime/tools/image-download.test.js +106 -0
- package/dist/runtime/tools/path-security.js +72 -0
- package/dist/runtime/tools/types.js +4 -0
- package/dist/workspace-bootstrap.js +0 -1
- package/dist/workspace-bootstrap.test.js +0 -2
- package/package.json +1 -1
- package/templates/mcp.json +8 -0
- package/templates/workspace/TOOLS.md +70 -1
- package/templates/workspace/HEARTBEAT.md +0 -10
|
@@ -147,6 +147,11 @@ export async function executeCronAction(action, ctx, cronCtx) {
|
|
|
147
147
|
cadence,
|
|
148
148
|
purposeTags,
|
|
149
149
|
model,
|
|
150
|
+
schedule: action.schedule,
|
|
151
|
+
timezone,
|
|
152
|
+
channel: action.channel,
|
|
153
|
+
prompt: action.prompt,
|
|
154
|
+
authorId: cronCtx.client.user?.id,
|
|
150
155
|
});
|
|
151
156
|
// Create status message.
|
|
152
157
|
try {
|
|
@@ -170,6 +175,11 @@ export async function executeCronAction(action, ctx, cronCtx) {
|
|
|
170
175
|
}
|
|
171
176
|
const updates = {};
|
|
172
177
|
const changes = [];
|
|
178
|
+
// Silent mode.
|
|
179
|
+
if (action.silent !== undefined) {
|
|
180
|
+
updates.silent = action.silent;
|
|
181
|
+
changes.push(`silent → ${action.silent}`);
|
|
182
|
+
}
|
|
173
183
|
// Model override.
|
|
174
184
|
if (action.model) {
|
|
175
185
|
updates.modelOverride = action.model;
|
|
@@ -231,6 +241,11 @@ export async function executeCronAction(action, ctx, cronCtx) {
|
|
|
231
241
|
const msg = err instanceof Error ? err.message : String(err);
|
|
232
242
|
return { ok: false, error: `Invalid cron definition: ${msg}` };
|
|
233
243
|
}
|
|
244
|
+
// Persist updated definition fields.
|
|
245
|
+
updates.schedule = newSchedule;
|
|
246
|
+
updates.timezone = newTimezone;
|
|
247
|
+
updates.channel = newChannel;
|
|
248
|
+
updates.prompt = newPrompt;
|
|
234
249
|
}
|
|
235
250
|
await cronCtx.statsStore.upsertRecord(action.cronId, record.threadId, updates);
|
|
236
251
|
// Update status message.
|
|
@@ -313,6 +328,8 @@ export async function executeCronAction(action, ctx, cronCtx) {
|
|
|
313
328
|
lines.push(`Runtime: \uD83D\uDD04 running`);
|
|
314
329
|
}
|
|
315
330
|
lines.push(`Model: ${record.modelOverride ?? record.model ?? 'N/A'}${record.modelOverride ? ' (override)' : ''}`);
|
|
331
|
+
if (record.silent)
|
|
332
|
+
lines.push(`Silent: yes`);
|
|
316
333
|
lines.push(`Cadence: ${record.cadence ?? 'N/A'}`);
|
|
317
334
|
lines.push(`Runs: ${record.runCount} | Last: ${record.lastRunStatus ?? 'never'}`);
|
|
318
335
|
if (record.lastRunAt)
|
|
@@ -562,6 +579,7 @@ export function cronActionsPromptSection() {
|
|
|
562
579
|
\`\`\`
|
|
563
580
|
- \`cronId\` (required): The stable cron ID.
|
|
564
581
|
- \`schedule\`, \`timezone\`, \`channel\`, \`prompt\`, \`model\`, \`tags\` (optional).
|
|
582
|
+
- \`silent\` (optional): Boolean. When true, suppresses short "nothing to report" responses.
|
|
565
583
|
|
|
566
584
|
**cronList** — List all cron jobs:
|
|
567
585
|
\`\`\`
|
|
@@ -325,6 +325,18 @@ describe('executeCronAction', () => {
|
|
|
325
325
|
expect(result.ok).toBe(true);
|
|
326
326
|
expect(cronCtx.statsStore.upsertRecord).toHaveBeenCalledWith('cron-test0001', 'thread-1', expect.objectContaining({ modelOverride: 'opus' }));
|
|
327
327
|
});
|
|
328
|
+
it('cronUpdate with silent sets silent flag', async () => {
|
|
329
|
+
const cronCtx = makeCronCtx();
|
|
330
|
+
const result = await executeCronAction({ type: 'cronUpdate', cronId: 'cron-test0001', silent: true }, makeActionCtx(), cronCtx);
|
|
331
|
+
expect(result.ok).toBe(true);
|
|
332
|
+
expect(cronCtx.statsStore.upsertRecord).toHaveBeenCalledWith('cron-test0001', 'thread-1', expect.objectContaining({ silent: true }));
|
|
333
|
+
});
|
|
334
|
+
it('cronUpdate with silent false clears silent flag', async () => {
|
|
335
|
+
const cronCtx = makeCronCtx();
|
|
336
|
+
const result = await executeCronAction({ type: 'cronUpdate', cronId: 'cron-test0001', silent: false }, makeActionCtx(), cronCtx);
|
|
337
|
+
expect(result.ok).toBe(true);
|
|
338
|
+
expect(cronCtx.statsStore.upsertRecord).toHaveBeenCalledWith('cron-test0001', 'thread-1', expect.objectContaining({ silent: false }));
|
|
339
|
+
});
|
|
328
340
|
it('cronUpdate rejects invalid schedule before thread edits or scheduler mutation', async () => {
|
|
329
341
|
const cronCtx = makeCronCtx();
|
|
330
342
|
const thread = cronCtx.client.channels.cache.get('thread-1');
|
|
@@ -41,8 +41,13 @@ export function handleModelsCommand(cmd, opts) {
|
|
|
41
41
|
'',
|
|
42
42
|
'**Roles:** `chat`, `fast`, `forge-drafter`, `forge-auditor`, `summary`, `cron`, `cron-exec`',
|
|
43
43
|
'',
|
|
44
|
+
'**Runtime switching (chat role only):**',
|
|
45
|
+
'Setting the `chat` role to a runtime name (`openrouter`, `openai`, `gemini`, `codex`, `claude`) switches the active runtime adapter so invocations route through that provider.',
|
|
46
|
+
'',
|
|
44
47
|
'**Examples:**',
|
|
45
48
|
'- `!models set chat sonnet`',
|
|
49
|
+
'- `!models set chat openrouter` — switch chat to the OpenRouter runtime',
|
|
50
|
+
'- `!models set chat gemini` — switch chat to the Gemini runtime',
|
|
46
51
|
'- `!models set fast haiku`',
|
|
47
52
|
'- `!models set forge-drafter opus`',
|
|
48
53
|
'- `!models set cron-exec haiku` — run crons on a cheaper model',
|
package/dist/index.js
CHANGED
|
@@ -28,6 +28,7 @@ import { initTasksForumGuard } from './tasks/forum-guard.js';
|
|
|
28
28
|
import { reloadTagMapInPlace } from './tasks/tag-map.js';
|
|
29
29
|
import { ensureWorkspaceBootstrapFiles } from './workspace-bootstrap.js';
|
|
30
30
|
import { probeWorkspacePermissions } from './workspace-permissions.js';
|
|
31
|
+
import { detectMcpServers } from './mcp-detect.js';
|
|
31
32
|
import { loadRunStats } from './cron/run-stats.js';
|
|
32
33
|
import { seedTagMap } from './cron/discord-sync.js';
|
|
33
34
|
import { loadCronTagMapStrict } from './cron/tag-map.js';
|
|
@@ -286,6 +287,27 @@ else if (permProbe.status === 'invalid') {
|
|
|
286
287
|
else {
|
|
287
288
|
log.info({ workspaceCwd, tier: permProbe.permissions.tier }, 'workspace permissions loaded');
|
|
288
289
|
}
|
|
290
|
+
// --- Detect MCP servers (startup health visibility) ---
|
|
291
|
+
const mcpResult = await detectMcpServers(workspaceCwd);
|
|
292
|
+
if (mcpResult.status === 'missing') {
|
|
293
|
+
log.debug({ workspaceCwd }, 'mcp: no .mcp.json found');
|
|
294
|
+
}
|
|
295
|
+
else if (mcpResult.status === 'invalid') {
|
|
296
|
+
log.warn({ workspaceCwd, reason: mcpResult.reason }, 'mcp: .mcp.json is invalid — MCP servers will not load');
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
const serverNames = mcpResult.servers.map((s) => s.name);
|
|
300
|
+
const claudeInUse = primaryRuntimeName === 'claude'
|
|
301
|
+
|| cfg.forgeDrafterRuntime === 'claude'
|
|
302
|
+
|| cfg.forgeAuditorRuntime === 'claude';
|
|
303
|
+
let msg = serverNames.length === 0
|
|
304
|
+
? 'mcp: .mcp.json found but no servers configured'
|
|
305
|
+
: `mcp: ${serverNames.length} server${serverNames.length === 1 ? '' : 's'} configured: ${serverNames.join(', ')}`;
|
|
306
|
+
if (serverNames.length > 0 && !claudeInUse) {
|
|
307
|
+
msg += ' (MCP servers only active with Claude runtime)';
|
|
308
|
+
}
|
|
309
|
+
log.info({ servers: serverNames, count: serverNames.length, strictMcpConfig: cfg.strictMcpConfig }, msg);
|
|
310
|
+
}
|
|
289
311
|
// --- Resolve bot display name ---
|
|
290
312
|
const botDisplayName = await resolveDisplayName({
|
|
291
313
|
configName: cfg.botDisplayName,
|
|
@@ -449,6 +471,7 @@ if (cfg.openaiApiKey) {
|
|
|
449
471
|
baseUrl: cfg.openaiBaseUrl ?? 'https://api.openai.com/v1',
|
|
450
472
|
apiKey: cfg.openaiApiKey,
|
|
451
473
|
defaultModel: cfg.openaiModel,
|
|
474
|
+
enableTools: cfg.openaiCompatToolsEnabled,
|
|
452
475
|
log,
|
|
453
476
|
});
|
|
454
477
|
const openaiRuntime = withConcurrencyLimit(openaiRuntimeRaw, {
|
|
@@ -464,6 +487,7 @@ if (cfg.openrouterApiKey) {
|
|
|
464
487
|
baseUrl: cfg.openrouterBaseUrl ?? 'https://openrouter.ai/api/v1',
|
|
465
488
|
apiKey: cfg.openrouterApiKey,
|
|
466
489
|
defaultModel: cfg.openrouterModel,
|
|
490
|
+
enableTools: cfg.openaiCompatToolsEnabled,
|
|
467
491
|
log,
|
|
468
492
|
});
|
|
469
493
|
const openrouterRuntime = withConcurrencyLimit(openrouterRuntimeRaw, {
|
|
@@ -619,6 +643,7 @@ const botParams = {
|
|
|
619
643
|
memoryCtx: undefined,
|
|
620
644
|
imagegenCtx: undefined,
|
|
621
645
|
configCtx: undefined,
|
|
646
|
+
deferOpts: undefined,
|
|
622
647
|
messageHistoryBudget,
|
|
623
648
|
summaryEnabled,
|
|
624
649
|
summaryModel,
|
|
@@ -733,6 +758,7 @@ if (discordActionsEnabled && cfg.discordActionsDefer) {
|
|
|
733
758
|
};
|
|
734
759
|
const deferScheduler = configureDeferredScheduler(deferOpts);
|
|
735
760
|
botParams.deferScheduler = deferScheduler;
|
|
761
|
+
botParams.deferOpts = deferOpts;
|
|
736
762
|
}
|
|
737
763
|
let client, status, system;
|
|
738
764
|
try {
|
|
@@ -920,6 +946,8 @@ if (taskCtx) {
|
|
|
920
946
|
botParams.configCtx = {
|
|
921
947
|
botParams,
|
|
922
948
|
runtime: limitedRuntime,
|
|
949
|
+
runtimeRegistry,
|
|
950
|
+
runtimeName: primaryRuntimeName,
|
|
923
951
|
};
|
|
924
952
|
log.info('config:action context initialized');
|
|
925
953
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Detect MCP servers configured in the workspace `.mcp.json` file.
|
|
5
|
+
* Returns structured info for startup health logging.
|
|
6
|
+
*/
|
|
7
|
+
export async function detectMcpServers(workspaceCwd) {
|
|
8
|
+
const mcpPath = path.join(workspaceCwd, '.mcp.json');
|
|
9
|
+
let raw;
|
|
10
|
+
try {
|
|
11
|
+
raw = await fs.readFile(mcpPath, 'utf-8');
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return { status: 'missing' };
|
|
15
|
+
}
|
|
16
|
+
let parsed;
|
|
17
|
+
try {
|
|
18
|
+
parsed = JSON.parse(raw);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return { status: 'invalid', reason: 'invalid JSON' };
|
|
22
|
+
}
|
|
23
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
24
|
+
return { status: 'invalid', reason: 'root must be an object' };
|
|
25
|
+
}
|
|
26
|
+
const obj = parsed;
|
|
27
|
+
const mcpServers = obj.mcpServers;
|
|
28
|
+
if (mcpServers === undefined) {
|
|
29
|
+
return { status: 'invalid', reason: 'missing "mcpServers" key' };
|
|
30
|
+
}
|
|
31
|
+
if (typeof mcpServers !== 'object' || mcpServers === null || Array.isArray(mcpServers)) {
|
|
32
|
+
return { status: 'invalid', reason: '"mcpServers" must be an object' };
|
|
33
|
+
}
|
|
34
|
+
const entries = Object.entries(mcpServers);
|
|
35
|
+
const servers = [];
|
|
36
|
+
for (const [name, value] of entries) {
|
|
37
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
38
|
+
return { status: 'invalid', reason: `server "${name}" must be an object` };
|
|
39
|
+
}
|
|
40
|
+
const entry = value;
|
|
41
|
+
if (typeof entry.command !== 'string' || !entry.command) {
|
|
42
|
+
return { status: 'invalid', reason: `server "${name}" missing "command"` };
|
|
43
|
+
}
|
|
44
|
+
const server = { name, command: entry.command };
|
|
45
|
+
if (Array.isArray(entry.args)) {
|
|
46
|
+
server.args = entry.args.filter((a) => typeof a === 'string');
|
|
47
|
+
}
|
|
48
|
+
servers.push(server);
|
|
49
|
+
}
|
|
50
|
+
return { status: 'found', servers };
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Log MCP detection results at startup.
|
|
54
|
+
*/
|
|
55
|
+
export function logMcpDetection(result, log) {
|
|
56
|
+
switch (result.status) {
|
|
57
|
+
case 'missing':
|
|
58
|
+
log.info({}, 'mcp: no .mcp.json found — MCP servers not configured');
|
|
59
|
+
break;
|
|
60
|
+
case 'invalid':
|
|
61
|
+
log.warn({ reason: result.reason }, 'mcp: .mcp.json is invalid — MCP servers will not load');
|
|
62
|
+
break;
|
|
63
|
+
case 'found': {
|
|
64
|
+
const names = result.servers.map((s) => s.name);
|
|
65
|
+
if (names.length === 0) {
|
|
66
|
+
log.info({}, 'mcp: .mcp.json found but no servers configured');
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
log.info({ count: names.length, servers: names }, `mcp: ${names.length} server${names.length === 1 ? '' : 's'} configured: ${names.join(', ')}`);
|
|
70
|
+
}
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { detectMcpServers, logMcpDetection } from './mcp-detect.js';
|
|
6
|
+
function mockLog() {
|
|
7
|
+
return { info: vi.fn(), warn: vi.fn() };
|
|
8
|
+
}
|
|
9
|
+
describe('detectMcpServers', () => {
|
|
10
|
+
const dirs = [];
|
|
11
|
+
afterEach(async () => {
|
|
12
|
+
for (const d of dirs)
|
|
13
|
+
await fs.rm(d, { recursive: true, force: true });
|
|
14
|
+
dirs.length = 0;
|
|
15
|
+
});
|
|
16
|
+
it('returns missing when .mcp.json does not exist', async () => {
|
|
17
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-detect-'));
|
|
18
|
+
dirs.push(workspace);
|
|
19
|
+
const result = await detectMcpServers(workspace);
|
|
20
|
+
expect(result).toEqual({ status: 'missing' });
|
|
21
|
+
});
|
|
22
|
+
it('returns invalid for malformed JSON', async () => {
|
|
23
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-detect-'));
|
|
24
|
+
dirs.push(workspace);
|
|
25
|
+
await fs.writeFile(path.join(workspace, '.mcp.json'), '{ broken', 'utf-8');
|
|
26
|
+
const result = await detectMcpServers(workspace);
|
|
27
|
+
expect(result).toEqual({ status: 'invalid', reason: 'invalid JSON' });
|
|
28
|
+
});
|
|
29
|
+
it('returns invalid when root is not an object', async () => {
|
|
30
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-detect-'));
|
|
31
|
+
dirs.push(workspace);
|
|
32
|
+
await fs.writeFile(path.join(workspace, '.mcp.json'), '"hello"', 'utf-8');
|
|
33
|
+
const result = await detectMcpServers(workspace);
|
|
34
|
+
expect(result).toEqual({ status: 'invalid', reason: 'root must be an object' });
|
|
35
|
+
});
|
|
36
|
+
it('returns invalid when root is an array', async () => {
|
|
37
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-detect-'));
|
|
38
|
+
dirs.push(workspace);
|
|
39
|
+
await fs.writeFile(path.join(workspace, '.mcp.json'), '[]', 'utf-8');
|
|
40
|
+
const result = await detectMcpServers(workspace);
|
|
41
|
+
expect(result).toEqual({ status: 'invalid', reason: 'root must be an object' });
|
|
42
|
+
});
|
|
43
|
+
it('returns invalid when mcpServers key is missing', async () => {
|
|
44
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-detect-'));
|
|
45
|
+
dirs.push(workspace);
|
|
46
|
+
await fs.writeFile(path.join(workspace, '.mcp.json'), '{}', 'utf-8');
|
|
47
|
+
const result = await detectMcpServers(workspace);
|
|
48
|
+
expect(result).toEqual({ status: 'invalid', reason: 'missing "mcpServers" key' });
|
|
49
|
+
});
|
|
50
|
+
it('returns invalid when mcpServers is not an object', async () => {
|
|
51
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-detect-'));
|
|
52
|
+
dirs.push(workspace);
|
|
53
|
+
await fs.writeFile(path.join(workspace, '.mcp.json'), JSON.stringify({ mcpServers: 42 }), 'utf-8');
|
|
54
|
+
const result = await detectMcpServers(workspace);
|
|
55
|
+
expect(result).toEqual({ status: 'invalid', reason: '"mcpServers" must be an object' });
|
|
56
|
+
});
|
|
57
|
+
it('returns found with empty servers when mcpServers is empty', async () => {
|
|
58
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-detect-'));
|
|
59
|
+
dirs.push(workspace);
|
|
60
|
+
await fs.writeFile(path.join(workspace, '.mcp.json'), JSON.stringify({ mcpServers: {} }), 'utf-8');
|
|
61
|
+
const result = await detectMcpServers(workspace);
|
|
62
|
+
expect(result).toEqual({ status: 'found', servers: [] });
|
|
63
|
+
});
|
|
64
|
+
it('returns found with server details for valid config', async () => {
|
|
65
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-detect-'));
|
|
66
|
+
dirs.push(workspace);
|
|
67
|
+
const config = {
|
|
68
|
+
mcpServers: {
|
|
69
|
+
filesystem: {
|
|
70
|
+
command: 'npx',
|
|
71
|
+
args: ['-y', '@modelcontextprotocol/server-filesystem', '/home/user/docs'],
|
|
72
|
+
},
|
|
73
|
+
'brave-search': {
|
|
74
|
+
command: 'npx',
|
|
75
|
+
args: ['-y', '@anthropic/mcp-server-brave-search'],
|
|
76
|
+
env: { BRAVE_API_KEY: 'key-here' },
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
await fs.writeFile(path.join(workspace, '.mcp.json'), JSON.stringify(config), 'utf-8');
|
|
81
|
+
const result = await detectMcpServers(workspace);
|
|
82
|
+
expect(result).toEqual({
|
|
83
|
+
status: 'found',
|
|
84
|
+
servers: [
|
|
85
|
+
{ name: 'filesystem', command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem', '/home/user/docs'] },
|
|
86
|
+
{ name: 'brave-search', command: 'npx', args: ['-y', '@anthropic/mcp-server-brave-search'] },
|
|
87
|
+
],
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
it('returns invalid when a server entry is missing command', async () => {
|
|
91
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-detect-'));
|
|
92
|
+
dirs.push(workspace);
|
|
93
|
+
const config = {
|
|
94
|
+
mcpServers: {
|
|
95
|
+
broken: { args: ['--flag'] },
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
await fs.writeFile(path.join(workspace, '.mcp.json'), JSON.stringify(config), 'utf-8');
|
|
99
|
+
const result = await detectMcpServers(workspace);
|
|
100
|
+
expect(result).toEqual({ status: 'invalid', reason: 'server "broken" missing "command"' });
|
|
101
|
+
});
|
|
102
|
+
it('returns invalid when a server entry is not an object', async () => {
|
|
103
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-detect-'));
|
|
104
|
+
dirs.push(workspace);
|
|
105
|
+
const config = { mcpServers: { bad: 'string' } };
|
|
106
|
+
await fs.writeFile(path.join(workspace, '.mcp.json'), JSON.stringify(config), 'utf-8');
|
|
107
|
+
const result = await detectMcpServers(workspace);
|
|
108
|
+
expect(result).toEqual({ status: 'invalid', reason: 'server "bad" must be an object' });
|
|
109
|
+
});
|
|
110
|
+
it('handles server with command only (no args)', async () => {
|
|
111
|
+
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-detect-'));
|
|
112
|
+
dirs.push(workspace);
|
|
113
|
+
const config = {
|
|
114
|
+
mcpServers: {
|
|
115
|
+
simple: { command: '/usr/local/bin/mcp-server' },
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
await fs.writeFile(path.join(workspace, '.mcp.json'), JSON.stringify(config), 'utf-8');
|
|
119
|
+
const result = await detectMcpServers(workspace);
|
|
120
|
+
expect(result).toEqual({
|
|
121
|
+
status: 'found',
|
|
122
|
+
servers: [{ name: 'simple', command: '/usr/local/bin/mcp-server' }],
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
describe('logMcpDetection', () => {
|
|
127
|
+
it('logs info for missing .mcp.json', () => {
|
|
128
|
+
const log = mockLog();
|
|
129
|
+
logMcpDetection({ status: 'missing' }, log);
|
|
130
|
+
expect(log.info).toHaveBeenCalledWith({}, expect.stringContaining('no .mcp.json found'));
|
|
131
|
+
expect(log.warn).not.toHaveBeenCalled();
|
|
132
|
+
});
|
|
133
|
+
it('logs warn for invalid .mcp.json', () => {
|
|
134
|
+
const log = mockLog();
|
|
135
|
+
logMcpDetection({ status: 'invalid', reason: 'invalid JSON' }, log);
|
|
136
|
+
expect(log.warn).toHaveBeenCalledWith({ reason: 'invalid JSON' }, expect.stringContaining('invalid'));
|
|
137
|
+
expect(log.info).not.toHaveBeenCalled();
|
|
138
|
+
});
|
|
139
|
+
it('logs info for empty servers', () => {
|
|
140
|
+
const log = mockLog();
|
|
141
|
+
logMcpDetection({ status: 'found', servers: [] }, log);
|
|
142
|
+
expect(log.info).toHaveBeenCalledWith({}, expect.stringContaining('no servers configured'));
|
|
143
|
+
});
|
|
144
|
+
it('logs info with server names for configured servers', () => {
|
|
145
|
+
const log = mockLog();
|
|
146
|
+
logMcpDetection({
|
|
147
|
+
status: 'found',
|
|
148
|
+
servers: [
|
|
149
|
+
{ name: 'filesystem', command: 'npx' },
|
|
150
|
+
{ name: 'brave-search', command: 'npx' },
|
|
151
|
+
],
|
|
152
|
+
}, log);
|
|
153
|
+
expect(log.info).toHaveBeenCalledWith({ count: 2, servers: ['filesystem', 'brave-search'] }, expect.stringContaining('2 servers configured: filesystem, brave-search'));
|
|
154
|
+
});
|
|
155
|
+
it('uses singular "server" for single server', () => {
|
|
156
|
+
const log = mockLog();
|
|
157
|
+
logMcpDetection({ status: 'found', servers: [{ name: 'filesystem', command: 'npx' }] }, log);
|
|
158
|
+
expect(log.info).toHaveBeenCalledWith({ count: 1, servers: ['filesystem'] }, expect.stringContaining('1 server configured'));
|
|
159
|
+
});
|
|
160
|
+
});
|