@robota-sdk/agent-command 3.0.0-beta.79 → 3.0.0-beta.82

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 (201) hide show
  1. package/README.md +11 -2
  2. package/dist/node/index.cjs +77 -38
  3. package/dist/node/index.d.cts +946 -0
  4. package/dist/node/index.d.cts.map +1 -0
  5. package/dist/node/index.d.ts +564 -51
  6. package/dist/node/index.d.ts.map +1 -1
  7. package/dist/node/index.js +77 -38
  8. package/dist/node/index.js.map +1 -1
  9. package/package.json +39 -16
  10. package/src/advisor/advisor-command-module.ts +47 -0
  11. package/src/advisor/advisor-command.test.ts +102 -0
  12. package/src/advisor/advisor-command.ts +55 -0
  13. package/src/advisor/advisor-cost-functional.test.ts +92 -0
  14. package/src/advisor/index.ts +6 -0
  15. package/src/agent/__tests__/agent-command.test.ts +39 -8
  16. package/src/agent/agent-command-module.ts +14 -7
  17. package/src/agent/agent-command-parser.ts +1 -1
  18. package/src/agent/agent-command.ts +3 -3
  19. package/src/background/__tests__/background-command-module.test.ts +59 -52
  20. package/src/background/background-command-module.ts +2 -1
  21. package/src/background/background-command.ts +3 -3
  22. package/src/command-module-utils.ts +33 -0
  23. package/src/compact/__tests__/compact-command-module.test.ts +31 -23
  24. package/src/compact/compact-command-module.ts +10 -1
  25. package/src/compact/compact-command.ts +6 -3
  26. package/src/context/__tests__/context-command-module.test.ts +214 -63
  27. package/src/context/auto-compact-format.ts +25 -0
  28. package/src/context/context-breakdown.ts +295 -0
  29. package/src/context/context-command-module.ts +45 -14
  30. package/src/context/context-command.ts +34 -257
  31. package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +110 -0
  32. package/src/default/__tests__/default-command-modules.test.ts +59 -7
  33. package/src/default/__tests__/model-exposure.test.ts +241 -0
  34. package/src/default/__tests__/org-policy-forwarding.test.ts +98 -0
  35. package/src/default/default-command-modules.ts +130 -18
  36. package/src/default/index.ts +4 -1
  37. package/src/devices/__tests__/devices-command-module.test.ts +212 -0
  38. package/src/devices/devices-command-module.ts +224 -0
  39. package/src/devices/devices-command-port.ts +82 -0
  40. package/src/devices/index.ts +11 -0
  41. package/src/doctor/__tests__/doctor-command-module.test.ts +145 -0
  42. package/src/doctor/__tests__/doctor-display-vocabulary.test.ts +38 -0
  43. package/src/doctor/__tests__/doctor-fixture.ts +145 -0
  44. package/src/doctor/__tests__/doctor-redaction.test.ts +29 -0
  45. package/src/doctor/__tests__/doctor-runner.test.ts +464 -0
  46. package/src/doctor/doctor-command-module.ts +132 -0
  47. package/src/doctor/doctor-extensions-probe.ts +299 -0
  48. package/src/doctor/doctor-node-deps.ts +89 -0
  49. package/src/doctor/doctor-redaction.ts +99 -0
  50. package/src/doctor/doctor-render.ts +70 -0
  51. package/src/doctor/doctor-repair.ts +136 -0
  52. package/src/doctor/doctor-runner.ts +122 -0
  53. package/src/doctor/doctor-settings-probe.ts +239 -0
  54. package/src/doctor/doctor-storage-probe.ts +187 -0
  55. package/src/doctor/doctor-types.ts +109 -0
  56. package/src/doctor/index.ts +32 -0
  57. package/src/editor/__tests__/editor-command-functional.test.ts +49 -16
  58. package/src/editor/editor-command-module.ts +6 -5
  59. package/src/editor/editor-command.ts +10 -5
  60. package/src/effort/effort-command-module.ts +51 -0
  61. package/src/effort/effort-command.test.ts +140 -0
  62. package/src/effort/effort-command.ts +122 -0
  63. package/src/effort/index.ts +6 -0
  64. package/src/exit/__tests__/exit-command-module.test.ts +16 -9
  65. package/src/exit/exit-command-module.ts +2 -1
  66. package/src/exit/exit-command.ts +6 -6
  67. package/src/fork/__tests__/fork-command.test.ts +184 -0
  68. package/src/fork/fork-command-module.ts +61 -0
  69. package/src/fork/fork-command.ts +162 -0
  70. package/src/fork/index.ts +8 -0
  71. package/src/git/__tests__/fake-git-port.ts +28 -0
  72. package/src/git/__tests__/git-command-module.test.ts +103 -0
  73. package/src/git/__tests__/git-commit.test.ts +185 -0
  74. package/src/git/__tests__/git-diff.test.ts +125 -0
  75. package/src/git/__tests__/git-process.test.ts +221 -0
  76. package/src/git/__tests__/git-status.test.ts +117 -0
  77. package/src/git/git-command-module.ts +139 -0
  78. package/src/git/git-commit.ts +251 -0
  79. package/src/git/git-diff.ts +129 -0
  80. package/src/git/git-process.ts +169 -0
  81. package/src/git/git-status.ts +134 -0
  82. package/src/git/index.ts +17 -0
  83. package/src/goal/__tests__/goal-command.test.ts +9 -4
  84. package/src/goal/goal-command-module.ts +2 -1
  85. package/src/goal/goal-command.ts +6 -9
  86. package/src/handoff/__tests__/handoff-command-module.test.ts +24 -0
  87. package/src/handoff/__tests__/handoff-command.test.ts +218 -0
  88. package/src/handoff/handoff-command-module.ts +49 -0
  89. package/src/handoff/handoff-command.ts +187 -0
  90. package/src/handoff/index.ts +6 -0
  91. package/src/help/__tests__/help-command-module.test.ts +63 -40
  92. package/src/help/__tests__/help-command.test.ts +46 -37
  93. package/src/help/help-command-module.ts +2 -1
  94. package/src/help/help-command.ts +3 -3
  95. package/src/index.ts +124 -2
  96. package/src/keybindings/__tests__/keybindings-command-module.test.ts +57 -0
  97. package/src/keybindings/index.ts +6 -0
  98. package/src/keybindings/keybindings-command-module.ts +74 -0
  99. package/src/language/__tests__/language-command-module.test.ts +33 -28
  100. package/src/language/language-command-module.ts +2 -1
  101. package/src/language/language-command.ts +8 -6
  102. package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +630 -0
  103. package/src/mcp-activation/__tests__/mcp-model-view.test.ts +211 -0
  104. package/src/mcp-activation/index.ts +12 -0
  105. package/src/mcp-activation/mcp-activation-command-module.ts +92 -0
  106. package/src/mcp-activation/mcp-activation-command.ts +448 -0
  107. package/src/mcp-activation/mcp-model-notice.ts +85 -0
  108. package/src/mcp-activation/mcp-model-status.ts +99 -0
  109. package/src/memory/__tests__/memory-command-module.test.ts +167 -74
  110. package/src/memory/memory-command-module.ts +18 -1
  111. package/src/memory/memory-command.ts +38 -38
  112. package/src/mode/__tests__/mode-command-module.test.ts +51 -38
  113. package/src/mode/mode-command-module.ts +2 -1
  114. package/src/mode/mode-command.ts +15 -7
  115. package/src/output-style/__tests__/output-style-command.test.ts +96 -0
  116. package/src/output-style/index.ts +6 -0
  117. package/src/output-style/output-style-command-module.ts +143 -0
  118. package/src/peers/__tests__/peers-command.test.ts +321 -0
  119. package/src/peers/index.ts +9 -0
  120. package/src/peers/peers-command-module.ts +49 -0
  121. package/src/peers/peers-command.ts +191 -0
  122. package/src/permissions/__tests__/permissions-command-module.test.ts +149 -37
  123. package/src/permissions/permissions-command-module.ts +2 -1
  124. package/src/permissions/permissions-command.ts +46 -4
  125. package/src/plan/__tests__/plan-command.test.ts +129 -0
  126. package/src/plan/index.ts +9 -0
  127. package/src/plan/plan-command-module.ts +49 -0
  128. package/src/plan/plan-command.ts +92 -0
  129. package/src/plugin/__tests__/plugin-command-module.test.ts +46 -40
  130. package/src/plugin/__tests__/plugin-host-boundary.test.ts +11 -0
  131. package/src/plugin/plugin-command-module.ts +3 -1
  132. package/src/plugin/plugin-command.ts +17 -16
  133. package/src/preset/__tests__/host-supplied-preset-registry.test.ts +98 -0
  134. package/src/preset/__tests__/preset-command-module.test.ts +102 -56
  135. package/src/preset/preset-command-module.ts +26 -8
  136. package/src/preset/preset-command.ts +85 -27
  137. package/src/provider/__tests__/org-policy.test.ts +4 -5
  138. package/src/provider/__tests__/provider-command-module.test.ts +11 -14
  139. package/src/provider/__tests__/provider-startup-host-settings.test.ts +16 -0
  140. package/src/provider/__tests__/scripted-interaction.ts +7 -5
  141. package/src/provider/provider-command-execution.ts +4 -4
  142. package/src/provider/provider-command-module.ts +2 -1
  143. package/src/provider/provider-command-profile-lifecycle.ts +4 -4
  144. package/src/provider/provider-command-profile-operations.ts +3 -3
  145. package/src/provider/provider-command-profile.ts +1 -1
  146. package/src/provider/provider-command-setup.ts +4 -4
  147. package/src/provider/provider-startup.ts +62 -35
  148. package/src/remote-control/__tests__/remote-control-command-module.test.ts +36 -0
  149. package/src/remote-control/__tests__/remote-control-command.test.ts +216 -0
  150. package/src/remote-control/index.ts +8 -0
  151. package/src/remote-control/remote-control-command-module.ts +61 -0
  152. package/src/remote-control/remote-control-command.ts +138 -0
  153. package/src/reset/__tests__/reset-command-module.test.ts +5 -5
  154. package/src/reset/reset-command-module.ts +2 -1
  155. package/src/reset/reset-command.ts +3 -4
  156. package/src/rewind/__tests__/rewind-command-module.test.ts +40 -0
  157. package/src/rewind/rewind-command-module.ts +2 -1
  158. package/src/rewind/rewind-command.ts +73 -6
  159. package/src/sandbox/__tests__/sandbox-command.test.ts +80 -0
  160. package/src/sandbox/index.ts +2 -0
  161. package/src/sandbox/sandbox-command-module.ts +56 -0
  162. package/src/sandbox/sandbox-command.ts +94 -0
  163. package/src/schedule/__tests__/loop-command-functional.test.ts +386 -0
  164. package/src/schedule/__tests__/loop-command.test.ts +360 -0
  165. package/src/schedule/__tests__/loop-jitter.test.ts +25 -0
  166. package/src/schedule/__tests__/monitor-model-permission-functional.test.ts +126 -0
  167. package/src/schedule/__tests__/schedule-command.test.ts +143 -3
  168. package/src/schedule/__tests__/schedule-redos.test.ts +81 -0
  169. package/src/schedule/index.ts +3 -0
  170. package/src/schedule/loop-command.ts +333 -0
  171. package/src/schedule/loop-jitter.ts +41 -0
  172. package/src/schedule/schedule-command-module.ts +92 -14
  173. package/src/schedule/schedule-command.ts +98 -9
  174. package/src/schedule/schedule-spec-parser.ts +5 -1
  175. package/src/session/__tests__/rename-host-persistence.test.ts +72 -0
  176. package/src/session/__tests__/session-command-module.test.ts +210 -45
  177. package/src/session/session-command-module.ts +68 -37
  178. package/src/session/session-command.ts +147 -80
  179. package/src/settings/settings-command-module.ts +3 -2
  180. package/src/shell/__tests__/resolve-shell-host.test.ts +11 -0
  181. package/src/shell/__tests__/shell-command-functional.test.ts +23 -1
  182. package/src/shell/resolve-shell.ts +2 -2
  183. package/src/shell/shell-command-module.ts +6 -5
  184. package/src/shell/shell-command.ts +9 -5
  185. package/src/skills/__tests__/skills-command-module.test.ts +69 -28
  186. package/src/skills/index.ts +1 -0
  187. package/src/skills/skills-command-module.ts +22 -5
  188. package/src/skills/skills-command.ts +14 -13
  189. package/src/statusline/__tests__/statusline-command-module.test.ts +5 -4
  190. package/src/statusline/statusline-command-module.ts +2 -1
  191. package/src/statusline/statusline-command.ts +4 -4
  192. package/src/theme/__tests__/theme-command.test.ts +204 -0
  193. package/src/theme/index.ts +12 -0
  194. package/src/theme/theme-command-module.ts +211 -0
  195. package/src/user-local/__tests__/user-local-command.test.ts +145 -110
  196. package/src/user-local/user-local-command-constants.ts +1 -1
  197. package/src/user-local/user-local-command-module.ts +6 -5
  198. package/src/user-local/user-local-command.ts +12 -6
  199. package/src/user-local/user-local-memory-command.ts +17 -7
  200. package/src/plugins/default-plugin-command-adapter.ts +0 -164
  201. package/src/plugins/default-plugin-command-source-loader.ts +0 -31
