@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.
Files changed (62) hide show
  1. package/CHANGELOG.md +64 -12
  2. package/README.md +6 -3
  3. package/dist/node/index.cjs +72 -70
  4. package/dist/node/index.d.cts +16 -8
  5. package/dist/node/index.d.cts.map +1 -1
  6. package/dist/node/index.d.ts +16 -8
  7. package/dist/node/index.d.ts.map +1 -1
  8. package/dist/node/index.js +51 -49
  9. package/dist/node/index.js.map +1 -1
  10. package/package.json +15 -13
  11. package/src/agent/__tests__/agent-command.test.ts +5 -5
  12. package/src/agent/agent-command-module.ts +1 -1
  13. package/src/background/__tests__/background-command-module.test.ts +6 -6
  14. package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +15 -4
  15. package/src/default/__tests__/default-command-modules.test.ts +2 -2
  16. package/src/default/__tests__/model-exposure.test.ts +18 -5
  17. package/src/default/__tests__/org-policy-forwarding.test.ts +1 -1
  18. package/src/default/default-command-modules.ts +2 -2
  19. package/src/devices/devices-command-module.ts +1 -1
  20. package/src/doctor/__tests__/doctor-command-module.test.ts +10 -10
  21. package/src/doctor/__tests__/doctor-display-vocabulary.test.ts +1 -1
  22. package/src/doctor/__tests__/doctor-fixture.ts +14 -14
  23. package/src/doctor/__tests__/doctor-runner.test.ts +57 -57
  24. package/src/doctor/doctor-storage-probe.ts +1 -1
  25. package/src/doctor/doctor-types.ts +2 -2
  26. package/src/editor/__tests__/editor-command-functional.test.ts +1 -1
  27. package/src/fork/__tests__/fork-command.test.ts +1 -1
  28. package/src/fork/fork-command-module.ts +1 -1
  29. package/src/git/__tests__/git-command-module.test.ts +1 -1
  30. package/src/handoff/__tests__/handoff-command-module.test.ts +1 -1
  31. package/src/handoff/handoff-command-module.ts +1 -1
  32. package/src/keybindings/keybindings-command-module.ts +1 -1
  33. package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +8 -8
  34. package/src/mcp-activation/__tests__/mcp-model-view.test.ts +43 -12
  35. package/src/mcp-activation/__tests__/mcp-skill-command.test.ts +141 -0
  36. package/src/mcp-activation/mcp-activation-command-module.ts +27 -2
  37. package/src/mcp-activation/mcp-activation-command.ts +149 -15
  38. package/src/mcp-activation/mcp-model-notice.ts +19 -5
  39. package/src/mcp-activation/mcp-model-status.ts +7 -2
  40. package/src/memory/__tests__/memory-command-module.test.ts +89 -16
  41. package/src/memory/memory-command-module.ts +4 -1
  42. package/src/memory/memory-command.ts +53 -1
  43. package/src/peers/__tests__/peers-command-module.test.ts +1 -1
  44. package/src/peers/peers-command-module.ts +1 -1
  45. package/src/permissions/__tests__/permissions-command-module.test.ts +4 -4
  46. package/src/plugin/__tests__/plugin-active-turn.test.ts +100 -0
  47. package/src/plugin/__tests__/plugin-command-module.test.ts +7 -7
  48. package/src/plugin/plugin-command-module.ts +4 -0
  49. package/src/provider/provider-startup.ts +4 -2
  50. package/src/remote-control/__tests__/remote-control-command.test.ts +2 -2
  51. package/src/rewind/__tests__/rewind-command-module.test.ts +4 -3
  52. package/src/rewind/rewind-command.ts +27 -21
  53. package/src/schedule/schedule-command-module.ts +1 -1
  54. package/src/session/__tests__/session-command-module.test.ts +2 -2
  55. package/src/skills/__tests__/skill-read-command.test.ts +64 -0
  56. package/src/skills/__tests__/skills-command-module.test.ts +2 -1
  57. package/src/skills/skill-read-command.ts +60 -0
  58. package/src/skills/skills-command-module.ts +3 -2
  59. package/src/terminal-client/__tests__/terminal-client-commands.test.ts +1 -1
  60. package/src/theme/__tests__/theme-command.test.ts +1 -1
  61. package/src/theme/theme-command-module.ts +1 -1
  62. 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. Returns the status list, or what the one action did.',
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. You cannot approve, sign in or sign out yourself.',
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 how = `run /mcp login ${argument}, or robota mcp login ${argument} in a terminal`;
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(mcp: ICommandMCPActivationAdapter | undefined): Promise<ICommandResult> {
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 robota mcp login ${argument} --no-browser in a terminal.`
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
- ? ` If its pre-registered client needs a secret, run robota mcp login ${argument} --client-secret in a terminal.`
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
- 'A client secret is never typed into a session. Run robota mcp login ' +
460
- `${argument} --client-secret in a terminal.`,
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: `Signing in is not available in this session; run robota mcp login ${argument} in a terminal.`,
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
- ): string {
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' ? `robota mcp login ${name}` : `/mcp login ${name}`;
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 'robota trust';
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 the user to run \`${mcpUserActionCommand(serverId, action, surface)}\`${where}.`
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
- : { suggestCommand: mcpUserActionCommand(summary.serverId, action, surface) }),
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 =