@indemn/cli 1.6.0 → 1.7.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/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +9 -4
- package/.github/workflows/ci.yml +10 -0
- package/.github/workflows/publish.yml +2 -2
- package/.mcp.json +1 -4
- package/CONTRIBUTING.md +3 -1
- package/README.md +69 -176
- package/dist/cli/agents.d.ts.map +1 -1
- package/dist/cli/agents.js +226 -18
- package/dist/cli/agents.js.map +1 -1
- package/dist/cli/analytics.d.ts +3 -0
- package/dist/cli/analytics.d.ts.map +1 -0
- package/dist/cli/analytics.js +133 -0
- package/dist/cli/analytics.js.map +1 -0
- package/dist/cli/auth.d.ts.map +1 -1
- package/dist/cli/auth.js +62 -17
- package/dist/cli/auth.js.map +1 -1
- package/dist/cli/billing.d.ts +3 -0
- package/dist/cli/billing.d.ts.map +1 -0
- package/dist/cli/billing.js +23 -0
- package/dist/cli/billing.js.map +1 -0
- package/dist/cli/campaigns.d.ts +3 -0
- package/dist/cli/campaigns.d.ts.map +1 -0
- package/dist/cli/campaigns.js +34 -0
- package/dist/cli/campaigns.js.map +1 -0
- package/dist/cli/chat.d.ts.map +1 -1
- package/dist/cli/chat.js +69 -102
- package/dist/cli/chat.js.map +1 -1
- package/dist/cli/config.d.ts.map +1 -1
- package/dist/cli/config.js +71 -27
- package/dist/cli/config.js.map +1 -1
- package/dist/cli/conversations.d.ts +3 -0
- package/dist/cli/conversations.d.ts.map +1 -0
- package/dist/cli/conversations.js +129 -0
- package/dist/cli/conversations.js.map +1 -0
- package/dist/cli/distribution.d.ts +3 -0
- package/dist/cli/distribution.d.ts.map +1 -0
- package/dist/cli/distribution.js +122 -0
- package/dist/cli/distribution.js.map +1 -0
- package/dist/cli/eval.d.ts.map +1 -1
- package/dist/cli/eval.js +315 -71
- package/dist/cli/eval.js.map +1 -1
- package/dist/cli/functions.d.ts.map +1 -1
- package/dist/cli/functions.js +109 -38
- package/dist/cli/functions.js.map +1 -1
- package/dist/cli/index.js +63 -54
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/init.d.ts +27 -0
- package/dist/cli/init.d.ts.map +1 -0
- package/dist/cli/init.js +152 -0
- package/dist/cli/init.js.map +1 -0
- package/dist/cli/kb.d.ts.map +1 -1
- package/dist/cli/kb.js +141 -15
- package/dist/cli/kb.js.map +1 -1
- package/dist/cli/link.d.ts +21 -0
- package/dist/cli/link.d.ts.map +1 -0
- package/dist/cli/link.js +112 -0
- package/dist/cli/link.js.map +1 -0
- package/dist/cli/org.d.ts.map +1 -1
- package/dist/cli/org.js +79 -67
- package/dist/cli/org.js.map +1 -1
- package/dist/cli/platform.d.ts +3 -0
- package/dist/cli/platform.d.ts.map +1 -0
- package/dist/cli/platform.js +133 -0
- package/dist/cli/platform.js.map +1 -0
- package/dist/cli/tickets.d.ts +3 -0
- package/dist/cli/tickets.d.ts.map +1 -0
- package/dist/cli/tickets.js +125 -0
- package/dist/cli/tickets.js.map +1 -0
- package/dist/cli/utils/output.d.ts +15 -9
- package/dist/cli/utils/output.d.ts.map +1 -1
- package/dist/cli/utils/output.js +53 -46
- package/dist/cli/utils/output.js.map +1 -1
- package/dist/cli/voice.d.ts +3 -0
- package/dist/cli/voice.d.ts.map +1 -0
- package/dist/cli/voice.js +206 -0
- package/dist/cli/voice.js.map +1 -0
- package/dist/core/diff.d.ts +16 -0
- package/dist/core/diff.d.ts.map +1 -0
- package/dist/core/diff.js +57 -0
- package/dist/core/diff.js.map +1 -0
- package/dist/core/org-resolver.d.ts +37 -0
- package/dist/core/org-resolver.d.ts.map +1 -0
- package/dist/core/org-resolver.js +73 -0
- package/dist/core/org-resolver.js.map +1 -0
- package/dist/core/runtime.d.ts +36 -0
- package/dist/core/runtime.d.ts.map +1 -0
- package/dist/core/runtime.js +62 -0
- package/dist/core/runtime.js.map +1 -0
- package/dist/core/scope.d.ts +33 -0
- package/dist/core/scope.d.ts.map +1 -0
- package/dist/core/scope.js +37 -0
- package/dist/core/scope.js.map +1 -0
- package/dist/mcp/registry.d.ts +67 -0
- package/dist/mcp/registry.d.ts.map +1 -0
- package/dist/mcp/registry.js +195 -0
- package/dist/mcp/registry.js.map +1 -0
- package/dist/mcp/server.d.ts +9 -0
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +87 -2
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tool-meta.d.ts +21 -0
- package/dist/mcp/tool-meta.d.ts.map +1 -0
- package/dist/mcp/tool-meta.js +203 -0
- package/dist/mcp/tool-meta.js.map +1 -0
- package/dist/mcp/tools.d.ts +2 -1
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +1462 -215
- package/dist/mcp/tools.js.map +1 -1
- package/dist/sdk/agent-bundle.d.ts +99 -0
- package/dist/sdk/agent-bundle.d.ts.map +1 -0
- package/dist/sdk/agent-bundle.js +211 -0
- package/dist/sdk/agent-bundle.js.map +1 -0
- package/dist/sdk/agents.d.ts +54 -1
- package/dist/sdk/agents.d.ts.map +1 -1
- package/dist/sdk/agents.js +59 -5
- package/dist/sdk/agents.js.map +1 -1
- package/dist/sdk/analytics.d.ts +158 -0
- package/dist/sdk/analytics.d.ts.map +1 -0
- package/dist/sdk/analytics.js +185 -0
- package/dist/sdk/analytics.js.map +1 -0
- package/dist/sdk/auth.d.ts +34 -12
- package/dist/sdk/auth.d.ts.map +1 -1
- package/dist/sdk/auth.js +60 -77
- package/dist/sdk/auth.js.map +1 -1
- package/dist/sdk/billing.d.ts +25 -0
- package/dist/sdk/billing.d.ts.map +1 -0
- package/dist/sdk/billing.js +61 -0
- package/dist/sdk/billing.js.map +1 -0
- package/dist/sdk/campaigns.d.ts +35 -0
- package/dist/sdk/campaigns.d.ts.map +1 -0
- package/dist/sdk/campaigns.js +59 -0
- package/dist/sdk/campaigns.js.map +1 -0
- package/dist/sdk/chat.d.ts +25 -2
- package/dist/sdk/chat.d.ts.map +1 -1
- package/dist/sdk/chat.js +37 -10
- package/dist/sdk/chat.js.map +1 -1
- package/dist/sdk/client.d.ts +93 -5
- package/dist/sdk/client.d.ts.map +1 -1
- package/dist/sdk/client.js +362 -100
- package/dist/sdk/client.js.map +1 -1
- package/dist/sdk/config-change.d.ts +74 -0
- package/dist/sdk/config-change.d.ts.map +1 -0
- package/dist/sdk/config-change.js +117 -0
- package/dist/sdk/config-change.js.map +1 -0
- package/dist/sdk/config.d.ts.map +1 -1
- package/dist/sdk/config.js +2 -1
- package/dist/sdk/config.js.map +1 -1
- package/dist/sdk/conversations.d.ts +101 -0
- package/dist/sdk/conversations.d.ts.map +1 -0
- package/dist/sdk/conversations.js +155 -0
- package/dist/sdk/conversations.js.map +1 -0
- package/dist/sdk/credentials.d.ts +25 -0
- package/dist/sdk/credentials.d.ts.map +1 -0
- package/dist/sdk/credentials.js +44 -0
- package/dist/sdk/credentials.js.map +1 -0
- package/dist/sdk/distribution.d.ts +94 -0
- package/dist/sdk/distribution.d.ts.map +1 -0
- package/dist/sdk/distribution.js +100 -0
- package/dist/sdk/distribution.js.map +1 -0
- package/dist/sdk/eval.d.ts +81 -0
- package/dist/sdk/eval.d.ts.map +1 -1
- package/dist/sdk/eval.js +117 -0
- package/dist/sdk/eval.js.map +1 -1
- package/dist/sdk/functions.d.ts +14 -1
- package/dist/sdk/functions.d.ts.map +1 -1
- package/dist/sdk/functions.js +48 -5
- package/dist/sdk/functions.js.map +1 -1
- package/dist/sdk/index.d.ts +2 -0
- package/dist/sdk/index.d.ts.map +1 -1
- package/dist/sdk/index.js +2 -0
- package/dist/sdk/index.js.map +1 -1
- package/dist/sdk/jarvis-chat.d.ts +28 -0
- package/dist/sdk/jarvis-chat.d.ts.map +1 -0
- package/dist/sdk/jarvis-chat.js +117 -0
- package/dist/sdk/jarvis-chat.js.map +1 -0
- package/dist/sdk/kb.d.ts +69 -1
- package/dist/sdk/kb.d.ts.map +1 -1
- package/dist/sdk/kb.js +111 -24
- package/dist/sdk/kb.js.map +1 -1
- package/dist/sdk/orgs.d.ts +13 -0
- package/dist/sdk/orgs.d.ts.map +1 -0
- package/dist/sdk/orgs.js +59 -0
- package/dist/sdk/orgs.js.map +1 -0
- package/dist/sdk/platform.d.ts +62 -0
- package/dist/sdk/platform.d.ts.map +1 -0
- package/dist/sdk/platform.js +95 -0
- package/dist/sdk/platform.js.map +1 -0
- package/dist/sdk/session.d.ts +25 -0
- package/dist/sdk/session.d.ts.map +1 -0
- package/dist/sdk/session.js +69 -0
- package/dist/sdk/session.js.map +1 -0
- package/dist/sdk/skills.d.ts +46 -0
- package/dist/sdk/skills.d.ts.map +1 -0
- package/dist/sdk/skills.js +203 -0
- package/dist/sdk/skills.js.map +1 -0
- package/dist/sdk/test-chat.d.ts +52 -0
- package/dist/sdk/test-chat.d.ts.map +1 -0
- package/dist/sdk/test-chat.js +134 -0
- package/dist/sdk/test-chat.js.map +1 -0
- package/dist/sdk/tickets.d.ts +98 -0
- package/dist/sdk/tickets.d.ts.map +1 -0
- package/dist/sdk/tickets.js +91 -0
- package/dist/sdk/tickets.js.map +1 -0
- package/dist/sdk/types.d.ts +91 -6
- package/dist/sdk/types.d.ts.map +1 -1
- package/dist/sdk/voice.d.ts +147 -0
- package/dist/sdk/voice.d.ts.map +1 -0
- package/dist/sdk/voice.js +236 -0
- package/dist/sdk/voice.js.map +1 -0
- package/package.json +22 -3
- package/scripts/check-skills.mjs +25 -0
- package/skills/agent-builder/SKILL.md +89 -252
- package/skills/evaluations/SKILL.md +23 -234
- package/skills/evaluations/references/analysis.md +95 -0
- package/skills/evaluations/references/cli-reference.md +28 -0
- package/skills/evaluations/references/json-formats.md +100 -0
- package/src/cli/agents.ts +202 -22
- package/src/cli/analytics.ts +138 -0
- package/src/cli/auth.ts +56 -18
- package/src/cli/billing.ts +25 -0
- package/src/cli/campaigns.ts +33 -0
- package/src/cli/chat.ts +66 -115
- package/src/cli/config.ts +65 -31
- package/src/cli/conversations.ts +133 -0
- package/src/cli/distribution.ts +116 -0
- package/src/cli/eval.ts +254 -70
- package/src/cli/functions.ts +105 -39
- package/src/cli/index.ts +58 -55
- package/src/cli/init.ts +154 -0
- package/src/cli/kb.ts +120 -17
- package/src/cli/link.ts +117 -0
- package/src/cli/org.ts +81 -75
- package/src/cli/platform.ts +128 -0
- package/src/cli/tickets.ts +130 -0
- package/src/cli/utils/output.ts +51 -42
- package/src/cli/voice.ts +204 -0
- package/src/core/diff.ts +52 -0
- package/src/core/org-resolver.ts +113 -0
- package/src/core/runtime.ts +73 -0
- package/src/core/scope.ts +64 -0
- package/src/mcp/registry.ts +236 -0
- package/src/mcp/server.ts +84 -2
- package/src/mcp/tool-meta.ts +241 -0
- package/src/mcp/tools.ts +1529 -265
- package/src/sdk/agent-bundle.ts +245 -0
- package/src/sdk/agents.ts +74 -5
- package/src/sdk/analytics.ts +220 -0
- package/src/sdk/auth.ts +87 -107
- package/src/sdk/billing.ts +59 -0
- package/src/sdk/campaigns.ts +60 -0
- package/src/sdk/chat.ts +44 -10
- package/src/sdk/client.ts +373 -102
- package/src/sdk/config-change.ts +148 -0
- package/src/sdk/config.ts +2 -1
- package/src/sdk/conversations.ts +199 -0
- package/src/sdk/credentials.ts +53 -0
- package/src/sdk/distribution.ts +132 -0
- package/src/sdk/eval.ts +140 -0
- package/src/sdk/functions.ts +52 -6
- package/src/sdk/index.ts +2 -0
- package/src/sdk/jarvis-chat.ts +112 -0
- package/src/sdk/kb.ts +116 -29
- package/src/sdk/orgs.ts +62 -0
- package/src/sdk/platform.ts +115 -0
- package/src/sdk/session.ts +90 -0
- package/src/sdk/skills.ts +188 -0
- package/src/sdk/test-chat.ts +151 -0
- package/src/sdk/tickets.ts +120 -0
- package/src/sdk/types.ts +88 -6
- package/src/sdk/voice.ts +262 -0
- package/test/cli/entry.test.ts +34 -0
- package/test/cli/init.test.ts +66 -0
- package/test/cli/link.test.ts +57 -0
- package/test/core/org-resolver.test.ts +92 -0
- package/test/core/runtime.test.ts +30 -0
- package/test/core/scope.test.ts +93 -0
- package/test/harness.test.ts +24 -0
- package/test/helpers/config-home.ts +22 -0
- package/test/helpers/fake-middleware.ts +56 -0
- package/test/helpers/fetch-recorder.ts +75 -0
- package/test/helpers/jwt.ts +9 -0
- package/test/helpers/mcp-pair.ts +38 -0
- package/test/mcp/__snapshots__/tools-snapshot.test.ts.snap +145 -0
- package/test/mcp/askers.test.ts +60 -0
- package/test/mcp/elicit-timeout.test.ts +29 -0
- package/test/mcp/registry.test.ts +169 -0
- package/test/mcp/shaping.test.ts +41 -0
- package/test/mcp/tools-snapshot.test.ts +42 -0
- package/test/sdk/agents.test.ts +232 -0
- package/test/sdk/analytics.test.ts +89 -0
- package/test/sdk/auth.test.ts +70 -0
- package/test/sdk/billing.test.ts +47 -0
- package/test/sdk/campaigns.test.ts +43 -0
- package/test/sdk/client.test.ts +80 -0
- package/test/sdk/config-change.test.ts +142 -0
- package/test/sdk/conversations.test.ts +80 -0
- package/test/sdk/credentials.test.ts +33 -0
- package/test/sdk/distribution.test.ts +55 -0
- package/test/sdk/eval.test.ts +155 -0
- package/test/sdk/kb-upload.test.ts +50 -0
- package/test/sdk/kb.test.ts +95 -0
- package/test/sdk/orgs.test.ts +48 -0
- package/test/sdk/platform.test.ts +89 -0
- package/test/sdk/renewal.test.ts +115 -0
- package/test/sdk/session.test.ts +60 -0
- package/test/sdk/skills.test.ts +235 -0
- package/test/sdk/test-chat.test.ts +112 -0
- package/test/sdk/tickets.test.ts +42 -0
- package/test/sdk/voice.test.ts +147 -0
- package/vitest.config.ts +11 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { cliVersion, EXIT, isNonInteractive, NeedsInputError, SessionExpiredError } from '../../src/core/runtime.js';
|
|
4
|
+
|
|
5
|
+
describe('isNonInteractive', () => {
|
|
6
|
+
it.each([
|
|
7
|
+
[{ CLAUDECODE: '1' }, true, true],
|
|
8
|
+
[{ CI: 'true' }, true, true],
|
|
9
|
+
[{ INDEMN_NON_INTERACTIVE: '1' }, true, true],
|
|
10
|
+
[{}, false, true],
|
|
11
|
+
[{}, true, false],
|
|
12
|
+
[{ CI: 'false' }, true, false],
|
|
13
|
+
])('env %j with TTY %s gives %s', (env, tty, expected) => {
|
|
14
|
+
expect(isNonInteractive(env as NodeJS.ProcessEnv, tty)).toBe(expected);
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('errors and version', () => {
|
|
19
|
+
it('uses exit code 2 for a needed choice and 3 for an expired session', () => {
|
|
20
|
+
const needs = new NeedsInputError('Which org?', [{ id: 'a', label: 'A' }]);
|
|
21
|
+
expect(needs.exitCode).toBe(EXIT.NEEDS_INPUT);
|
|
22
|
+
expect(needs.choices).toEqual([{ id: 'a', label: 'A' }]);
|
|
23
|
+
expect(new SessionExpiredError().exitCode).toBe(EXIT.AUTH);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('reads the version from package.json', () => {
|
|
27
|
+
const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
|
|
28
|
+
expect(cliVersion()).toBe(pkg.version);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { recordFetch } from '../helpers/fetch-recorder.js';
|
|
5
|
+
import { cleanEnv, useTempHome } from '../helpers/config-home.js';
|
|
6
|
+
import { jwtExpiringIn } from '../helpers/jwt.js';
|
|
7
|
+
import { IndemnClient } from '../../src/sdk/client.js';
|
|
8
|
+
import { onOrgResolved, runWithOrg, setAskers, setOrgFlag } from '../../src/core/scope.js';
|
|
9
|
+
import { NeedsInputError } from '../../src/core/runtime.js';
|
|
10
|
+
|
|
11
|
+
const A = 'aaaaaaaaaaaaaaaaaaaaaaaa';
|
|
12
|
+
const B = 'bbbbbbbbbbbbbbbbbbbbbbbb';
|
|
13
|
+
const AGENT_1 = '111111111111111111111111';
|
|
14
|
+
const AGENT_2 = '222222222222222222222222';
|
|
15
|
+
|
|
16
|
+
describe('org for each request', () => {
|
|
17
|
+
let home: ReturnType<typeof useTempHome>;
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
cleanEnv();
|
|
20
|
+
home = useTempHome();
|
|
21
|
+
vi.spyOn(process, 'cwd').mockReturnValue(home.dir);
|
|
22
|
+
setAskers({});
|
|
23
|
+
setOrgFlag(undefined);
|
|
24
|
+
IndemnClient.saveConfig({
|
|
25
|
+
environment: 'dev', user_id: 'u1', session: { token: jwtExpiringIn(5 * 3_600_000) },
|
|
26
|
+
orgs: [{ id: A, name: 'QA A' }, { id: B, name: 'QA B' }],
|
|
27
|
+
org_id: B, // legacy default org: must be ignored
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
afterEach(() => { setOrgFlag(undefined); home.cleanup(); });
|
|
31
|
+
|
|
32
|
+
const bots = (c: IndemnClient) => c.copilotGet('/organization/:org_id/ai-studio/v2/bots');
|
|
33
|
+
|
|
34
|
+
it('uses --org, not the legacy org saved in the config', async () => {
|
|
35
|
+
setOrgFlag(A);
|
|
36
|
+
const { calls } = recordFetch();
|
|
37
|
+
await bots(new IndemnClient());
|
|
38
|
+
expect(calls[0].url).toContain(`/organization/${A}/`);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('with two orgs and nobody to ask, needs input (exit 2) and sends nothing org-scoped', async () => {
|
|
42
|
+
const { calls } = recordFetch();
|
|
43
|
+
const err = await bots(new IndemnClient()).catch((e) => e);
|
|
44
|
+
expect(err).toBeInstanceOf(NeedsInputError);
|
|
45
|
+
expect(err.exitCode).toBe(2);
|
|
46
|
+
expect(calls).toHaveLength(0);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('asks when someone can answer, and reports the choice once', async () => {
|
|
50
|
+
setAskers({ chooseOrg: async () => B });
|
|
51
|
+
const seen = vi.fn();
|
|
52
|
+
onOrgResolved(seen);
|
|
53
|
+
const { calls } = recordFetch();
|
|
54
|
+
const client = new IndemnClient();
|
|
55
|
+
await bots(client);
|
|
56
|
+
await bots(client);
|
|
57
|
+
expect(calls.every((c) => c.url.includes(`/organization/${B}/`))).toBe(true);
|
|
58
|
+
expect(seen).toHaveBeenCalledTimes(1);
|
|
59
|
+
expect(seen).toHaveBeenCalledWith({ id: B, name: 'QA B', source: 'asked', environment: 'dev' });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('uses the linked folder and refuses an agent that is not linked, naming the file', async () => {
|
|
63
|
+
const project = join(home.dir, 'project');
|
|
64
|
+
mkdirSync(join(project, '.indemn'), { recursive: true });
|
|
65
|
+
writeFileSync(join(project, '.indemn', 'project.json'), JSON.stringify({ org_id: A, environment: 'dev', agents: [AGENT_1] }));
|
|
66
|
+
vi.spyOn(process, 'cwd').mockReturnValue(project);
|
|
67
|
+
const { calls } = recordFetch();
|
|
68
|
+
const client = new IndemnClient();
|
|
69
|
+
await client.copilotGet(`/organization/:org_id/ai-studio/v2/bots/${AGENT_1}`);
|
|
70
|
+
expect(calls[0].url).toContain(`/organization/${A}/ai-studio/v2/bots/${AGENT_1}`);
|
|
71
|
+
await expect(client.copilotGet(`/organization/:org_id/ai-studio/bots/${AGENT_2}/configurations`))
|
|
72
|
+
.rejects.toThrow(join(home.dir, 'project', '.indemn', 'project.json'));
|
|
73
|
+
expect(calls).toHaveLength(1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('lets one MCP call act on its own org', async () => {
|
|
77
|
+
setOrgFlag(A);
|
|
78
|
+
const { calls } = recordFetch();
|
|
79
|
+
const client = new IndemnClient();
|
|
80
|
+
await runWithOrg(B, () => bots(client));
|
|
81
|
+
expect(calls[0].url).toContain(`/organization/${B}/`);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('uses the only org without asking', async () => {
|
|
85
|
+
IndemnClient.saveConfig({ orgs: [{ id: A, name: 'QA A' }] });
|
|
86
|
+
const ask = vi.fn();
|
|
87
|
+
setAskers({ chooseOrg: ask });
|
|
88
|
+
const { calls } = recordFetch();
|
|
89
|
+
await bots(new IndemnClient());
|
|
90
|
+
expect(calls[0].url).toContain(`/organization/${A}/`);
|
|
91
|
+
expect(ask).not.toHaveBeenCalled();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { recordFetch, on } from './helpers/fetch-recorder.js';
|
|
3
|
+
import { cleanEnv, useTempHome } from './helpers/config-home.js';
|
|
4
|
+
import { IndemnClient } from '../src/sdk/client.js';
|
|
5
|
+
|
|
6
|
+
describe('test harness', () => {
|
|
7
|
+
let home: ReturnType<typeof useTempHome>;
|
|
8
|
+
afterEach(() => home?.cleanup());
|
|
9
|
+
|
|
10
|
+
it('records fetch calls and answers with scripted JSON', async () => {
|
|
11
|
+
const { calls } = recordFetch([on('GET', '/ping', { json: { pong: true } })]);
|
|
12
|
+
const res = await fetch('https://example.test/ping', { headers: { 'X-Test': '1' } });
|
|
13
|
+
expect(await res.json()).toEqual({ pong: true });
|
|
14
|
+
expect(calls).toEqual([
|
|
15
|
+
{ method: 'GET', url: 'https://example.test/ping', headers: { 'x-test': '1' }, body: undefined },
|
|
16
|
+
]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('isolates HOME so no real config is read', () => {
|
|
20
|
+
cleanEnv();
|
|
21
|
+
home = useTempHome();
|
|
22
|
+
expect(IndemnClient.loadConfig()).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
/** Point HOME at a fresh temporary folder so tests never touch ~/.indemn. */
|
|
7
|
+
export function useTempHome() {
|
|
8
|
+
const dir = mkdtempSync(join(tmpdir(), 'indemn-test-'));
|
|
9
|
+
vi.stubEnv('HOME', dir);
|
|
10
|
+
return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Clear every variable that changes CLI behaviour, including the ones Claude Code sets. */
|
|
14
|
+
export function cleanEnv() {
|
|
15
|
+
for (const name of [
|
|
16
|
+
'CLAUDECODE', 'CI', 'INDEMN_NON_INTERACTIVE', 'INDEMN_DEBUG', 'INDEMN_ENV', 'INDEMN_API_KEY',
|
|
17
|
+
'INDEMN_TOKEN', 'INDEMN_ORG_ID', 'INDEMN_NO_BANNER', 'INDEMN_COPILOT_URL', 'INDEMN_EVALS_URL',
|
|
18
|
+
'INDEMN_MIDDLEWARE_URL', 'INDEMN_PLATFORM_URL',
|
|
19
|
+
]) {
|
|
20
|
+
vi.stubEnv(name, undefined as unknown as string);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import type { AddressInfo } from 'node:net';
|
|
3
|
+
import { Server } from 'socket.io';
|
|
4
|
+
|
|
5
|
+
export interface FakeMiddleware {
|
|
6
|
+
url: string;
|
|
7
|
+
sessionRequests: Array<Record<string, any>>;
|
|
8
|
+
userMessages: Array<Record<string, any>>;
|
|
9
|
+
closed: Array<Record<string, any>>;
|
|
10
|
+
close: () => Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A stand-in for middleware-socket-service: confirms a session, streams a
|
|
15
|
+
* greeting for new sessions, and answers each user message with a streamed
|
|
16
|
+
* reply "echo: <message>".
|
|
17
|
+
*/
|
|
18
|
+
export async function startFakeMiddleware(opts: { sessionId?: string; greeting?: string } = {}): Promise<FakeMiddleware> {
|
|
19
|
+
const http = createServer();
|
|
20
|
+
const io = new Server(http, { transports: ['websocket'] });
|
|
21
|
+
const state: FakeMiddleware = { url: '', sessionRequests: [], userMessages: [], closed: [], close: async () => {} };
|
|
22
|
+
let streamCount = 0;
|
|
23
|
+
|
|
24
|
+
const stream = (socket: import('socket.io').Socket, text: string) => {
|
|
25
|
+
const streamId = `s${++streamCount}`;
|
|
26
|
+
const words = text.split(' ');
|
|
27
|
+
words.forEach((w, i) => {
|
|
28
|
+
socket.emit('stream_bot_uttered', {
|
|
29
|
+
streamId,
|
|
30
|
+
isFirstChunk: i === 0,
|
|
31
|
+
text: (i ? ' ' : '') + w,
|
|
32
|
+
streamFinished: false,
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
socket.emit('stream_bot_uttered', { streamId, text: '', streamFinished: true });
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
io.on('connection', (socket) => {
|
|
39
|
+
socket.on('session_request', (req) => {
|
|
40
|
+
state.sessionRequests.push(req);
|
|
41
|
+
const sessionId = req.session_id || opts.sessionId || 'sess-1';
|
|
42
|
+
socket.emit('session_confirm', { session_id: sessionId });
|
|
43
|
+
if (!req.session_id) setTimeout(() => stream(socket, opts.greeting ?? 'Hello there'), 10);
|
|
44
|
+
});
|
|
45
|
+
socket.on('user_uttered', (msg) => {
|
|
46
|
+
state.userMessages.push(msg);
|
|
47
|
+
setTimeout(() => stream(socket, `echo: ${msg.message}`), 10);
|
|
48
|
+
});
|
|
49
|
+
socket.on('session_closed', (msg) => state.closed.push(msg));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
await new Promise<void>((resolve) => http.listen(0, '127.0.0.1', resolve));
|
|
53
|
+
state.url = `http://127.0.0.1:${(http.address() as AddressInfo).port}`;
|
|
54
|
+
state.close = () => new Promise((resolve) => { io.close(() => resolve()); });
|
|
55
|
+
return state;
|
|
56
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
export interface RecordedCall {
|
|
4
|
+
method: string;
|
|
5
|
+
url: string;
|
|
6
|
+
headers: Record<string, string>;
|
|
7
|
+
body: unknown;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ScriptedResponse {
|
|
11
|
+
status?: number;
|
|
12
|
+
json?: unknown;
|
|
13
|
+
text?: string;
|
|
14
|
+
/** Throw a network error instead of answering. */
|
|
15
|
+
networkError?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type Responder = (call: RecordedCall) => ScriptedResponse | undefined;
|
|
19
|
+
|
|
20
|
+
function parseBody(body: unknown): unknown {
|
|
21
|
+
if (typeof body !== 'string') return body instanceof URLSearchParams ? body.toString() : body;
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(body);
|
|
24
|
+
} catch {
|
|
25
|
+
return body;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function toResponse(r: ScriptedResponse): Response {
|
|
30
|
+
const status = r.status ?? 200;
|
|
31
|
+
if (r.json !== undefined) {
|
|
32
|
+
return new Response(JSON.stringify(r.json), { status, headers: { 'content-type': 'application/json' } });
|
|
33
|
+
}
|
|
34
|
+
return new Response(r.text ?? '', { status });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Replace global fetch with a recorder. Responders are tried in order; the
|
|
39
|
+
* first one that returns a response answers the call. Unmatched calls get
|
|
40
|
+
* 200 `{ success: true, data: [] }`.
|
|
41
|
+
*/
|
|
42
|
+
export function recordFetch(responders: Responder[] = []) {
|
|
43
|
+
const calls: RecordedCall[] = [];
|
|
44
|
+
const fn = vi.fn(async (input: string | URL | Request, init: RequestInit = {}) => {
|
|
45
|
+
const headers: Record<string, string> = {};
|
|
46
|
+
new Headers(init.headers).forEach((value, key) => {
|
|
47
|
+
headers[key] = value;
|
|
48
|
+
});
|
|
49
|
+
const call: RecordedCall = {
|
|
50
|
+
method: (init.method || 'GET').toUpperCase(),
|
|
51
|
+
url: typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url,
|
|
52
|
+
headers,
|
|
53
|
+
body: parseBody(init.body),
|
|
54
|
+
};
|
|
55
|
+
calls.push(call);
|
|
56
|
+
for (const responder of responders) {
|
|
57
|
+
const res = responder(call);
|
|
58
|
+
if (res) {
|
|
59
|
+
if (res.networkError) throw new TypeError('fetch failed');
|
|
60
|
+
return toResponse(res);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return toResponse({ json: { success: true, data: [] } });
|
|
64
|
+
});
|
|
65
|
+
vi.stubGlobal('fetch', fn);
|
|
66
|
+
return { calls, fn };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Match a call by method and a URL substring. */
|
|
70
|
+
export function on(method: string, urlPart: string, res: ScriptedResponse | ((c: RecordedCall) => ScriptedResponse)): Responder {
|
|
71
|
+
return (call) => {
|
|
72
|
+
if (call.method !== method.toUpperCase() || !call.url.includes(urlPart)) return undefined;
|
|
73
|
+
return typeof res === 'function' ? res(call) : res;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** An unsigned JWT-shaped string with the given claims. Only the payload matters to the CLI. */
|
|
2
|
+
export function fakeJwt(claims: Record<string, unknown>): string {
|
|
3
|
+
const enc = (o: unknown) => Buffer.from(JSON.stringify(o)).toString('base64url');
|
|
4
|
+
return `${enc({ alg: 'HS256', typ: 'JWT' })}.${enc(claims)}.sig`;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function jwtExpiringIn(ms: number, extra: Record<string, unknown> = {}): string {
|
|
8
|
+
return fakeJwt({ _id: 'user1', email: 'qa@indemn.ai', exp: Math.floor((Date.now() + ms) / 1000), ...extra });
|
|
9
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
3
|
+
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
|
4
|
+
import { ElicitRequestSchema, type ElicitResult } from '@modelcontextprotocol/sdk/types.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A real MCP server and client joined in memory. Pass `elicit` to make the
|
|
8
|
+
* client advertise elicitation and answer every question with it.
|
|
9
|
+
*/
|
|
10
|
+
export async function connectPair(
|
|
11
|
+
setup: (server: McpServer) => void,
|
|
12
|
+
opts: { elicit?: (params: { message: string; requestedSchema: unknown }) => ElicitResult } = {},
|
|
13
|
+
) {
|
|
14
|
+
const server = new McpServer({ name: 'test', version: '0.0.0' });
|
|
15
|
+
setup(server);
|
|
16
|
+
const client = new Client(
|
|
17
|
+
{ name: 'test-client', version: '0.0.0' },
|
|
18
|
+
{ capabilities: opts.elicit ? { elicitation: {} } : {} },
|
|
19
|
+
);
|
|
20
|
+
const questions: Array<{ message: string; requestedSchema: unknown }> = [];
|
|
21
|
+
if (opts.elicit) {
|
|
22
|
+
client.setRequestHandler(ElicitRequestSchema, async (req) => {
|
|
23
|
+
const params = req.params as { message: string; requestedSchema: unknown };
|
|
24
|
+
questions.push(params);
|
|
25
|
+
return opts.elicit!(params);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
const [a, b] = InMemoryTransport.createLinkedPair();
|
|
29
|
+
await Promise.all([server.connect(a), client.connect(b)]);
|
|
30
|
+
const call = async (name: string, args: Record<string, unknown> = {}) => {
|
|
31
|
+
const res = await client.callTool({ name, arguments: args });
|
|
32
|
+
const content = res.content as Array<{ type: string; text: string }>;
|
|
33
|
+
let parsed: unknown = content[0]?.text;
|
|
34
|
+
try { parsed = JSON.parse(content[0].text); } catch { /* plain text */ }
|
|
35
|
+
return { raw: res, body: parsed as any, isError: Boolean(res.isError) };
|
|
36
|
+
};
|
|
37
|
+
return { server, client, call, questions, close: () => Promise.all([client.close(), server.close()]) };
|
|
38
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
|
+
|
|
3
|
+
exports[`MCP tool list > gives every tool a title and annotations 1`] = `
|
|
4
|
+
[
|
|
5
|
+
"add_function_parameter",
|
|
6
|
+
"add_holiday",
|
|
7
|
+
"add_kb_data",
|
|
8
|
+
"agent_stats",
|
|
9
|
+
"agent_summary",
|
|
10
|
+
"archive_ticket",
|
|
11
|
+
"attach_phone_number",
|
|
12
|
+
"bulk_create_skills",
|
|
13
|
+
"buy_phone_number",
|
|
14
|
+
"chat_with_agent",
|
|
15
|
+
"clone_agent",
|
|
16
|
+
"compare_eval_runs",
|
|
17
|
+
"create_agent",
|
|
18
|
+
"create_agent_from_template",
|
|
19
|
+
"create_distribution",
|
|
20
|
+
"create_function",
|
|
21
|
+
"create_knowledge_base",
|
|
22
|
+
"create_rubric",
|
|
23
|
+
"create_template",
|
|
24
|
+
"create_test_set",
|
|
25
|
+
"create_ticket",
|
|
26
|
+
"delete_agent",
|
|
27
|
+
"delete_function",
|
|
28
|
+
"delete_function_parameter",
|
|
29
|
+
"delete_kb_data",
|
|
30
|
+
"delete_knowledge_base",
|
|
31
|
+
"delete_rubric",
|
|
32
|
+
"delete_template",
|
|
33
|
+
"delete_test_set",
|
|
34
|
+
"delete_ticket_rule",
|
|
35
|
+
"detach_phone_number",
|
|
36
|
+
"eval_gate_verdict",
|
|
37
|
+
"eval_quality_metrics",
|
|
38
|
+
"eval_report_link",
|
|
39
|
+
"eval_trends",
|
|
40
|
+
"evaluate_transcript",
|
|
41
|
+
"export_agent",
|
|
42
|
+
"export_agent_card",
|
|
43
|
+
"export_eval_markdown",
|
|
44
|
+
"export_eval_report",
|
|
45
|
+
"export_functions",
|
|
46
|
+
"export_kb",
|
|
47
|
+
"generate_skills_from_doc",
|
|
48
|
+
"get_agent",
|
|
49
|
+
"get_agent_voice",
|
|
50
|
+
"get_analytics",
|
|
51
|
+
"get_billing",
|
|
52
|
+
"get_bot_context",
|
|
53
|
+
"get_campaigns",
|
|
54
|
+
"get_config",
|
|
55
|
+
"get_conversation",
|
|
56
|
+
"get_custom_chart",
|
|
57
|
+
"get_embed_code",
|
|
58
|
+
"get_eval_results",
|
|
59
|
+
"get_eval_status",
|
|
60
|
+
"get_knowledge_base",
|
|
61
|
+
"get_org_settings",
|
|
62
|
+
"get_phone_number",
|
|
63
|
+
"get_prompt",
|
|
64
|
+
"get_rubric",
|
|
65
|
+
"get_test_set",
|
|
66
|
+
"get_ticket",
|
|
67
|
+
"import_agent",
|
|
68
|
+
"import_functions",
|
|
69
|
+
"import_kb",
|
|
70
|
+
"jarvis_chat",
|
|
71
|
+
"jarvis_create_job",
|
|
72
|
+
"jarvis_jobs",
|
|
73
|
+
"kb_check",
|
|
74
|
+
"kb_chunks",
|
|
75
|
+
"kb_gaps",
|
|
76
|
+
"kb_scrape_status",
|
|
77
|
+
"link_kb",
|
|
78
|
+
"list_agent_numbers",
|
|
79
|
+
"list_agents",
|
|
80
|
+
"list_analytics_conversations",
|
|
81
|
+
"list_audit_logs",
|
|
82
|
+
"list_channels",
|
|
83
|
+
"list_conversations",
|
|
84
|
+
"list_custom_charts",
|
|
85
|
+
"list_distributions",
|
|
86
|
+
"list_evaluator_configs",
|
|
87
|
+
"list_function_parameters",
|
|
88
|
+
"list_functions",
|
|
89
|
+
"list_kb_data",
|
|
90
|
+
"list_knowledge_bases",
|
|
91
|
+
"list_llm_models",
|
|
92
|
+
"list_master_functions",
|
|
93
|
+
"list_org_members",
|
|
94
|
+
"list_organizations",
|
|
95
|
+
"list_prod_calls",
|
|
96
|
+
"list_rubric_versions",
|
|
97
|
+
"list_rubrics",
|
|
98
|
+
"list_solutions",
|
|
99
|
+
"list_templates",
|
|
100
|
+
"list_test_set_versions",
|
|
101
|
+
"list_test_sets",
|
|
102
|
+
"list_ticket_rules",
|
|
103
|
+
"list_tickets",
|
|
104
|
+
"list_voice_models",
|
|
105
|
+
"list_voices",
|
|
106
|
+
"mark_conversation_test",
|
|
107
|
+
"org_evaluations",
|
|
108
|
+
"platform_delete",
|
|
109
|
+
"platform_get",
|
|
110
|
+
"platform_list",
|
|
111
|
+
"platform_save",
|
|
112
|
+
"promote_prod_call",
|
|
113
|
+
"reimport_kb_source",
|
|
114
|
+
"run_evaluation",
|
|
115
|
+
"run_simulation",
|
|
116
|
+
"save_ticket_rule",
|
|
117
|
+
"scrape_url",
|
|
118
|
+
"search_phone_numbers",
|
|
119
|
+
"set_agent_lifecycle",
|
|
120
|
+
"set_agent_transcriber",
|
|
121
|
+
"set_agent_voice",
|
|
122
|
+
"set_business_hours",
|
|
123
|
+
"set_config",
|
|
124
|
+
"set_org_admin_settings",
|
|
125
|
+
"set_org_timezone",
|
|
126
|
+
"set_prompt",
|
|
127
|
+
"simulation_status",
|
|
128
|
+
"test_function",
|
|
129
|
+
"transfer_agent_owner",
|
|
130
|
+
"unlink_kb",
|
|
131
|
+
"update_agent",
|
|
132
|
+
"update_distribution",
|
|
133
|
+
"update_function",
|
|
134
|
+
"update_function_parameter",
|
|
135
|
+
"update_kb_data",
|
|
136
|
+
"update_knowledge_base",
|
|
137
|
+
"update_rubric",
|
|
138
|
+
"update_test_set",
|
|
139
|
+
"update_ticket",
|
|
140
|
+
"upload_kb_file",
|
|
141
|
+
"validate_platform_template",
|
|
142
|
+
"voice_preview",
|
|
143
|
+
"whoami",
|
|
144
|
+
]
|
|
145
|
+
`;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { recordFetch } from '../helpers/fetch-recorder.js';
|
|
3
|
+
import { cleanEnv, useTempHome } from '../helpers/config-home.js';
|
|
4
|
+
import { jwtExpiringIn } from '../helpers/jwt.js';
|
|
5
|
+
import { connectPair } from '../helpers/mcp-pair.js';
|
|
6
|
+
import { IndemnClient } from '../../src/sdk/client.js';
|
|
7
|
+
import { registerTools } from '../../src/mcp/tools.js';
|
|
8
|
+
import { mcpAskers } from '../../src/mcp/server.js';
|
|
9
|
+
import { setAskers } from '../../src/core/scope.js';
|
|
10
|
+
|
|
11
|
+
const A = 'aaaaaaaaaaaaaaaaaaaaaaaa';
|
|
12
|
+
const B = 'bbbbbbbbbbbbbbbbbbbbbbbb';
|
|
13
|
+
|
|
14
|
+
describe('MCP asks which org', () => {
|
|
15
|
+
let home: ReturnType<typeof useTempHome>;
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
cleanEnv();
|
|
18
|
+
home = useTempHome();
|
|
19
|
+
vi.spyOn(process, 'cwd').mockReturnValue(home.dir);
|
|
20
|
+
IndemnClient.saveConfig({
|
|
21
|
+
environment: 'dev', user_id: 'u1', session: { token: jwtExpiringIn(5 * 3_600_000) },
|
|
22
|
+
orgs: [{ id: A, name: 'QA A' }, { id: B, name: 'QA B' }],
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
afterEach(() => { setAskers({}); home.cleanup(); });
|
|
26
|
+
|
|
27
|
+
function setup(server: Parameters<typeof mcpAskers>[0]) {
|
|
28
|
+
setAskers(mcpAskers(server));
|
|
29
|
+
registerTools(server, new IndemnClient());
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
it('asks the person through the host and acts on the org they pick', async () => {
|
|
33
|
+
const { calls } = recordFetch();
|
|
34
|
+
const pair = await connectPair(setup, { elicit: () => ({ action: 'accept', content: { org_id: B } }) });
|
|
35
|
+
const res = await pair.call('list_agents');
|
|
36
|
+
expect(calls[0].url).toContain(`/organization/${B}/ai-studio/v2/bots`);
|
|
37
|
+
expect(res.body.context).toEqual({ org_id: B, org_name: 'QA B', environment: 'dev' });
|
|
38
|
+
expect(JSON.stringify(pair.questions[0].requestedSchema)).toContain('QA A');
|
|
39
|
+
await pair.close();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('without elicitation, returns the choices instead of guessing', async () => {
|
|
43
|
+
const { calls } = recordFetch();
|
|
44
|
+
const pair = await connectPair(setup);
|
|
45
|
+
const res = await pair.call('list_agents');
|
|
46
|
+
expect(res.isError).toBe(false);
|
|
47
|
+
expect(res.body).toMatchObject({ needs_input: true, choices: [{ id: A, label: 'QA A' }, { id: B, label: 'QA B' }] });
|
|
48
|
+
expect(calls).toHaveLength(0);
|
|
49
|
+
await pair.close();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('an explicit org_id needs no question', async () => {
|
|
53
|
+
const { calls } = recordFetch();
|
|
54
|
+
const pair = await connectPair(setup, { elicit: () => { throw new Error('should not ask'); } });
|
|
55
|
+
await pair.call('list_agents', { org_id: A });
|
|
56
|
+
expect(calls[0].url).toContain(`/organization/${A}/`);
|
|
57
|
+
expect(pair.questions).toHaveLength(0);
|
|
58
|
+
await pair.close();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
4
|
+
import { guardDestructive } from '../../src/mcp/registry.js';
|
|
5
|
+
import { PERSON_TIMEOUT_MS } from '../../src/core/runtime.js';
|
|
6
|
+
|
|
7
|
+
function serverAskingWith(impl: (...a: unknown[]) => Promise<unknown>) {
|
|
8
|
+
const server = new McpServer({ name: 't', version: '0' });
|
|
9
|
+
vi.spyOn(server.server, 'getClientCapabilities').mockReturnValue({ elicitation: {} });
|
|
10
|
+
const spy = vi.spyOn(server.server, 'elicitInput').mockImplementation(impl as never);
|
|
11
|
+
return { server, spy };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('asking a person through the host', () => {
|
|
15
|
+
it('gives the person 10 minutes, not the MCP default of 60 seconds', async () => {
|
|
16
|
+
const { server, spy } = serverAskingWith(async () => ({ action: 'accept', content: { confirm: true } }));
|
|
17
|
+
await expect(guardDestructive(server, 'wipe', {}, 'This will wipe.')).resolves.toMatchObject({ proceed: true });
|
|
18
|
+
expect(spy.mock.calls[0][1]).toEqual({ timeout: PERSON_TIMEOUT_MS });
|
|
19
|
+
expect(PERSON_TIMEOUT_MS).toBe(600_000);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('treats no answer in time as cancelled, never as a dry run to self-confirm', async () => {
|
|
23
|
+
const { server } = serverAskingWith(async () => { throw new McpError(ErrorCode.RequestTimeout, 'Request timed out'); });
|
|
24
|
+
const res = await guardDestructive(server, 'wipe', { confirm: true }, 'This will wipe.');
|
|
25
|
+
expect(res.proceed).toBe(false);
|
|
26
|
+
const body = JSON.parse((res as { result: { content: Array<{ text: string }> } }).result.content[0].text);
|
|
27
|
+
expect(body).toMatchObject({ cancelled: true, reason: 'No answer within 10 minutes; nothing was changed.' });
|
|
28
|
+
});
|
|
29
|
+
});
|