@@ -0,0 +1,448 @@
1
+ import { shellArgumentForDisplay } from '@robota-sdk/agent-core';
2
+
3
+ import { mcpModelStatusResult } from './mcp-model-status.js';
4
+
5
+ import type {
6
+ ICommandHostAdapterAccess,
7
+ ICommandHostSessionAccess,
8
+ ICommandHostUserInteraction,
9
+ ICommandHostWorkspace,
10
+ ICommandMCPActivationAdapter,
11
+ ICommandMCPActivationSummary,
12
+ ICommandMCPOAuthLoginRequest,
13
+ ICommandMCPOAuthLoginResult,
14
+ ICommandMCPOAuthLogoutResult,
15
+ ICommandMCPOAuthStatus,
16
+ ICommandMCPSourceProblem,
17
+ } from '@robota-sdk/agent-framework';
18
+ import type { ICommandResult } from '@robota-sdk/agent-interface-command';
19
+
20
+ function adapter(context: ICommandHostAdapterAccess): ICommandMCPActivationAdapter | undefined {
21
+ return context.getCommandHostAdapters?.().mcpActivation;
22
+ }
23
+
24
+ const OAUTH_STATE_LABEL: Record<ICommandMCPOAuthStatus['state'], string> = {
25
+ 'signed-in': 'signed in',
26
+ 'expired-refreshable': 'token expired, will refresh',
27
+ 'sign-in-required': 'sign-in required',
28
+ 'signed-out': 'signed out',
29
+ };
30
+
31
+ const TOKEN_LABEL = { refresh_token: 'refresh token', access_token: 'access token' } as const;
32
+
33
+ /** What revocation did, by fixed words and reasons only. */
34
+ function revocationText(result: ICommandMCPOAuthLogoutResult): string {
35
+ const failure = result.revocationFailure ?? 'revocation-failed';
36
+ switch (result.revocation) {
37
+ case 'revoked':
38
+ return 'the tokens were revoked';
39
+ case 'not-attempted':
40
+ return 'there were no tokens to revoke';
41
+ case 'unsupported':
42
+ return 'the authorization server offers no token revocation; the tokens stay valid until they expire';
43
+ case 'failed':
44
+ return `token revocation failed (${failure}); the tokens stay valid until they expire`;
45
+ case 'partial':
46
+ return (result.tokens ?? [])
47
+ .map((token) =>
48
+ token.revoked
49
+ ? `the ${TOKEN_LABEL[token.token]} was revoked`
50
+ : `the ${TOKEN_LABEL[token.token]} was not (${token.failure ?? 'revocation-failed'}) and stays valid until it expires`,
51
+ )
52
+ .join('; ');
53
+ }
54
+ }
55
+
56
+ const USAGE =
57
+ 'Usage: /mcp [status] | /mcp <approve|reject|revoke|logout> <serverId> | /mcp login <serverId> [--no-browser]';
58
+ const LOGIN_USAGE = 'Usage: /mcp login <serverId> [--no-browser]';
59
+
60
+ /**
61
+ * A server's name as a command argument. It comes from a definition a repository may write, so it
62
+ * is shown only when it is safe to paste into any shell; otherwise `<server>` stands in for it.
63
+ */
64
+ function serverArgument(serverId: string): string {
65
+ return shellArgumentForDisplay(serverId) ?? '<server>';
66
+ }
67
+
68
+ /** How to sign in to one server: in this session, or from a terminal. */
69
+ function signInHint(serverId: string): string {
70
+ const argument = serverArgument(serverId);
71
+ const how = `run /mcp login ${argument}, or robota mcp login ${argument} in a terminal`;
72
+ return shellArgumentForDisplay(serverId) === undefined
73
+ ? ` (${how}; its name cannot be shown safely here)`
74
+ : ` (${how})`;
75
+ }
76
+
77
+ function formatSummary(
78
+ summary: ICommandMCPActivationSummary,
79
+ oauth: ICommandMCPOAuthStatus['state'] | undefined,
80
+ ): string {
81
+ const label = summary.displayName ? ` (${summary.displayName})` : '';
82
+ // A fixed word per state: nothing token-derived ever reaches this line.
83
+ const signIn =
84
+ oauth === undefined
85
+ ? ''
86
+ : ` — OAuth: ${OAUTH_STATE_LABEL[oauth]}${oauth === 'sign-in-required' || oauth === 'signed-out' ? signInHint(summary.serverId) : ''}`;
87
+ return ` ${summary.serverId}${label} — ${summary.status} — ${summary.source} — ${summary.reason}${signIn}`;
88
+ }
89
+
90
+ /**
91
+ * Issue #2794: a source that produced no server names at all — "which file could not be read".
92
+ *
93
+ * `blockedServerNames` (PR #3076 review): a managed-tier problem also blocked every lower-tier
94
+ * server that would otherwise have resolved — those names never appear in `list()` above (an
95
+ * `unresolved` entry is never an activation candidate), so this line is the ONLY place `/mcp status`
96
+ * says they exist at all and why they are not active.
97
+ */
98
+ function formatSourceProblem(problem: ICommandMCPSourceProblem): string {
99
+ const blocked = problem.blockedServerNames ?? [];
100
+ const blockedNote = blocked.length === 0 ? '' : ` Blocked until fixed: ${blocked.join(', ')}.`;
101
+ return ` ${problem.source} (${problem.origin}) could not be read: ${problem.reason}.${blockedNote}`;
102
+ }
103
+
104
+ async function oauthStates(
105
+ mcp: ICommandMCPActivationAdapter,
106
+ ): Promise<ReadonlyMap<string, ICommandMCPOAuthStatus['state']>> {
107
+ const states = (await mcp.oauthStatus?.()) ?? [];
108
+ return new Map(states.map((status) => [status.serverId, status.state]));
109
+ }
110
+
111
+ async function listResult(mcp: ICommandMCPActivationAdapter | undefined): Promise<ICommandResult> {
112
+ if (!mcp) {
113
+ return {
114
+ message: 'MCP activation management is not available in this environment.',
115
+ success: true,
116
+ };
117
+ }
118
+ const entries = mcp.list();
119
+ const oauth = await oauthStates(mcp);
120
+ // A source-scoped problem (issue #2794) names no server, so it never appears in `entries` — it is
121
+ // reported beside them rather than folded into the "no servers" branch, which would otherwise say
122
+ // nothing while a managed policy sits unreadable.
123
+ const sourceProblems = mcp.sourceProblems?.() ?? [];
124
+ const sourceProblemLines =
125
+ sourceProblems.length === 0
126
+ ? []
127
+ : [`MCP source problems:\n${sourceProblems.map(formatSourceProblem).join('\n')}`];
128
+
129
+ if (entries.length === 0) {
130
+ return {
131
+ message:
132
+ sourceProblemLines.length === 0
133
+ ? 'No MCP definitions are registered.'
134
+ : sourceProblemLines[0]!,
135
+ success: true,
136
+ data: { servers: [], sourceProblems },
137
+ };
138
+ }
139
+ return {
140
+ message: [
141
+ `MCP activation status:\n${entries
142
+ .map((entry) => formatSummary(entry, oauth.get(entry.serverId)))
143
+ .join('\n')}`,
144
+ ...sourceProblemLines,
145
+ ].join('\n\n'),
146
+ success: true,
147
+ data: {
148
+ servers: entries.map((entry) => ({
149
+ serverId: entry.serverId,
150
+ source: entry.source,
151
+ status: entry.status,
152
+ allowed: entry.allowed,
153
+ provenanceId: entry.provenanceId,
154
+ definitionFingerprint: entry.definitionFingerprint,
155
+ securityIdentity: entry.securityIdentity,
156
+ ...(oauth.has(entry.serverId) ? { oauth: oauth.get(entry.serverId) } : {}),
157
+ })),
158
+ sourceProblems,
159
+ },
160
+ };
161
+ }
162
+
163
+ /** What `/mcp` reaches: its port, and — to sign in — the live session and the user. */
164
+ export type TMCPActivationCommandContext = ICommandHostAdapterAccess &
165
+ Pick<ICommandHostSessionAccess, 'getSession'> &
166
+ ICommandHostUserInteraction &
167
+ Partial<Pick<ICommandHostWorkspace, 'getCommandInvocationSource'>>;
168
+
169
+ export async function executeMCPActivationCommand(
170
+ context: TMCPActivationCommandContext,
171
+ args: string,
172
+ ): Promise<ICommandResult> {
173
+ const trimmed = args.trim();
174
+ const spaceAt = trimmed.indexOf(' ');
175
+ const verb = (spaceAt === -1 ? trimmed : trimmed.slice(0, spaceAt)).toLowerCase();
176
+ const serverId = spaceAt === -1 ? '' : trimmed.slice(spaceAt + 1).trim();
177
+
178
+ if (verb === '' || verb === 'status' || verb === 'list') {
179
+ // The full view only for a caller known to be a person; an unknown caller gets the model's view.
180
+ const source = context.getCommandInvocationSource?.();
181
+ return source === 'user' || source === 'remote'
182
+ ? listResult(adapter(context))
183
+ : mcpModelStatusResult(adapter(context));
184
+ }
185
+ if (verb === 'login') return loginResult(context, serverId);
186
+
187
+ if (verb !== 'approve' && verb !== 'reject' && verb !== 'revoke' && verb !== 'logout') {
188
+ return { message: `Unknown argument. ${USAGE}`, success: false };
189
+ }
190
+ if (!serverId) {
191
+ return {
192
+ message: `Usage: /mcp ${verb} <serverId>`,
193
+ success: false,
194
+ };
195
+ }
196
+
197
+ const mcp = adapter(context);
198
+ if (!mcp) {
199
+ return {
200
+ message: 'MCP activation management is not available in this environment.',
201
+ success: true,
202
+ };
203
+ }
204
+ if (verb === 'logout') return logoutResult(mcp, serverId);
205
+
206
+ try {
207
+ const result = await mcp[verb](serverId);
208
+ return {
209
+ message: `MCP server ${serverId} is now ${result.status}. ${result.reason}`,
210
+ success:
211
+ result.status === 'approved' || result.status === 'rejected' || result.status === 'revoked',
212
+ data: {
213
+ serverId: result.serverId,
214
+ status: result.status,
215
+ allowed: result.allowed,
216
+ source: result.source,
217
+ provenanceId: result.provenanceId,
218
+ definitionFingerprint: result.definitionFingerprint,
219
+ securityIdentity: result.securityIdentity,
220
+ },
221
+ };
222
+ } catch (error) {
223
+ return {
224
+ message: error instanceof Error ? error.message : String(error),
225
+ success: false,
226
+ };
227
+ }
228
+ }
229
+
230
+ async function logoutResult(
231
+ mcp: ICommandMCPActivationAdapter,
232
+ serverId: string,
233
+ ): Promise<ICommandResult> {
234
+ if (mcp.oauthLogout === undefined || (await oauthStates(mcp)).get(serverId) === undefined) {
235
+ return { message: `MCP server ${serverId} does not declare OAuth.`, success: false };
236
+ }
237
+ let result: ICommandMCPOAuthLogoutResult;
238
+ try {
239
+ result = await mcp.oauthLogout(serverId);
240
+ } catch {
241
+ return { message: `Signing out of MCP server ${serverId} failed.`, success: false };
242
+ }
243
+ const signedOut = result.removed
244
+ ? `Signed out of MCP server ${serverId}`
245
+ : `MCP server ${serverId} was not signed in`;
246
+ return {
247
+ message: `${signedOut}; ${revocationText(result)}.`,
248
+ success: true,
249
+ data: { ...result },
250
+ };
251
+ }
252
+
253
+ let loginPrompts = 0;
254
+
255
+ /** Before the browser opens: the URL it will show, and the choice to paste or cancel instead. */
256
+ function browserConfirmer(
257
+ context: ICommandHostUserInteraction,
258
+ serverId: string,
259
+ ): ICommandMCPOAuthLoginRequest['confirmBrowser'] {
260
+ const ui = context.getUserInteraction();
261
+ if (ui === undefined) return undefined;
262
+ return async (prompt) => {
263
+ loginPrompts += 1;
264
+ const answer = await ui.ask({
265
+ id: `mcp-login-browser-${loginPrompts}`,
266
+ title: `Sign in to MCP server ${serverArgument(serverId)}`,
267
+ description:
268
+ `Your browser will open this URL; if it does not, open it yourself:\n${prompt.authorizationUrl}\n\n` +
269
+ 'The sign-in completes on its own once you approve in the browser.',
270
+ options: [
271
+ { value: 'open', label: 'Open my browser' },
272
+ { value: 'paste', label: 'Paste the redirect instead (browser on another machine)' },
273
+ { value: 'cancel', label: 'Cancel' },
274
+ ],
275
+ maxSelect: 1,
276
+ default: { values: ['open'] },
277
+ });
278
+ if (answer.type !== 'answer') return 'cancel';
279
+ const choice = answer.values[0];
280
+ return choice === 'open' || choice === 'paste' ? choice : 'cancel';
281
+ };
282
+ }
283
+
284
+ /** Asks, through the session's own prompt, for the redirect URL the user's browser was sent to. */
285
+ function redirectReader(
286
+ context: ICommandHostUserInteraction,
287
+ serverId: string,
288
+ ): ICommandMCPOAuthLoginRequest['readRedirect'] {
289
+ const ui = context.getUserInteraction();
290
+ if (ui === undefined) return undefined;
291
+ return async (prompt) => {
292
+ loginPrompts += 1;
293
+ const answer = await ui.ask({
294
+ id: `mcp-login-redirect-${loginPrompts}`,
295
+ title: `Sign in to MCP server ${serverArgument(serverId)}`,
296
+ description:
297
+ `Open this URL in a browser:\n${prompt.authorizationUrl}\n\n` +
298
+ `After you approve, the browser is sent to ${prompt.redirectUri}, which may not load. ` +
299
+ 'Copy the full address from its address bar and paste it here.',
300
+ allowFreeText: true,
301
+ masked: true,
302
+ placeholder: 'Redirect URL (not shown)',
303
+ });
304
+ if (answer.type !== 'answer' || answer.text === undefined || answer.text.trim() === '') {
305
+ throw new Error('No redirect URL was pasted.');
306
+ }
307
+ return answer.text;
308
+ };
309
+ }
310
+
311
+ /** Why a sign-in did not complete, and what to run instead, by fixed words only. */
312
+ function loginFailureText(result: ICommandMCPOAuthLoginResult): string {
313
+ const { serverId, failure } = result;
314
+ const argument = serverArgument(serverId);
315
+ switch (failure) {
316
+ case 'not-oauth':
317
+ return `MCP server ${serverId} does not declare OAuth; there is nothing to sign in to.`;
318
+ case 'url-unset':
319
+ return `MCP server ${serverId} has an unset variable in its url.`;
320
+ case 'sign-in-in-progress':
321
+ return `A sign-in to MCP server ${serverId} is already in progress.`;
322
+ case 'prompt-unavailable':
323
+ return (
324
+ 'Signing in without a browser needs the redirect URL pasted here, and nothing here can ask ' +
325
+ `for it; run robota mcp login ${argument} --no-browser in a terminal.`
326
+ );
327
+ case 'browser-failed':
328
+ return (
329
+ `Sign-in to MCP server ${serverId} failed (browser-failed): no browser could be opened. ` +
330
+ `Run /mcp login ${argument} --no-browser to paste the redirect instead.`
331
+ );
332
+ default: {
333
+ const secret =
334
+ failure === 'token-exchange-failed' && result.preRegisteredClient
335
+ ? ` If its pre-registered client needs a secret, run robota mcp login ${argument} --client-secret in a terminal.`
336
+ : '';
337
+ return `Sign-in to MCP server ${serverId} failed (${failure ?? 'unexpected-error'}); nothing was changed.${secret}`;
338
+ }
339
+ }
340
+ }
341
+
342
+ /** What a completed sign-in did to this session. */
343
+ function loginSuccessText(
344
+ serverId: string,
345
+ connection: ICommandMCPOAuthLoginResult['connection'],
346
+ added: number,
347
+ ): string {
348
+ switch (connection) {
349
+ case 'connected':
350
+ return added === 0
351
+ ? `Signed in to MCP server ${serverId}; it is connected and offers no new tools.`
352
+ : `Signed in to MCP server ${serverId}; ${added} of its tools ${added === 1 ? 'is' : 'are'} available from your next message.`;
353
+ case 'recovered':
354
+ return `Signed in to MCP server ${serverId}; its tools work again.`;
355
+ case 'not-admitted':
356
+ return `Signed in to MCP server ${serverId}, but it is not approved for this session (see /mcp status).`;
357
+ default:
358
+ return `Signed in to MCP server ${serverId}, but it could not connect in this session; the next session connects it.`;
359
+ }
360
+ }
361
+
362
+ async function loginResult(
363
+ context: TMCPActivationCommandContext,
364
+ rest: string,
365
+ ): Promise<ICommandResult> {
366
+ const words = rest.split(/\s+/).filter((word) => word !== '');
367
+ const noBrowser = words.includes('--no-browser');
368
+ const withSecret = words.includes('--client-secret');
369
+ const positional = words.filter((word) => word !== '--no-browser' && word !== '--client-secret');
370
+ const serverId = positional[0];
371
+ if (positional.length !== 1 || serverId === undefined || serverId.startsWith('-')) {
372
+ return { message: LOGIN_USAGE, success: false };
373
+ }
374
+ const argument = serverArgument(serverId);
375
+ if (withSecret) {
376
+ // A secret typed here would become part of the conversation; it is asked for only in a terminal.
377
+ return {
378
+ message:
379
+ 'A client secret is never typed into a session. Run robota mcp login ' +
380
+ `${argument} --client-secret in a terminal.`,
381
+ success: false,
382
+ };
383
+ }
384
+ const mcp = adapter(context);
385
+ if (!mcp) {
386
+ return {
387
+ message: 'MCP activation management is not available in this environment.',
388
+ success: true,
389
+ };
390
+ }
391
+ if (mcp.oauthLogin === undefined) {
392
+ return {
393
+ message: `Signing in is not available in this session; run robota mcp login ${argument} in a terminal.`,
394
+ success: false,
395
+ };
396
+ }
397
+ // A dismissed prompt cancels the sign-in itself, not only the read it was for.
398
+ const cancel = new AbortController();
399
+ const readRedirect = redirectReader(context, serverId);
400
+ const confirmBrowser = noBrowser ? undefined : browserConfirmer(context, serverId);
401
+ const result = await mcp.oauthLogin({
402
+ serverId,
403
+ noBrowser,
404
+ signal: cancel.signal,
405
+ ...(readRedirect === undefined
406
+ ? {}
407
+ : {
408
+ readRedirect: (prompt, signal) =>
409
+ readRedirect(prompt, signal).catch((error: unknown) => {
410
+ cancel.abort();
411
+ throw error;
412
+ }),
413
+ }),
414
+ ...(confirmBrowser === undefined
415
+ ? {}
416
+ : {
417
+ confirmBrowser: async (prompt, signal) => {
418
+ const choice = await confirmBrowser(prompt, signal);
419
+ if (choice === 'cancel') cancel.abort();
420
+ return choice;
421
+ },
422
+ }),
423
+ });
424
+ if (result.failure !== undefined) {
425
+ return {
426
+ message: loginFailureText(result),
427
+ success: false,
428
+ data: { serverId, failure: result.failure },
429
+ };
430
+ }
431
+ const added = result.tools.length === 0 ? [] : await context.getSession().addTools(result.tools);
432
+ if (result.tools.length > 0) mcp.oauthToolsAdded?.(serverId, added);
433
+ const dropped = result.tools.length - added.length;
434
+ const droppedText =
435
+ dropped === 0
436
+ ? ''
437
+ : ` ${dropped} of its tools ${dropped === 1 ? 'was' : 'were'} left out: the session already has a tool by that name.`;
438
+ return {
439
+ message: `${loginSuccessText(serverId, result.connection, added.length)}${droppedText}`,
440
+ success: true,
441
+ data: {
442
+ serverId,
443
+ connection: result.connection ?? 'not-connected',
444
+ tools: [...added],
445
+ ...(dropped === 0 ? {} : { dropped }),
446
+ },
447
+ };
448
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * What the MODEL is told about an MCP server it cannot use, and which user command fixes it.
3
+ *
4
+ * Fixed words only. A server's name comes from a definition a repository may write, so it is named
5
+ * only when it is safe to paste into any shell; otherwise the notice stays generic and points at
6
+ * `/mcp status`, where the user sees the name. Nothing a server or its definition returned — reason
7
+ * text, endpoint, header or token — ever reaches these strings.
8
+ *
9
+ * The actions themselves (approving a server, signing in, trusting a workspace) are the user's: the
10
+ * model can only suggest the command.
11
+ */
12
+
13
+ import { shellArgumentForDisplay } from '@robota-sdk/agent-core';
14
+
15
+ /** Why a server's tools are unavailable, as a user action that would fix it. */
16
+ export type TMCPUserAction = 'approve' | 'sign-in' | 'trust-workspace';
17
+
18
+ /**
19
+ * Where the user can act: `session` is an interactive session they can type a `/mcp` command into;
20
+ * `terminal` is a run with no such prompt (print, serve), where sign-in is the terminal command.
21
+ */
22
+ export type TMCPUserActionSurface = 'session' | 'terminal';
23
+
24
+ /** The command the user runs for `action`; `<server>` stands for a name that cannot be shown. */
25
+ export function mcpUserActionCommand(
26
+ serverId: string,
27
+ action: TMCPUserAction,
28
+ surface: TMCPUserActionSurface = 'session',
29
+ ): string {
30
+ const name = shellArgumentForDisplay(serverId) ?? '<server>';
31
+ switch (action) {
32
+ case 'approve':
33
+ return `/mcp approve ${name}`;
34
+ case 'sign-in':
35
+ return surface === 'terminal' ? `robota mcp login ${name}` : `/mcp login ${name}`;
36
+ case 'trust-workspace':
37
+ return 'robota trust';
38
+ }
39
+ }
40
+
41
+ const NEED: Record<TMCPUserAction, string> = {
42
+ approve: 'is waiting for the user’s approval',
43
+ 'sign-in': 'needs the user to sign in',
44
+ 'trust-workspace': 'needs the user to trust this workspace',
45
+ };
46
+
47
+ function subject(serverId: string): string {
48
+ const name = shellArgumentForDisplay(serverId);
49
+ return name === undefined
50
+ ? 'An MCP server (its name cannot be shown here; `/mcp status` lists it)'
51
+ : `MCP server "${name}"`;
52
+ }
53
+
54
+ /** One server's notice: what is missing and the exact command to suggest. */
55
+ export function mcpUserActionNotice(
56
+ serverId: string,
57
+ action: TMCPUserAction,
58
+ surface: TMCPUserActionSurface = 'session',
59
+ ): string {
60
+ const inTerminal =
61
+ action === 'trust-workspace' || (action === 'sign-in' && surface === 'terminal');
62
+ const where = inTerminal ? ' in a terminal, then restart the session' : '';
63
+ return (
64
+ `${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}.`
66
+ );
67
+ }
68
+
69
+ /**
70
+ * The notice for every server that could not start for a reason the user can fix, or `undefined`
71
+ * when there is none. Meant for the model's context at session start.
72
+ */
73
+ export function mcpUnavailableServersNotice(
74
+ servers: ReadonlyMap<string, TMCPUserAction>,
75
+ ): string | undefined {
76
+ if (servers.size === 0) return undefined;
77
+ const lines = [...servers].map(
78
+ ([serverId, action]) => `- ${mcpUserActionNotice(serverId, action)}`,
79
+ );
80
+ return [
81
+ 'MCP servers that did not start this session. Mention this only when the user needs one of ' +
82
+ 'these servers or its tools:',
83
+ ...lines,
84
+ ].join('\n');
85
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * `/mcp status` as the MODEL receives it: names, states and the user command that would fix a
3
+ * server — nothing else.
4
+ *
5
+ * The user's view carries each definition's reason text, source, provenance, fingerprint and
6
+ * security identity. None of that helps the model decide what to suggest, and some of it is
7
+ * definition-derived text a repository wrote, so the model's view is rebuilt from fixed words
8
+ * rather than filtered from the user's. A name that is not safe to show is replaced, never quoted.
9
+ */
10
+
11
+ import { shellArgumentForDisplay } from '@robota-sdk/agent-core';
12
+
13
+ import { mcpUserActionCommand } from './mcp-model-notice.js';
14
+
15
+ import type { TMCPUserAction, TMCPUserActionSurface } from './mcp-model-notice.js';
16
+ import type {
17
+ ICommandMCPActivationAdapter,
18
+ ICommandMCPActivationSummary,
19
+ ICommandMCPOAuthStatus,
20
+ } from '@robota-sdk/agent-framework';
21
+ import type { ICommandResult } from '@robota-sdk/agent-interface-command';
22
+
23
+ type TOAuthState = ICommandMCPOAuthStatus['state'];
24
+
25
+ function userActionFor(
26
+ status: ICommandMCPActivationSummary['status'],
27
+ oauth: TOAuthState | undefined,
28
+ ): TMCPUserAction | undefined {
29
+ if (status === 'pending' || status === 'stale') return 'approve';
30
+ if (status === 'untrusted') return 'trust-workspace';
31
+ if (status === 'approved' && (oauth === 'sign-in-required' || oauth === 'signed-out')) {
32
+ return 'sign-in';
33
+ }
34
+ // `rejected` and `revoked` are decisions the user already made; there is nothing to suggest.
35
+ return undefined;
36
+ }
37
+
38
+ interface IModelServerView {
39
+ readonly name?: string;
40
+ readonly status: ICommandMCPActivationSummary['status'];
41
+ readonly oauth?: TOAuthState;
42
+ readonly suggestCommand?: string;
43
+ }
44
+
45
+ function toModelView(
46
+ summary: ICommandMCPActivationSummary,
47
+ oauth: TOAuthState | undefined,
48
+ surface: TMCPUserActionSurface,
49
+ ): IModelServerView {
50
+ const name = shellArgumentForDisplay(summary.serverId);
51
+ const action = userActionFor(summary.status, oauth);
52
+ return {
53
+ ...(name === undefined ? {} : { name }),
54
+ status: summary.status,
55
+ ...(oauth === undefined ? {} : { oauth }),
56
+ ...(action === undefined
57
+ ? {}
58
+ : { suggestCommand: mcpUserActionCommand(summary.serverId, action, surface) }),
59
+ };
60
+ }
61
+
62
+ function formatLine(view: IModelServerView): string {
63
+ const name = view.name ?? '(name not shown)';
64
+ const signIn = view.oauth === undefined ? '' : ` — sign-in: ${view.oauth}`;
65
+ const suggest =
66
+ view.suggestCommand === undefined ? '' : ` — ask the user to run \`${view.suggestCommand}\``;
67
+ return ` ${name} — ${view.status}${signIn}${suggest}`;
68
+ }
69
+
70
+ export async function mcpModelStatusResult(
71
+ mcp: ICommandMCPActivationAdapter | undefined,
72
+ ): Promise<ICommandResult> {
73
+ if (!mcp) {
74
+ return { message: 'MCP is not available in this environment.', success: true };
75
+ }
76
+ const oauthStates = new Map(
77
+ ((await mcp.oauthStatus?.()) ?? []).map((status) => [status.serverId, status.state]),
78
+ );
79
+ const servers = mcp
80
+ .list()
81
+ .map((summary) =>
82
+ toModelView(summary, oauthStates.get(summary.serverId), mcp.userActionSurface ?? 'session'),
83
+ );
84
+ const unreadableSources = (mcp.sourceProblems?.() ?? []).length;
85
+ const lines =
86
+ servers.length === 0
87
+ ? ['No MCP servers are configured.']
88
+ : ['MCP servers:', ...servers.map(formatLine)];
89
+ if (unreadableSources > 0) {
90
+ lines.push(
91
+ `${unreadableSources} MCP configuration source(s) could not be read; the user can see which with \`/mcp status\`.`,
92
+ );
93
+ }
94
+ return {
95
+ message: lines.join('\n'),
96
+ success: true,
97
+ data: { servers, unreadableSources },
98
+ };
99
+ }