@robota-sdk/agent-command 3.0.0-beta.85 → 3.0.0-beta.87
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 +64 -12
- package/README.md +6 -3
- package/dist/node/index.cjs +72 -70
- package/dist/node/index.d.cts +16 -8
- package/dist/node/index.d.cts.map +1 -1
- package/dist/node/index.d.ts +16 -8
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +51 -49
- package/dist/node/index.js.map +1 -1
- package/package.json +15 -13
- package/src/agent/__tests__/agent-command.test.ts +5 -5
- package/src/agent/agent-command-module.ts +1 -1
- package/src/background/__tests__/background-command-module.test.ts +6 -6
- package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +15 -4
- package/src/default/__tests__/default-command-modules.test.ts +2 -2
- package/src/default/__tests__/model-exposure.test.ts +18 -5
- package/src/default/__tests__/org-policy-forwarding.test.ts +1 -1
- package/src/default/default-command-modules.ts +2 -2
- package/src/devices/devices-command-module.ts +1 -1
- package/src/doctor/__tests__/doctor-command-module.test.ts +10 -10
- package/src/doctor/__tests__/doctor-display-vocabulary.test.ts +1 -1
- package/src/doctor/__tests__/doctor-fixture.ts +14 -14
- package/src/doctor/__tests__/doctor-runner.test.ts +57 -57
- package/src/doctor/doctor-storage-probe.ts +1 -1
- package/src/doctor/doctor-types.ts +2 -2
- package/src/editor/__tests__/editor-command-functional.test.ts +1 -1
- package/src/fork/__tests__/fork-command.test.ts +1 -1
- package/src/fork/fork-command-module.ts +1 -1
- package/src/git/__tests__/git-command-module.test.ts +1 -1
- package/src/handoff/__tests__/handoff-command-module.test.ts +1 -1
- package/src/handoff/handoff-command-module.ts +1 -1
- package/src/keybindings/keybindings-command-module.ts +1 -1
- package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +8 -8
- package/src/mcp-activation/__tests__/mcp-model-view.test.ts +43 -12
- package/src/mcp-activation/__tests__/mcp-skill-command.test.ts +141 -0
- package/src/mcp-activation/mcp-activation-command-module.ts +27 -2
- package/src/mcp-activation/mcp-activation-command.ts +149 -15
- package/src/mcp-activation/mcp-model-notice.ts +19 -5
- package/src/mcp-activation/mcp-model-status.ts +7 -2
- package/src/memory/__tests__/memory-command-module.test.ts +89 -16
- package/src/memory/memory-command-module.ts +4 -1
- package/src/memory/memory-command.ts +53 -1
- package/src/peers/__tests__/peers-command-module.test.ts +1 -1
- package/src/peers/peers-command-module.ts +1 -1
- package/src/permissions/__tests__/permissions-command-module.test.ts +4 -4
- package/src/plugin/__tests__/plugin-active-turn.test.ts +100 -0
- package/src/plugin/__tests__/plugin-command-module.test.ts +7 -7
- package/src/plugin/plugin-command-module.ts +4 -0
- package/src/provider/provider-startup.ts +4 -2
- package/src/remote-control/__tests__/remote-control-command.test.ts +2 -2
- package/src/rewind/__tests__/rewind-command-module.test.ts +4 -3
- package/src/rewind/rewind-command.ts +27 -21
- package/src/schedule/schedule-command-module.ts +1 -1
- package/src/session/__tests__/session-command-module.test.ts +2 -2
- package/src/skills/__tests__/skill-read-command.test.ts +64 -0
- package/src/skills/__tests__/skills-command-module.test.ts +2 -1
- package/src/skills/skill-read-command.ts +60 -0
- package/src/skills/skills-command-module.ts +3 -2
- package/src/terminal-client/__tests__/terminal-client-commands.test.ts +1 -1
- package/src/theme/__tests__/theme-command.test.ts +1 -1
- package/src/theme/theme-command-module.ts +1 -1
- package/src/user-local/__tests__/user-local-command.test.ts +16 -16
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { expect, it, vi } from 'vitest';
|
|
2
|
+
import { SystemCommandExecutor } from '@robota-sdk/agent-framework';
|
|
3
|
+
import { createTestCommandHost } from '@robota-sdk/agent-framework/testing';
|
|
4
|
+
import { executeMCPActivationCommand } from '../mcp-activation-command.js';
|
|
5
|
+
import { createMCPActivationCommandModule } from '../mcp-activation-command-module.js';
|
|
6
|
+
import type { TCommandInvocationSource } from '@robota-sdk/agent-interface-command';
|
|
7
|
+
|
|
8
|
+
const preview = {
|
|
9
|
+
serverId: 'docs',
|
|
10
|
+
uri: 'skill://docs/demo/SKILL.md',
|
|
11
|
+
namespace: 'origin',
|
|
12
|
+
fingerprint: 'a'.repeat(64),
|
|
13
|
+
name: 'demo',
|
|
14
|
+
description: 'Use this workflow',
|
|
15
|
+
frontmatter: { name: 'demo', description: 'Use this workflow', 'allowed-tools': 'Write' },
|
|
16
|
+
content: '---\nname: demo\n---\nInstructions for review.',
|
|
17
|
+
};
|
|
18
|
+
function fixture(source: TCommandInvocationSource | undefined, locality?: 'local' | 'remote') {
|
|
19
|
+
const skills = {
|
|
20
|
+
list: vi.fn(async () => [{ ...preview, content: undefined }]),
|
|
21
|
+
inspect: vi.fn(async () => preview),
|
|
22
|
+
approve: vi.fn(async () => preview),
|
|
23
|
+
withdraw: vi.fn(),
|
|
24
|
+
};
|
|
25
|
+
const host = createTestCommandHost({
|
|
26
|
+
overrides: {
|
|
27
|
+
getCommandHostAdapters: () => ({
|
|
28
|
+
mcpActivation: {
|
|
29
|
+
list: () => [],
|
|
30
|
+
approve: vi.fn(),
|
|
31
|
+
reject: vi.fn(),
|
|
32
|
+
revoke: vi.fn(),
|
|
33
|
+
skills,
|
|
34
|
+
},
|
|
35
|
+
}),
|
|
36
|
+
getCommandInvocationSource: () => source ?? 'user',
|
|
37
|
+
getCommandSurfaceLocalityEvidence: () => locality,
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
const {
|
|
41
|
+
getCommandInvocationSource: _source,
|
|
42
|
+
getCommandSurfaceLocalityEvidence: _locality,
|
|
43
|
+
...base
|
|
44
|
+
} = host;
|
|
45
|
+
return {
|
|
46
|
+
skills,
|
|
47
|
+
host: {
|
|
48
|
+
...base,
|
|
49
|
+
...(source === undefined ? {} : { getCommandInvocationSource: () => source }),
|
|
50
|
+
...(locality === undefined ? {} : { getCommandSurfaceLocalityEvidence: () => locality }),
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
it('lists model-visible metadata without inspecting or approving instructions', async () => {
|
|
56
|
+
const f = fixture('model');
|
|
57
|
+
const result = await executeMCPActivationCommand(f.host, 'skill-list docs');
|
|
58
|
+
expect(result.success).toBe(true);
|
|
59
|
+
expect(result.message).toContain('Use this workflow');
|
|
60
|
+
expect(f.skills.list).toHaveBeenCalledWith('docs');
|
|
61
|
+
expect(f.skills.inspect).not.toHaveBeenCalled();
|
|
62
|
+
expect(f.skills.approve).not.toHaveBeenCalled();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('shows exact content and supplies a content-bound approval command for the user', async () => {
|
|
66
|
+
const f = fixture('user');
|
|
67
|
+
const result = await executeMCPActivationCommand(
|
|
68
|
+
f.host,
|
|
69
|
+
'skill-inspect docs skill://docs/demo/SKILL.md',
|
|
70
|
+
);
|
|
71
|
+
expect(result.success).toBe(true);
|
|
72
|
+
expect(result.message).toContain(preview.content);
|
|
73
|
+
expect(result.message).toContain(
|
|
74
|
+
`/mcp skill-approve docs skill://docs/demo/SKILL.md ${preview.fingerprint}`,
|
|
75
|
+
);
|
|
76
|
+
expect(f.skills.approve).not.toHaveBeenCalled();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it.each(['model', 'remote', undefined] as const)(
|
|
80
|
+
'refuses consent actions from %s before dispatch',
|
|
81
|
+
async (source) => {
|
|
82
|
+
const f = fixture(source);
|
|
83
|
+
for (const verb of ['skill-inspect', 'skill-approve', 'skill-withdraw']) {
|
|
84
|
+
const result = await executeMCPActivationCommand(
|
|
85
|
+
f.host,
|
|
86
|
+
`${verb} docs skill://docs/demo/SKILL.md ${preview.fingerprint}`,
|
|
87
|
+
);
|
|
88
|
+
expect(result.success).toBe(false);
|
|
89
|
+
expect(result.message).toContain('local user');
|
|
90
|
+
}
|
|
91
|
+
expect(f.skills.inspect).not.toHaveBeenCalled();
|
|
92
|
+
expect(f.skills.approve).not.toHaveBeenCalled();
|
|
93
|
+
expect(f.skills.withdraw).not.toHaveBeenCalled();
|
|
94
|
+
},
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
it('allows a proven local app user and refuses another-device transport', async () => {
|
|
98
|
+
const f = fixture('remote', 'local');
|
|
99
|
+
expect(
|
|
100
|
+
(
|
|
101
|
+
await executeMCPActivationCommand(
|
|
102
|
+
f.host,
|
|
103
|
+
`skill-approve docs ${preview.uri} ${preview.fingerprint}`,
|
|
104
|
+
)
|
|
105
|
+
).success,
|
|
106
|
+
).toBe(true);
|
|
107
|
+
expect(f.skills.approve).toHaveBeenCalledWith('docs', preview.uri, preview.fingerprint, 'user');
|
|
108
|
+
const remote = fixture('remote', 'remote');
|
|
109
|
+
expect(
|
|
110
|
+
(
|
|
111
|
+
await executeMCPActivationCommand(
|
|
112
|
+
remote.host,
|
|
113
|
+
`skill-approve docs ${preview.uri} ${preview.fingerprint}`,
|
|
114
|
+
)
|
|
115
|
+
).success,
|
|
116
|
+
).toBe(false);
|
|
117
|
+
expect(remote.skills.approve).not.toHaveBeenCalled();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('allows only metadata listing through the model command executor', async () => {
|
|
121
|
+
const [command] = createMCPActivationCommandModule().systemCommands ?? [];
|
|
122
|
+
const execute = vi.fn();
|
|
123
|
+
const executor = new SystemCommandExecutor([{ ...command!, execute }]);
|
|
124
|
+
for (const verb of ['skill-inspect', 'skill-approve', 'skill-withdraw'])
|
|
125
|
+
expect(
|
|
126
|
+
(await executor.executeModelInvocable('mcp', createTestCommandHost(), `${verb} docs`))
|
|
127
|
+
?.success,
|
|
128
|
+
).toBe(false);
|
|
129
|
+
expect(execute).not.toHaveBeenCalled();
|
|
130
|
+
await executor.executeModelInvocable('mcp', createTestCommandHost(), 'skill-list docs');
|
|
131
|
+
expect(execute).toHaveBeenCalledOnce();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('accepts JSON argument arrays for opaque names and URIs without shell evaluation', async () => {
|
|
135
|
+
const f = fixture('user');
|
|
136
|
+
const args = ['docs with spaces', 'skill://host/demo/SKILL.md?label=a b'];
|
|
137
|
+
expect(
|
|
138
|
+
(await executeMCPActivationCommand(f.host, `skill-inspect ${JSON.stringify(args)}`)).success,
|
|
139
|
+
).toBe(true);
|
|
140
|
+
expect(f.skills.inspect).toHaveBeenCalledWith(...args);
|
|
141
|
+
});
|
|
@@ -11,7 +11,8 @@ export function createMCPActivationCommandEntry(): ICommand {
|
|
|
11
11
|
"Show each MCP server's trust approval and OAuth sign-in state, or change one server: " +
|
|
12
12
|
'approve, reject or revoke its trust; sign in to it (login, which connects it in this ' +
|
|
13
13
|
'session) or out of it (logout). Use when an MCP server is not approved, needs a sign-in, or ' +
|
|
14
|
-
'should stop being used.
|
|
14
|
+
'should stop being used. List MCP Skills metadata, inspect exact instructions, approve their ' +
|
|
15
|
+
'content fingerprint or withdraw content consent separately from server trust. Returns the status list, verified preview, or what the action did.',
|
|
15
16
|
// Model-invocable for `status` only: when an MCP server's tools are missing or failing, the
|
|
16
17
|
// model can see why and name the command the user should run. Approving, rejecting and revoking
|
|
17
18
|
// a server are trust decisions and signing in or out is a credential action, so those stay
|
|
@@ -21,7 +22,11 @@ export function createMCPActivationCommandEntry(): ICommand {
|
|
|
21
22
|
'server or its tools are missing, a tool call reports that a server needs sign-in or approval, ' +
|
|
22
23
|
'or the user asks about MCP servers. Returns one line per server: its name, activation state, ' +
|
|
23
24
|
'sign-in state, and the command to suggest to the user when the server needs their approval, ' +
|
|
24
|
-
'sign-in or workspace trust.
|
|
25
|
+
'sign-in or workspace trust. Use skill-list <server> to discover advertised skill names, URIs ' +
|
|
26
|
+
'and descriptions without loading instructions. Returns an invocation name for each supported ' +
|
|
27
|
+
'skill; use the session skill activation command with that exact name when its workflow fits ' +
|
|
28
|
+
'the task. If content consent is needed, ask the user to ' +
|
|
29
|
+
'run /mcp skill-inspect <server> <uri> and approve the reviewed fingerprint. You cannot approve, inspect instructions, sign in or sign out yourself.',
|
|
25
30
|
argumentHint:
|
|
26
31
|
'[status] | <approve|reject|revoke|logout> <server> | login <server> [--no-browser]',
|
|
27
32
|
source: 'mcp-activation',
|
|
@@ -35,6 +40,26 @@ export function createMCPActivationCommandEntry(): ICommand {
|
|
|
35
40
|
source: 'mcp-activation',
|
|
36
41
|
modelInvocable: true,
|
|
37
42
|
},
|
|
43
|
+
{
|
|
44
|
+
name: 'skill-list',
|
|
45
|
+
description:
|
|
46
|
+
'Discover MCP skill metadata and exact invocation names, or reasons they are unavailable, without activating instructions',
|
|
47
|
+
argumentHint: '<server>',
|
|
48
|
+
source: 'mcp-activation',
|
|
49
|
+
modelInvocable: true,
|
|
50
|
+
},
|
|
51
|
+
...(['skill-inspect', 'skill-approve', 'skill-withdraw'] as const).map((verb) => ({
|
|
52
|
+
name: verb,
|
|
53
|
+
description:
|
|
54
|
+
verb === 'skill-inspect'
|
|
55
|
+
? 'Review verified MCP skill instructions and frontmatter without activation'
|
|
56
|
+
: verb === 'skill-approve'
|
|
57
|
+
? 'Grant consent for an exactly reviewed MCP skill content fingerprint'
|
|
58
|
+
: 'Withdraw MCP skill content consent',
|
|
59
|
+
argumentHint: verb === 'skill-approve' ? '<server> <uri> <fingerprint>' : '<server> <uri>',
|
|
60
|
+
source: 'mcp-activation',
|
|
61
|
+
modelInvocable: false,
|
|
62
|
+
})),
|
|
38
63
|
{
|
|
39
64
|
name: 'reload',
|
|
40
65
|
description: 'Retry connecting every MCP server that is not currently connected',
|
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
ICommandMCPOAuthStatus,
|
|
16
16
|
ICommandMCPSourceProblem,
|
|
17
17
|
} from '@robota-sdk/agent-framework';
|
|
18
|
+
import type { ICommandProductVocabulary } from '@robota-sdk/agent-framework';
|
|
18
19
|
import type { ICommandResult } from '@robota-sdk/agent-interface-command';
|
|
19
20
|
|
|
20
21
|
function adapter(context: ICommandHostAdapterAccess): ICommandMCPActivationAdapter | undefined {
|
|
@@ -66,9 +67,13 @@ function serverArgument(serverId: string): string {
|
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
/** How to sign in to one server: in this session, or from a terminal. */
|
|
69
|
-
function signInHint(serverId: string): string {
|
|
70
|
+
function signInHint(serverId: string, cliName?: string): string {
|
|
70
71
|
const argument = serverArgument(serverId);
|
|
71
|
-
const
|
|
72
|
+
const terminal =
|
|
73
|
+
cliName === undefined
|
|
74
|
+
? 'sign in from a terminal'
|
|
75
|
+
: `run ${cliName} mcp login ${argument} in a terminal`;
|
|
76
|
+
const how = `run /mcp login ${argument}, or ${terminal}`;
|
|
72
77
|
return shellArgumentForDisplay(serverId) === undefined
|
|
73
78
|
? ` (${how}; its name cannot be shown safely here)`
|
|
74
79
|
: ` (${how})`;
|
|
@@ -77,13 +82,14 @@ function signInHint(serverId: string): string {
|
|
|
77
82
|
function formatSummary(
|
|
78
83
|
summary: ICommandMCPActivationSummary,
|
|
79
84
|
oauth: ICommandMCPOAuthStatus['state'] | undefined,
|
|
85
|
+
cliName?: string,
|
|
80
86
|
): string {
|
|
81
87
|
const label = summary.displayName ? ` (${summary.displayName})` : '';
|
|
82
88
|
// A fixed word per state: nothing token-derived ever reaches this line.
|
|
83
89
|
const signIn =
|
|
84
90
|
oauth === undefined
|
|
85
91
|
? ''
|
|
86
|
-
: ` — OAuth: ${OAUTH_STATE_LABEL[oauth]}${oauth === 'sign-in-required' || oauth === 'signed-out' ? signInHint(summary.serverId) : ''}`;
|
|
92
|
+
: ` — OAuth: ${OAUTH_STATE_LABEL[oauth]}${oauth === 'sign-in-required' || oauth === 'signed-out' ? signInHint(summary.serverId, cliName) : ''}`;
|
|
87
93
|
return ` ${summary.serverId}${label} — ${summary.status} — ${summary.source} — ${summary.reason}${signIn}`;
|
|
88
94
|
}
|
|
89
95
|
|
|
@@ -108,7 +114,10 @@ async function oauthStates(
|
|
|
108
114
|
return new Map(states.map((status) => [status.serverId, status.state]));
|
|
109
115
|
}
|
|
110
116
|
|
|
111
|
-
async function listResult(
|
|
117
|
+
async function listResult(
|
|
118
|
+
mcp: ICommandMCPActivationAdapter | undefined,
|
|
119
|
+
cliName?: string,
|
|
120
|
+
): Promise<ICommandResult> {
|
|
112
121
|
if (!mcp) {
|
|
113
122
|
return {
|
|
114
123
|
message: 'MCP activation management is not available in this environment.',
|
|
@@ -139,7 +148,7 @@ async function listResult(mcp: ICommandMCPActivationAdapter | undefined): Promis
|
|
|
139
148
|
return {
|
|
140
149
|
message: [
|
|
141
150
|
`MCP activation status:\n${entries
|
|
142
|
-
.map((entry) => formatSummary(entry, oauth.get(entry.serverId)))
|
|
151
|
+
.map((entry) => formatSummary(entry, oauth.get(entry.serverId), cliName))
|
|
143
152
|
.join('\n')}`,
|
|
144
153
|
...sourceProblemLines,
|
|
145
154
|
].join('\n\n'),
|
|
@@ -171,7 +180,10 @@ async function listResult(mcp: ICommandMCPActivationAdapter | undefined): Promis
|
|
|
171
180
|
export type TMCPActivationCommandContext = ICommandHostAdapterAccess &
|
|
172
181
|
Pick<ICommandHostSessionAccess, 'getSession'> &
|
|
173
182
|
ICommandHostUserInteraction &
|
|
174
|
-
Partial<Pick<ICommandHostWorkspace, 'getCommandInvocationSource'
|
|
183
|
+
Partial<Pick<ICommandHostWorkspace, 'getCommandInvocationSource'>> & {
|
|
184
|
+
getCommandSurfaceLocalityEvidence?(): 'local' | 'remote' | undefined;
|
|
185
|
+
getCommandProductVocabulary?(): ICommandProductVocabulary | undefined;
|
|
186
|
+
};
|
|
175
187
|
|
|
176
188
|
export async function executeMCPActivationCommand(
|
|
177
189
|
context: TMCPActivationCommandContext,
|
|
@@ -179,15 +191,18 @@ export async function executeMCPActivationCommand(
|
|
|
179
191
|
): Promise<ICommandResult> {
|
|
180
192
|
const trimmed = args.trim();
|
|
181
193
|
const spaceAt = trimmed.indexOf(' ');
|
|
194
|
+
const cliName = context.getCommandProductVocabulary?.()?.cliName;
|
|
182
195
|
const verb = (spaceAt === -1 ? trimmed : trimmed.slice(0, spaceAt)).toLowerCase();
|
|
183
196
|
const serverId = spaceAt === -1 ? '' : trimmed.slice(spaceAt + 1).trim();
|
|
184
197
|
|
|
198
|
+
if (verb.startsWith('skill-')) return skillResult(context, verb, serverId);
|
|
199
|
+
|
|
185
200
|
if (verb === '' || verb === 'status' || verb === 'list') {
|
|
186
201
|
// The full view only for a caller known to be a person; an unknown caller gets the model's view.
|
|
187
202
|
const source = context.getCommandInvocationSource?.();
|
|
188
203
|
return source === 'user' || source === 'remote'
|
|
189
|
-
? listResult(adapter(context))
|
|
190
|
-
: mcpModelStatusResult(adapter(context));
|
|
204
|
+
? listResult(adapter(context), cliName)
|
|
205
|
+
: mcpModelStatusResult(adapter(context), cliName);
|
|
191
206
|
}
|
|
192
207
|
if (verb === 'login') return loginResult(context, serverId);
|
|
193
208
|
if (verb === 'reload') return reloadResult(context);
|
|
@@ -253,6 +268,115 @@ export async function executeMCPActivationCommand(
|
|
|
253
268
|
}
|
|
254
269
|
}
|
|
255
270
|
|
|
271
|
+
/** JSON arrays preserve opaque identities; ordinary whitespace-separated arguments remain convenient. */
|
|
272
|
+
function skillArguments(args: string): string[] | undefined {
|
|
273
|
+
if (!args.startsWith('[')) return args.split(/\s+/).filter(Boolean);
|
|
274
|
+
try {
|
|
275
|
+
const parsed: unknown = JSON.parse(args);
|
|
276
|
+
return Array.isArray(parsed) && parsed.every((value) => typeof value === 'string')
|
|
277
|
+
? parsed
|
|
278
|
+
: undefined;
|
|
279
|
+
} catch {
|
|
280
|
+
return undefined;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function skillResult(
|
|
285
|
+
context: TMCPActivationCommandContext,
|
|
286
|
+
verb: string,
|
|
287
|
+
rest: string,
|
|
288
|
+
): Promise<ICommandResult> {
|
|
289
|
+
const source = context.getCommandInvocationSource?.();
|
|
290
|
+
const localUser =
|
|
291
|
+
source === 'user' ||
|
|
292
|
+
(source === 'remote' && context.getCommandSurfaceLocalityEvidence?.() === 'local');
|
|
293
|
+
if (verb !== 'skill-list' && !localUser)
|
|
294
|
+
return {
|
|
295
|
+
success: false,
|
|
296
|
+
message: 'Only a known local user can inspect, approve or withdraw MCP skill instructions.',
|
|
297
|
+
};
|
|
298
|
+
const words = skillArguments(rest);
|
|
299
|
+
const count = verb === 'skill-list' ? 1 : verb === 'skill-approve' ? 3 : 2;
|
|
300
|
+
if (
|
|
301
|
+
!['skill-list', 'skill-inspect', 'skill-approve', 'skill-withdraw'].includes(verb) ||
|
|
302
|
+
!words ||
|
|
303
|
+
words.length !== count ||
|
|
304
|
+
words.some((word) => word.length === 0)
|
|
305
|
+
)
|
|
306
|
+
return {
|
|
307
|
+
success: false,
|
|
308
|
+
message:
|
|
309
|
+
'Usage: /mcp skill-list <server> | skill-inspect <server> <uri> | skill-approve <server> <uri> <fingerprint> | skill-withdraw <server> <uri>. JSON string arrays also accept opaque arguments.',
|
|
310
|
+
};
|
|
311
|
+
const skills = adapter(context)?.skills;
|
|
312
|
+
if (!skills)
|
|
313
|
+
return { success: false, message: 'MCP Skills are not available in this environment.' };
|
|
314
|
+
const serverId = words[0]!;
|
|
315
|
+
const uri = words[1]!;
|
|
316
|
+
try {
|
|
317
|
+
if (verb === 'skill-list') {
|
|
318
|
+
const entries = await skills.list(serverId);
|
|
319
|
+
return {
|
|
320
|
+
success: true,
|
|
321
|
+
message:
|
|
322
|
+
entries.length === 0
|
|
323
|
+
? 'No MCP skills are advertised.'
|
|
324
|
+
: entries
|
|
325
|
+
.map(
|
|
326
|
+
(entry) =>
|
|
327
|
+
`${entry.name} — ${entry.uri}\n${entry.description}` +
|
|
328
|
+
(entry.invocationName ? `\nInvoke skill: ${entry.invocationName}` : '') +
|
|
329
|
+
(entry.unavailableReason ? `\nUnavailable: ${entry.unavailableReason}` : ''),
|
|
330
|
+
)
|
|
331
|
+
.join('\n\n'),
|
|
332
|
+
data: {
|
|
333
|
+
skills: entries.map((entry) => ({
|
|
334
|
+
serverId: entry.serverId,
|
|
335
|
+
uri: entry.uri,
|
|
336
|
+
name: entry.name,
|
|
337
|
+
description: entry.description,
|
|
338
|
+
...(entry.invocationName ? { invocationName: entry.invocationName } : {}),
|
|
339
|
+
...(entry.unavailableReason ? { unavailableReason: entry.unavailableReason } : {}),
|
|
340
|
+
})),
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
if (verb === 'skill-withdraw') {
|
|
345
|
+
skills.withdraw(serverId, uri, 'user');
|
|
346
|
+
return { success: true, message: 'MCP skill content consent withdrawn.' };
|
|
347
|
+
}
|
|
348
|
+
const preview =
|
|
349
|
+
verb === 'skill-approve'
|
|
350
|
+
? await skills.approve(serverId, uri, words[2]!, 'user')
|
|
351
|
+
: await skills.inspect(serverId, uri);
|
|
352
|
+
const args = [serverId, uri, preview.fingerprint];
|
|
353
|
+
const approveArgs = args.every((arg) => /^[\w:./%-]+$/.test(arg))
|
|
354
|
+
? args.join(' ')
|
|
355
|
+
: JSON.stringify(args);
|
|
356
|
+
return {
|
|
357
|
+
success: true,
|
|
358
|
+
message:
|
|
359
|
+
verb === 'skill-approve'
|
|
360
|
+
? 'MCP skill content consent saved; activation remains a separate action.'
|
|
361
|
+
: `${preview.content}\n\nFrontmatter: ${JSON.stringify(preview.frontmatter)}\n\nApprove exactly this content: /mcp skill-approve ${approveArgs}`,
|
|
362
|
+
data: {
|
|
363
|
+
serverId: preview.serverId,
|
|
364
|
+
uri: preview.uri,
|
|
365
|
+
namespace: preview.namespace,
|
|
366
|
+
fingerprint: preview.fingerprint,
|
|
367
|
+
frontmatter: preview.frontmatter,
|
|
368
|
+
...(verb === 'skill-inspect' ? { content: preview.content } : {}),
|
|
369
|
+
},
|
|
370
|
+
};
|
|
371
|
+
} catch {
|
|
372
|
+
return {
|
|
373
|
+
success: false,
|
|
374
|
+
message:
|
|
375
|
+
'MCP skill request refused. Check /mcp status; run /mcp skill-inspect <server> <uri> before granting content consent.',
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
256
380
|
async function logoutResult(
|
|
257
381
|
mcp: ICommandMCPActivationAdapter,
|
|
258
382
|
serverId: string,
|
|
@@ -389,7 +513,7 @@ function redirectReader(
|
|
|
389
513
|
}
|
|
390
514
|
|
|
391
515
|
/** Why a sign-in did not complete, and what to run instead, by fixed words only. */
|
|
392
|
-
function loginFailureText(result: ICommandMCPOAuthLoginResult): string {
|
|
516
|
+
function loginFailureText(result: ICommandMCPOAuthLoginResult, cliName?: string): string {
|
|
393
517
|
const { serverId, failure } = result;
|
|
394
518
|
const argument = serverArgument(serverId);
|
|
395
519
|
switch (failure) {
|
|
@@ -400,9 +524,12 @@ function loginFailureText(result: ICommandMCPOAuthLoginResult): string {
|
|
|
400
524
|
case 'sign-in-in-progress':
|
|
401
525
|
return `A sign-in to MCP server ${serverId} is already in progress.`;
|
|
402
526
|
case 'prompt-unavailable':
|
|
527
|
+
if (cliName === undefined) {
|
|
528
|
+
return 'Signing in without a browser needs a redirect URL pasted in a terminal, but no product command name is available in this session.';
|
|
529
|
+
}
|
|
403
530
|
return (
|
|
404
531
|
'Signing in without a browser needs the redirect URL pasted here, and nothing here can ask ' +
|
|
405
|
-
`for it; run
|
|
532
|
+
`for it; run ${cliName} mcp login ${argument} --no-browser in a terminal.`
|
|
406
533
|
);
|
|
407
534
|
case 'browser-failed':
|
|
408
535
|
return (
|
|
@@ -412,7 +539,9 @@ function loginFailureText(result: ICommandMCPOAuthLoginResult): string {
|
|
|
412
539
|
default: {
|
|
413
540
|
const secret =
|
|
414
541
|
failure === 'token-exchange-failed' && result.preRegisteredClient
|
|
415
|
-
?
|
|
542
|
+
? cliName === undefined
|
|
543
|
+
? ' If its pre-registered client needs a secret, sign in from a terminal configured for this product.'
|
|
544
|
+
: ` If its pre-registered client needs a secret, run ${cliName} mcp login ${argument} --client-secret in a terminal.`
|
|
416
545
|
: '';
|
|
417
546
|
return `Sign-in to MCP server ${serverId} failed (${failure ?? 'unexpected-error'}); nothing was changed.${secret}`;
|
|
418
547
|
}
|
|
@@ -443,6 +572,7 @@ async function loginResult(
|
|
|
443
572
|
context: TMCPActivationCommandContext,
|
|
444
573
|
rest: string,
|
|
445
574
|
): Promise<ICommandResult> {
|
|
575
|
+
const cliName = context.getCommandProductVocabulary?.()?.cliName;
|
|
446
576
|
const words = rest.split(/\s+/).filter((word) => word !== '');
|
|
447
577
|
const noBrowser = words.includes('--no-browser');
|
|
448
578
|
const withSecret = words.includes('--client-secret');
|
|
@@ -456,8 +586,9 @@ async function loginResult(
|
|
|
456
586
|
// A secret typed here would become part of the conversation; it is asked for only in a terminal.
|
|
457
587
|
return {
|
|
458
588
|
message:
|
|
459
|
-
|
|
460
|
-
|
|
589
|
+
cliName === undefined
|
|
590
|
+
? 'A client secret is never typed into a session. Sign in from a terminal configured for this product.'
|
|
591
|
+
: `A client secret is never typed into a session. Run ${cliName} mcp login ${argument} --client-secret in a terminal.`,
|
|
461
592
|
success: false,
|
|
462
593
|
};
|
|
463
594
|
}
|
|
@@ -470,7 +601,10 @@ async function loginResult(
|
|
|
470
601
|
}
|
|
471
602
|
if (mcp.oauthLogin === undefined) {
|
|
472
603
|
return {
|
|
473
|
-
message:
|
|
604
|
+
message:
|
|
605
|
+
cliName === undefined
|
|
606
|
+
? 'Signing in is not available in this session; sign in from a terminal configured for this product.'
|
|
607
|
+
: `Signing in is not available in this session; run ${cliName} mcp login ${argument} in a terminal.`,
|
|
474
608
|
success: false,
|
|
475
609
|
};
|
|
476
610
|
}
|
|
@@ -503,7 +637,7 @@ async function loginResult(
|
|
|
503
637
|
});
|
|
504
638
|
if (result.failure !== undefined) {
|
|
505
639
|
return {
|
|
506
|
-
message: loginFailureText(result),
|
|
640
|
+
message: loginFailureText(result, cliName),
|
|
507
641
|
success: false,
|
|
508
642
|
data: { serverId, failure: result.failure },
|
|
509
643
|
};
|
|
@@ -26,15 +26,20 @@ export function mcpUserActionCommand(
|
|
|
26
26
|
serverId: string,
|
|
27
27
|
action: TMCPUserAction,
|
|
28
28
|
surface: TMCPUserActionSurface = 'session',
|
|
29
|
-
|
|
29
|
+
cliName?: string,
|
|
30
|
+
): string | undefined {
|
|
30
31
|
const name = shellArgumentForDisplay(serverId) ?? '<server>';
|
|
31
32
|
switch (action) {
|
|
32
33
|
case 'approve':
|
|
33
34
|
return `/mcp approve ${name}`;
|
|
34
35
|
case 'sign-in':
|
|
35
|
-
return surface === 'terminal'
|
|
36
|
+
return surface === 'terminal'
|
|
37
|
+
? cliName === undefined
|
|
38
|
+
? undefined
|
|
39
|
+
: `${cliName} mcp login ${name}`
|
|
40
|
+
: `/mcp login ${name}`;
|
|
36
41
|
case 'trust-workspace':
|
|
37
|
-
return
|
|
42
|
+
return cliName === undefined ? undefined : `${cliName} trust`;
|
|
38
43
|
}
|
|
39
44
|
}
|
|
40
45
|
|
|
@@ -56,13 +61,21 @@ export function mcpUserActionNotice(
|
|
|
56
61
|
serverId: string,
|
|
57
62
|
action: TMCPUserAction,
|
|
58
63
|
surface: TMCPUserActionSurface = 'session',
|
|
64
|
+
cliName?: string,
|
|
59
65
|
): string {
|
|
60
66
|
const inTerminal =
|
|
61
67
|
action === 'trust-workspace' || (action === 'sign-in' && surface === 'terminal');
|
|
62
68
|
const where = inTerminal ? ' in a terminal, then restart the session' : '';
|
|
69
|
+
const command = mcpUserActionCommand(serverId, action, surface, cliName);
|
|
70
|
+
const ask =
|
|
71
|
+
command === undefined
|
|
72
|
+
? action === 'trust-workspace'
|
|
73
|
+
? 'ask the user to trust this workspace in a terminal'
|
|
74
|
+
: 'ask the user to sign in from a terminal'
|
|
75
|
+
: `ask the user to run \`${command}\`${where}`;
|
|
63
76
|
return (
|
|
64
77
|
`${subject(serverId)} ${NEED[action]}, so its tools are unavailable. You cannot do this ` +
|
|
65
|
-
`yourself; ask
|
|
78
|
+
`yourself; ${ask}.`
|
|
66
79
|
);
|
|
67
80
|
}
|
|
68
81
|
|
|
@@ -72,10 +85,11 @@ export function mcpUserActionNotice(
|
|
|
72
85
|
*/
|
|
73
86
|
export function mcpUnavailableServersNotice(
|
|
74
87
|
servers: ReadonlyMap<string, TMCPUserAction>,
|
|
88
|
+
cliName?: string,
|
|
75
89
|
): string | undefined {
|
|
76
90
|
if (servers.size === 0) return undefined;
|
|
77
91
|
const lines = [...servers].map(
|
|
78
|
-
([serverId, action]) => `- ${mcpUserActionNotice(serverId, action)}`,
|
|
92
|
+
([serverId, action]) => `- ${mcpUserActionNotice(serverId, action, 'session', cliName)}`,
|
|
79
93
|
);
|
|
80
94
|
return [
|
|
81
95
|
'MCP servers that did not start this session. Mention this only when the user needs one of ' +
|
|
@@ -46,6 +46,7 @@ function toModelView(
|
|
|
46
46
|
summary: ICommandMCPActivationSummary,
|
|
47
47
|
oauth: TOAuthState | undefined,
|
|
48
48
|
surface: TMCPUserActionSurface,
|
|
49
|
+
cliName?: string,
|
|
49
50
|
): IModelServerView {
|
|
50
51
|
const name = shellArgumentForDisplay(summary.serverId);
|
|
51
52
|
const action = userActionFor(summary.status, oauth);
|
|
@@ -55,7 +56,10 @@ function toModelView(
|
|
|
55
56
|
...(oauth === undefined ? {} : { oauth }),
|
|
56
57
|
...(action === undefined
|
|
57
58
|
? {}
|
|
58
|
-
:
|
|
59
|
+
: (() => {
|
|
60
|
+
const suggestCommand = mcpUserActionCommand(summary.serverId, action, surface, cliName);
|
|
61
|
+
return suggestCommand === undefined ? {} : { suggestCommand };
|
|
62
|
+
})()),
|
|
59
63
|
};
|
|
60
64
|
}
|
|
61
65
|
|
|
@@ -69,6 +73,7 @@ function formatLine(view: IModelServerView): string {
|
|
|
69
73
|
|
|
70
74
|
export async function mcpModelStatusResult(
|
|
71
75
|
mcp: ICommandMCPActivationAdapter | undefined,
|
|
76
|
+
cliName?: string,
|
|
72
77
|
): Promise<ICommandResult> {
|
|
73
78
|
if (!mcp) {
|
|
74
79
|
return { message: 'MCP is not available in this environment.', success: true };
|
|
@@ -79,7 +84,7 @@ export async function mcpModelStatusResult(
|
|
|
79
84
|
const servers = mcp
|
|
80
85
|
.list()
|
|
81
86
|
.map((summary) =>
|
|
82
|
-
toModelView(summary, oauthStates.get(summary.serverId), mcp.userActionSurface ?? 'session'),
|
|
87
|
+
toModelView(summary, oauthStates.get(summary.serverId), mcp.userActionSurface ?? 'session', cliName),
|
|
83
88
|
);
|
|
84
89
|
const unreadableSources = (mcp.sourceProblems?.() ?? []).length;
|
|
85
90
|
const lines =
|