@pellux/goodvibes-tui 0.19.98 → 0.19.99
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 +5 -0
- package/README.md +22 -3
- package/docs/foundation-artifacts/operator-contract.json +1376 -290
- package/package.json +2 -2
- package/src/input/command-registry.ts +1 -1
- package/src/input/commands/mcp-runtime.ts +237 -7
- package/src/input/feed-context-factory.ts +2 -0
- package/src/input/handler-feed.ts +3 -0
- package/src/input/handler-interactions.ts +1 -0
- package/src/input/handler-modal-stack.ts +3 -0
- package/src/input/handler-modal-token-routes.ts +11 -0
- package/src/input/handler-ui-state.ts +10 -0
- package/src/input/handler.ts +10 -0
- package/src/input/mcp-workspace.ts +554 -0
- package/src/mcp/runtime-reload.ts +81 -0
- package/src/panels/builtin/operations.ts +1 -11
- package/src/panels/builtin/shared.ts +2 -2
- package/src/panels/index.ts +0 -1
- package/src/renderer/conversation-overlays.ts +6 -0
- package/src/renderer/mcp-workspace.ts +297 -0
- package/src/runtime/bootstrap-command-parts.ts +2 -4
- package/src/runtime/bootstrap.ts +26 -2
- package/src/shell/ui-openers.ts +5 -0
- package/src/version.ts +1 -1
- package/src/panels/mcp-panel.ts +0 -215
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
import type { InputToken } from '@pellux/goodvibes-sdk/platform/core';
|
|
2
|
+
import type {
|
|
3
|
+
McpConfigScope,
|
|
4
|
+
McpEffectiveConfig,
|
|
5
|
+
McpServerConfig,
|
|
6
|
+
McpServerConfigEntry,
|
|
7
|
+
McpServerSecurityRecord,
|
|
8
|
+
RegisteredTool,
|
|
9
|
+
} from '@pellux/goodvibes-sdk/platform/mcp';
|
|
10
|
+
import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
11
|
+
import type { CommandContext } from './command-registry.ts';
|
|
12
|
+
import { requireMcpApi, requireShellPaths } from './commands/runtime-services.ts';
|
|
13
|
+
|
|
14
|
+
export const MCP_WORKSPACE_MODAL_NAME = 'mcpWorkspace';
|
|
15
|
+
|
|
16
|
+
const MCP_ROLES = ['general', 'docs', 'filesystem', 'git', 'database', 'browser', 'automation', 'ops', 'remote'] as const;
|
|
17
|
+
const MCP_FORM_TRUST_MODES = ['constrained', 'ask-on-risk', 'blocked'] as const;
|
|
18
|
+
|
|
19
|
+
export type McpWorkspaceMode = 'browse' | 'form' | 'delete-confirm';
|
|
20
|
+
|
|
21
|
+
export interface McpWorkspaceServerRow {
|
|
22
|
+
readonly name: string;
|
|
23
|
+
readonly connected: boolean;
|
|
24
|
+
readonly role: string;
|
|
25
|
+
readonly trustMode: string;
|
|
26
|
+
readonly freshness: string;
|
|
27
|
+
readonly source: 'project' | 'global' | 'external' | 'runtime';
|
|
28
|
+
readonly command?: string;
|
|
29
|
+
readonly args?: readonly string[];
|
|
30
|
+
readonly allowedPaths: readonly string[];
|
|
31
|
+
readonly allowedHosts: readonly string[];
|
|
32
|
+
readonly quarantineReason?: string;
|
|
33
|
+
readonly quarantineDetail?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface McpWorkspaceActionRow {
|
|
37
|
+
readonly type: 'action';
|
|
38
|
+
readonly id: 'add' | 'reload' | 'refresh-tools' | 'config';
|
|
39
|
+
readonly label: string;
|
|
40
|
+
readonly detail: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type McpWorkspaceRow =
|
|
44
|
+
| { readonly type: 'server'; readonly server: McpWorkspaceServerRow }
|
|
45
|
+
| McpWorkspaceActionRow;
|
|
46
|
+
|
|
47
|
+
export interface McpWorkspaceFormField {
|
|
48
|
+
readonly id: keyof McpWorkspaceForm | 'save' | 'cancel';
|
|
49
|
+
readonly label: string;
|
|
50
|
+
readonly value: string;
|
|
51
|
+
readonly help: string;
|
|
52
|
+
readonly editable: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface McpWorkspaceForm {
|
|
56
|
+
scope: McpConfigScope;
|
|
57
|
+
name: string;
|
|
58
|
+
command: string;
|
|
59
|
+
args: string;
|
|
60
|
+
role: NonNullable<McpServerConfig['role']>;
|
|
61
|
+
trustMode: Exclude<NonNullable<McpServerConfig['trustMode']>, 'allow-all'>;
|
|
62
|
+
env: string;
|
|
63
|
+
allowedPaths: string;
|
|
64
|
+
allowedHosts: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface McpWorkspaceSnapshot {
|
|
68
|
+
readonly projectPath: string;
|
|
69
|
+
readonly globalPath: string;
|
|
70
|
+
readonly effectiveConfig: McpEffectiveConfig;
|
|
71
|
+
readonly servers: readonly McpWorkspaceServerRow[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isTextField(field: keyof McpWorkspaceForm | 'save' | 'cancel'): field is 'name' | 'command' | 'args' | 'env' | 'allowedPaths' | 'allowedHosts' {
|
|
75
|
+
return field === 'name' || field === 'command' || field === 'args' || field === 'env' || field === 'allowedPaths' || field === 'allowedHosts';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function splitList(value: string): string[] {
|
|
79
|
+
return value
|
|
80
|
+
.split(',')
|
|
81
|
+
.map((entry) => entry.trim())
|
|
82
|
+
.filter(Boolean);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parseArgs(value: string): string[] {
|
|
86
|
+
const args: string[] = [];
|
|
87
|
+
let current = '';
|
|
88
|
+
let quote: '"' | "'" | null = null;
|
|
89
|
+
let escaping = false;
|
|
90
|
+
for (const ch of value) {
|
|
91
|
+
if (escaping) {
|
|
92
|
+
current += ch;
|
|
93
|
+
escaping = false;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (ch === '\\') {
|
|
97
|
+
escaping = true;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (quote) {
|
|
101
|
+
if (ch === quote) quote = null;
|
|
102
|
+
else current += ch;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (ch === '"' || ch === "'") {
|
|
106
|
+
quote = ch;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (/\s/.test(ch)) {
|
|
110
|
+
if (current.length > 0) {
|
|
111
|
+
args.push(current);
|
|
112
|
+
current = '';
|
|
113
|
+
}
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
current += ch;
|
|
117
|
+
}
|
|
118
|
+
if (escaping) current += '\\';
|
|
119
|
+
if (current.length > 0) args.push(current);
|
|
120
|
+
return args;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function parseEnv(value: string): Record<string, string> | undefined {
|
|
124
|
+
const entries: Array<[string, string]> = [];
|
|
125
|
+
for (const raw of splitList(value)) {
|
|
126
|
+
const eq = raw.indexOf('=');
|
|
127
|
+
if (eq <= 0) throw new Error(`Invalid env entry "${raw}". Use KEY=VALUE.`);
|
|
128
|
+
entries.push([raw.slice(0, eq).trim(), raw.slice(eq + 1)]);
|
|
129
|
+
}
|
|
130
|
+
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function serverConfigToForm(server?: McpServerConfig): McpWorkspaceForm {
|
|
134
|
+
return {
|
|
135
|
+
scope: 'project',
|
|
136
|
+
name: server?.name ?? '',
|
|
137
|
+
command: server?.command ?? '',
|
|
138
|
+
args: server?.args?.join(' ') ?? '',
|
|
139
|
+
role: server?.role ?? 'general',
|
|
140
|
+
trustMode: server?.trustMode === 'blocked' || server?.trustMode === 'ask-on-risk' ? server.trustMode : 'constrained',
|
|
141
|
+
env: server?.env ? Object.entries(server.env).map(([key, value]) => `${key}=${value}`).join(', ') : '',
|
|
142
|
+
allowedPaths: server?.allowedPaths?.join(', ') ?? '',
|
|
143
|
+
allowedHosts: server?.allowedHosts?.join(', ') ?? '',
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function formToServerConfig(form: McpWorkspaceForm): McpServerConfig {
|
|
148
|
+
const name = form.name.trim();
|
|
149
|
+
const command = form.command.trim();
|
|
150
|
+
if (!name) throw new Error('Server name is required.');
|
|
151
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
|
|
152
|
+
throw new Error('Server names may contain letters, numbers, dot, underscore, and dash only.');
|
|
153
|
+
}
|
|
154
|
+
if (!command) throw new Error('Command is required.');
|
|
155
|
+
const args = parseArgs(form.args.trim());
|
|
156
|
+
const env = parseEnv(form.env);
|
|
157
|
+
const allowedPaths = splitList(form.allowedPaths);
|
|
158
|
+
const allowedHosts = splitList(form.allowedHosts);
|
|
159
|
+
return {
|
|
160
|
+
name,
|
|
161
|
+
command,
|
|
162
|
+
...(args.length > 0 ? { args } : {}),
|
|
163
|
+
role: form.role,
|
|
164
|
+
trustMode: form.trustMode,
|
|
165
|
+
...(env ? { env } : {}),
|
|
166
|
+
...(allowedPaths.length > 0 ? { allowedPaths } : {}),
|
|
167
|
+
...(allowedHosts.length > 0 ? { allowedHosts } : {}),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function nextEnumValue<T extends readonly string[]>(values: T, current: T[number], direction: 1 | -1): T[number] {
|
|
172
|
+
const index = values.indexOf(current);
|
|
173
|
+
const next = (index + direction + values.length) % values.length;
|
|
174
|
+
return values[next]!;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function mergeServers(
|
|
178
|
+
effectiveConfig: McpEffectiveConfig,
|
|
179
|
+
runtimeServers: readonly McpServerSecurityRecord[],
|
|
180
|
+
): McpWorkspaceServerRow[] {
|
|
181
|
+
const configByName = new Map<string, McpServerConfigEntry>();
|
|
182
|
+
for (const entry of effectiveConfig.servers) configByName.set(entry.server.name, entry);
|
|
183
|
+
const runtimeByName = new Map(runtimeServers.map((server) => [server.name, server]));
|
|
184
|
+
const names = new Set<string>([
|
|
185
|
+
...effectiveConfig.servers.map((entry) => entry.server.name),
|
|
186
|
+
...runtimeServers.map((server) => server.name),
|
|
187
|
+
]);
|
|
188
|
+
|
|
189
|
+
return [...names].sort((a, b) => a.localeCompare(b)).map((name) => {
|
|
190
|
+
const entry = configByName.get(name);
|
|
191
|
+
const config = entry?.server;
|
|
192
|
+
const runtime = runtimeByName.get(name);
|
|
193
|
+
return {
|
|
194
|
+
name,
|
|
195
|
+
connected: runtime?.connected ?? false,
|
|
196
|
+
role: runtime?.role ?? config?.role ?? 'general',
|
|
197
|
+
trustMode: runtime?.trustMode ?? config?.trustMode ?? 'constrained',
|
|
198
|
+
freshness: runtime?.schemaFreshness ?? 'unknown',
|
|
199
|
+
source: entry?.source.scope === 'project' ? 'project' : entry?.source.scope === 'global' ? 'global' : config ? 'external' : 'runtime',
|
|
200
|
+
command: config?.command,
|
|
201
|
+
args: config?.args,
|
|
202
|
+
allowedPaths: runtime?.allowedPaths ?? config?.allowedPaths ?? [],
|
|
203
|
+
allowedHosts: runtime?.allowedHosts ?? config?.allowedHosts ?? [],
|
|
204
|
+
quarantineReason: runtime?.quarantineReason,
|
|
205
|
+
quarantineDetail: runtime?.quarantineDetail,
|
|
206
|
+
};
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export class McpWorkspace {
|
|
211
|
+
public active = false;
|
|
212
|
+
public mode: McpWorkspaceMode = 'browse';
|
|
213
|
+
public selectedIndex = 0;
|
|
214
|
+
public formIndex = 0;
|
|
215
|
+
public form: McpWorkspaceForm = serverConfigToForm();
|
|
216
|
+
public editingServerName: string | null = null;
|
|
217
|
+
public status = 'Ready. Add, edit, remove, reload, and inspect MCP servers without restarting the TUI.';
|
|
218
|
+
public tools: readonly RegisteredTool[] = [];
|
|
219
|
+
public loadingTools = false;
|
|
220
|
+
public lastError: string | null = null;
|
|
221
|
+
private context: CommandContext | null = null;
|
|
222
|
+
private snapshot: McpWorkspaceSnapshot = {
|
|
223
|
+
projectPath: '',
|
|
224
|
+
globalPath: '',
|
|
225
|
+
effectiveConfig: { servers: [], locations: [] },
|
|
226
|
+
servers: [],
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
open(context: CommandContext): void {
|
|
230
|
+
this.context = context;
|
|
231
|
+
this.active = true;
|
|
232
|
+
this.mode = 'browse';
|
|
233
|
+
this.selectedIndex = 0;
|
|
234
|
+
this.formIndex = 0;
|
|
235
|
+
this.lastError = null;
|
|
236
|
+
this.refreshSnapshot();
|
|
237
|
+
void this.refreshTools();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
reopen(): void {
|
|
241
|
+
this.active = true;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
close(): void {
|
|
245
|
+
this.active = false;
|
|
246
|
+
this.mode = 'browse';
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
get projectPath(): string {
|
|
250
|
+
return this.snapshot.projectPath;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
get servers(): readonly McpWorkspaceServerRow[] {
|
|
254
|
+
return this.snapshot.servers;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
get rows(): readonly McpWorkspaceRow[] {
|
|
258
|
+
return [
|
|
259
|
+
...this.snapshot.servers.map((server): McpWorkspaceRow => ({ type: 'server', server })),
|
|
260
|
+
{ type: 'action', id: 'add', label: 'Add server', detail: `Write a server through the SDK config manager. Default scope: ${this.form.scope}.` },
|
|
261
|
+
{ type: 'action', id: 'reload', label: 'Reload runtime', detail: 'Reconnect all MCP servers from global, Claude, and project config files.' },
|
|
262
|
+
{ type: 'action', id: 'refresh-tools', label: 'Refresh tools', detail: 'Fetch the currently available MCP tool list from connected servers.' },
|
|
263
|
+
{ type: 'action', id: 'config', label: 'Config locations', detail: 'Show SDK-scanned config files and writable project/global paths.' },
|
|
264
|
+
];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
get selectedRow(): McpWorkspaceRow | null {
|
|
268
|
+
return this.rows[this.selectedIndex] ?? null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
get selectedServer(): McpWorkspaceServerRow | null {
|
|
272
|
+
const row = this.selectedRow;
|
|
273
|
+
return row?.type === 'server' ? row.server : null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
get formFields(): readonly McpWorkspaceFormField[] {
|
|
277
|
+
return [
|
|
278
|
+
{ id: 'name', label: 'Server name', value: this.form.name, help: 'Unique MCP server id. Project config overrides matching global server names.', editable: true },
|
|
279
|
+
{ id: 'scope', label: 'Scope', value: this.form.scope, help: 'Cycle with Left/Right. Project writes to this workspace; global writes to your user MCP config.', editable: false },
|
|
280
|
+
{ id: 'command', label: 'Command', value: this.form.command, help: 'Executable to launch, for example npx, node, uvx, python, or an absolute path.', editable: true },
|
|
281
|
+
{ id: 'args', label: 'Arguments', value: this.form.args, help: 'Command arguments. Quotes are supported for values with spaces.', editable: true },
|
|
282
|
+
{ id: 'role', label: 'Role', value: this.form.role, help: `Cycle with Left/Right. Values: ${MCP_ROLES.join(', ')}.`, editable: false },
|
|
283
|
+
{ id: 'trustMode', label: 'Trust mode', value: this.form.trustMode, help: 'Cycle with Left/Right. allow-all is intentionally not offered here; use Settings MCP for explicit escalation.', editable: false },
|
|
284
|
+
{ id: 'env', label: 'Environment', value: this.form.env, help: 'Comma-separated KEY=VALUE entries. Prefer env var references or secure secrets for sensitive values.', editable: true },
|
|
285
|
+
{ id: 'allowedPaths', label: 'Allowed paths', value: this.form.allowedPaths, help: 'Comma-separated path prefixes for filesystem-oriented servers.', editable: true },
|
|
286
|
+
{ id: 'allowedHosts', label: 'Allowed hosts', value: this.form.allowedHosts, help: 'Comma-separated hostnames for network-oriented servers.', editable: true },
|
|
287
|
+
{ id: 'save', label: 'Save and reload', value: '', help: 'Write the selected scope config and reconnect the live MCP runtime.', editable: false },
|
|
288
|
+
{ id: 'cancel', label: 'Cancel', value: '', help: 'Return to the MCP server browser without changing config.', editable: false },
|
|
289
|
+
];
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
refreshSnapshot(): void {
|
|
293
|
+
if (!this.context) return;
|
|
294
|
+
const roots = requireShellPaths(this.context);
|
|
295
|
+
const api = requireMcpApi(this.context);
|
|
296
|
+
const effectiveConfig = api.getEffectiveConfig(roots);
|
|
297
|
+
const runtimeServers = api.listServerSecurity();
|
|
298
|
+
const projectPath = effectiveConfig.locations.find((location) => location.scope === 'project' && location.writable)?.path ?? `${roots.workingDirectory}/.goodvibes/mcp.json`;
|
|
299
|
+
const globalPath = effectiveConfig.locations.find((location) => location.scope === 'global' && location.writable)?.path ?? `${roots.homeDirectory}/.config/mcp/mcp.json`;
|
|
300
|
+
this.snapshot = {
|
|
301
|
+
projectPath,
|
|
302
|
+
globalPath,
|
|
303
|
+
effectiveConfig,
|
|
304
|
+
servers: mergeServers(effectiveConfig, runtimeServers),
|
|
305
|
+
};
|
|
306
|
+
this.selectedIndex = Math.max(0, Math.min(this.selectedIndex, this.rows.length - 1));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async refreshTools(): Promise<void> {
|
|
310
|
+
if (!this.context) return;
|
|
311
|
+
this.loadingTools = true;
|
|
312
|
+
this.lastError = null;
|
|
313
|
+
try {
|
|
314
|
+
const api = requireMcpApi(this.context);
|
|
315
|
+
this.tools = await api.listAllTools();
|
|
316
|
+
this.status = `Tool list refreshed: ${this.tools.length} tool(s) available.`;
|
|
317
|
+
} catch (error) {
|
|
318
|
+
this.lastError = summarizeError(error);
|
|
319
|
+
this.status = `Tool refresh failed: ${this.lastError}`;
|
|
320
|
+
} finally {
|
|
321
|
+
this.loadingTools = false;
|
|
322
|
+
this.context?.renderRequest();
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async reloadRuntime(): Promise<void> {
|
|
327
|
+
if (!this.context) return;
|
|
328
|
+
this.lastError = null;
|
|
329
|
+
try {
|
|
330
|
+
const api = requireMcpApi(this.context);
|
|
331
|
+
const roots = requireShellPaths(this.context);
|
|
332
|
+
const result = await api.reload(roots);
|
|
333
|
+
this.refreshSnapshot();
|
|
334
|
+
const connected = this.snapshot.servers.filter((server) => server.connected).length;
|
|
335
|
+
this.status = `Reloaded MCP runtime: ${connected}/${this.snapshot.servers.length} server(s) connected. Result: +${result.added} ~${result.changed} -${result.removed}, unchanged ${result.unchanged}.`;
|
|
336
|
+
void this.refreshTools();
|
|
337
|
+
} catch (error) {
|
|
338
|
+
this.lastError = summarizeError(error);
|
|
339
|
+
this.status = `Reload failed: ${this.lastError}`;
|
|
340
|
+
} finally {
|
|
341
|
+
this.context?.renderRequest();
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
openAddForm(): void {
|
|
346
|
+
this.mode = 'form';
|
|
347
|
+
this.formIndex = 0;
|
|
348
|
+
this.editingServerName = null;
|
|
349
|
+
this.form = serverConfigToForm();
|
|
350
|
+
this.status = 'Add an MCP server. Choose project or global scope, then save and reload.';
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
openEditForm(serverName: string): void {
|
|
354
|
+
const entry = this.snapshot.effectiveConfig.servers.find((configEntry) => configEntry.server.name === serverName);
|
|
355
|
+
this.mode = 'form';
|
|
356
|
+
this.formIndex = 0;
|
|
357
|
+
this.editingServerName = serverName;
|
|
358
|
+
this.form = { ...serverConfigToForm(entry?.server), scope: entry?.source.scope === 'global' ? 'global' : 'project' };
|
|
359
|
+
this.status = entry
|
|
360
|
+
? `Editing ${serverName}. Saving writes a ${this.form.scope} config entry and reloads the live runtime.`
|
|
361
|
+
: `Editing ${serverName}. Runtime status exists, but no launch config was found; enter command details before saving.`;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
requestDelete(serverName: string): void {
|
|
365
|
+
this.mode = 'delete-confirm';
|
|
366
|
+
this.editingServerName = serverName;
|
|
367
|
+
const entry = this.snapshot.effectiveConfig.servers.find((configEntry) => configEntry.server.name === serverName);
|
|
368
|
+
const scope = entry?.source.scope === 'global' ? 'global' : 'project';
|
|
369
|
+
this.status = `Remove ${scope} server "${serverName}"? Press y to confirm or n/Esc to cancel.`;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async saveForm(): Promise<void> {
|
|
373
|
+
if (!this.context) return;
|
|
374
|
+
try {
|
|
375
|
+
const server = formToServerConfig(this.form);
|
|
376
|
+
this.status = `Saving ${server.name} to ${this.form.scope} MCP config and reloading runtime...`;
|
|
377
|
+
this.mode = 'browse';
|
|
378
|
+
this.editingServerName = null;
|
|
379
|
+
const api = requireMcpApi(this.context);
|
|
380
|
+
const result = await api.upsertServerConfig(requireShellPaths(this.context), this.form.scope, server);
|
|
381
|
+
this.refreshSnapshot();
|
|
382
|
+
this.status = `Saved ${server.name} to ${result.path}. Reload result: +${result.reload.added} ~${result.reload.changed} -${result.reload.removed}, unchanged ${result.reload.unchanged}.`;
|
|
383
|
+
void this.refreshTools();
|
|
384
|
+
} catch (error) {
|
|
385
|
+
this.lastError = summarizeError(error);
|
|
386
|
+
this.status = `Save failed: ${this.lastError}`;
|
|
387
|
+
this.context.renderRequest();
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async confirmDelete(): Promise<void> {
|
|
392
|
+
if (!this.context || !this.editingServerName) return;
|
|
393
|
+
const name = this.editingServerName;
|
|
394
|
+
try {
|
|
395
|
+
const server = this.snapshot.effectiveConfig.servers.find((entry) => entry.server.name === name);
|
|
396
|
+
const scope = server?.source.scope === 'global' ? 'global' : 'project';
|
|
397
|
+
const api = requireMcpApi(this.context);
|
|
398
|
+
const result = await api.removeServerConfig(requireShellPaths(this.context), scope, name);
|
|
399
|
+
this.mode = 'browse';
|
|
400
|
+
this.editingServerName = null;
|
|
401
|
+
this.refreshSnapshot();
|
|
402
|
+
this.status = result.removed
|
|
403
|
+
? `Removed ${scope} server "${name}" from ${result.path}. Reload result: +${result.reload.added} ~${result.reload.changed} -${result.reload.removed}.`
|
|
404
|
+
: `No ${scope} MCP server named "${name}" exists in ${result.path}.`;
|
|
405
|
+
void this.refreshTools();
|
|
406
|
+
} catch (error) {
|
|
407
|
+
this.lastError = summarizeError(error);
|
|
408
|
+
this.status = `Remove failed: ${this.lastError}`;
|
|
409
|
+
this.context.renderRequest();
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
cancelForm(): void {
|
|
414
|
+
this.mode = 'browse';
|
|
415
|
+
this.editingServerName = null;
|
|
416
|
+
this.status = 'Returned to MCP server browser.';
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
moveSelection(delta: number): void {
|
|
420
|
+
const total = this.mode === 'form' ? this.formFields.length : this.rows.length;
|
|
421
|
+
if (total <= 0) return;
|
|
422
|
+
if (this.mode === 'form') {
|
|
423
|
+
this.formIndex = Math.max(0, Math.min(total - 1, this.formIndex + delta));
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
this.selectedIndex = Math.max(0, Math.min(total - 1, this.selectedIndex + delta));
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async activateSelected(): Promise<void> {
|
|
430
|
+
if (this.mode === 'form') {
|
|
431
|
+
const field = this.formFields[this.formIndex];
|
|
432
|
+
if (field?.id === 'save') await this.saveForm();
|
|
433
|
+
else if (field?.id === 'cancel') this.cancelForm();
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (this.mode === 'delete-confirm') {
|
|
437
|
+
await this.confirmDelete();
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
const row = this.selectedRow;
|
|
441
|
+
if (!row) return;
|
|
442
|
+
if (row.type === 'server') {
|
|
443
|
+
this.openEditForm(row.server.name);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
if (row.id === 'add') this.openAddForm();
|
|
447
|
+
else if (row.id === 'reload') await this.reloadRuntime();
|
|
448
|
+
else if (row.id === 'refresh-tools') await this.refreshTools();
|
|
449
|
+
else if (row.id === 'config') {
|
|
450
|
+
this.status = [
|
|
451
|
+
`Project config: ${this.snapshot.projectPath}`,
|
|
452
|
+
`Global config: ${this.snapshot.globalPath}`,
|
|
453
|
+
`Scanned: ${this.snapshot.effectiveConfig.locations.map((location) => `${location.kind}:${location.writable ? 'writable' : 'read-only'}`).join(', ')}`,
|
|
454
|
+
].join(' ');
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
adjustFormEnum(direction: 1 | -1): void {
|
|
459
|
+
const field = this.formFields[this.formIndex];
|
|
460
|
+
if (!field) return;
|
|
461
|
+
if (field.id === 'role') {
|
|
462
|
+
this.form.role = nextEnumValue(MCP_ROLES, this.form.role, direction);
|
|
463
|
+
} else if (field.id === 'scope') {
|
|
464
|
+
this.form.scope = nextEnumValue(['project', 'global'] as const, this.form.scope, direction);
|
|
465
|
+
} else if (field.id === 'trustMode') {
|
|
466
|
+
this.form.trustMode = nextEnumValue(MCP_FORM_TRUST_MODES, this.form.trustMode, direction);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
appendFormText(text: string): void {
|
|
471
|
+
const field = this.formFields[this.formIndex];
|
|
472
|
+
if (!field || !isTextField(field.id)) return;
|
|
473
|
+
this.form[field.id] += text;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
backspaceFormText(): void {
|
|
477
|
+
const field = this.formFields[this.formIndex];
|
|
478
|
+
if (!field || !isTextField(field.id)) return;
|
|
479
|
+
this.form[field.id] = this.form[field.id].slice(0, -1);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
export function handleMcpWorkspaceToken(
|
|
484
|
+
workspace: McpWorkspace,
|
|
485
|
+
token: InputToken,
|
|
486
|
+
handleEscape: () => void,
|
|
487
|
+
requestRender: () => void,
|
|
488
|
+
): boolean {
|
|
489
|
+
if (!workspace.active) return false;
|
|
490
|
+
|
|
491
|
+
if (token.type === 'mouse') {
|
|
492
|
+
if (token.action === 'press' && token.button === 64) workspace.moveSelection(-3);
|
|
493
|
+
else if (token.action === 'press' && token.button === 65) workspace.moveSelection(3);
|
|
494
|
+
else return true;
|
|
495
|
+
requestRender();
|
|
496
|
+
return true;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (token.type === 'text') {
|
|
500
|
+
if (workspace.mode === 'form') {
|
|
501
|
+
workspace.appendFormText(token.value);
|
|
502
|
+
} else if (workspace.mode === 'delete-confirm') {
|
|
503
|
+
if (token.value.toLowerCase() === 'y') void workspace.confirmDelete();
|
|
504
|
+
else if (token.value.toLowerCase() === 'n') workspace.cancelForm();
|
|
505
|
+
} else {
|
|
506
|
+
const value = token.value.toLowerCase();
|
|
507
|
+
if (value === 'a') workspace.openAddForm();
|
|
508
|
+
else if (value === 'e' && workspace.selectedServer) workspace.openEditForm(workspace.selectedServer.name);
|
|
509
|
+
else if (value === 'd' && workspace.selectedServer) workspace.requestDelete(workspace.selectedServer.name);
|
|
510
|
+
else if (value === 'r') void workspace.reloadRuntime();
|
|
511
|
+
else if (value === 't') void workspace.refreshTools();
|
|
512
|
+
}
|
|
513
|
+
requestRender();
|
|
514
|
+
return true;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
if (token.type !== 'key') return true;
|
|
518
|
+
if (token.logicalName === 'escape') {
|
|
519
|
+
if (workspace.mode === 'form' || workspace.mode === 'delete-confirm') {
|
|
520
|
+
workspace.cancelForm();
|
|
521
|
+
requestRender();
|
|
522
|
+
} else {
|
|
523
|
+
handleEscape();
|
|
524
|
+
}
|
|
525
|
+
return true;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (token.logicalName === 'up') workspace.moveSelection(token.shift ? -5 : -1);
|
|
529
|
+
else if (token.logicalName === 'down') workspace.moveSelection(token.shift ? 5 : 1);
|
|
530
|
+
else if (token.logicalName === 'pageup') workspace.moveSelection(-10);
|
|
531
|
+
else if (token.logicalName === 'pagedown') workspace.moveSelection(10);
|
|
532
|
+
else if (token.logicalName === 'enter') void workspace.activateSelected();
|
|
533
|
+
else if (token.logicalName === 'backspace' || token.logicalName === 'delete') {
|
|
534
|
+
if (workspace.mode === 'form') workspace.backspaceFormText();
|
|
535
|
+
else if (workspace.mode === 'browse' && workspace.selectedServer) workspace.requestDelete(workspace.selectedServer.name);
|
|
536
|
+
} else if (token.logicalName === 'left') {
|
|
537
|
+
if (workspace.mode === 'form') workspace.adjustFormEnum(-1);
|
|
538
|
+
} else if (token.logicalName === 'right') {
|
|
539
|
+
if (workspace.mode === 'form') workspace.adjustFormEnum(1);
|
|
540
|
+
} else if (token.logicalName === 'space') {
|
|
541
|
+
if (workspace.mode === 'form') workspace.appendFormText(' ');
|
|
542
|
+
} else if (token.logicalName === 'a' && workspace.mode === 'browse') {
|
|
543
|
+
workspace.openAddForm();
|
|
544
|
+
} else if (token.logicalName === 'd' && workspace.mode === 'browse' && workspace.selectedServer) {
|
|
545
|
+
workspace.requestDelete(workspace.selectedServer.name);
|
|
546
|
+
} else if (token.logicalName === 'r' && workspace.mode === 'browse') {
|
|
547
|
+
void workspace.reloadRuntime();
|
|
548
|
+
} else if (token.logicalName === 't' && workspace.mode === 'browse') {
|
|
549
|
+
void workspace.refreshTools();
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
requestRender();
|
|
553
|
+
return true;
|
|
554
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { statSync } from 'node:fs';
|
|
2
|
+
import { getMcpConfigLocations, type McpConfigRoots, type McpRegistry } from '@pellux/goodvibes-sdk/platform/mcp';
|
|
3
|
+
|
|
4
|
+
export interface McpRuntimeReloadHandle {
|
|
5
|
+
stop(): void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface McpRuntimeReloadOptions {
|
|
9
|
+
readonly roots: McpConfigRoots;
|
|
10
|
+
readonly registry: Pick<McpRegistry, 'reload' | 'listServerSecurity'>;
|
|
11
|
+
readonly onReload?: (summary: { connected: number; total: number }) => void;
|
|
12
|
+
readonly onError?: (error: unknown) => void;
|
|
13
|
+
readonly intervalMs?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface FileSignature {
|
|
17
|
+
readonly exists: boolean;
|
|
18
|
+
readonly mtimeMs: number;
|
|
19
|
+
readonly size: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function candidateMcpConfigPaths(roots: McpConfigRoots): string[] {
|
|
23
|
+
return getMcpConfigLocations(roots).map((location) => location.path);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function signatureFor(path: string): FileSignature {
|
|
27
|
+
try {
|
|
28
|
+
const stat = statSync(path);
|
|
29
|
+
return { exists: true, mtimeMs: stat.mtimeMs, size: stat.size };
|
|
30
|
+
} catch {
|
|
31
|
+
return { exists: false, mtimeMs: 0, size: 0 };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function signaturesDiffer(a: readonly FileSignature[], b: readonly FileSignature[]): boolean {
|
|
36
|
+
if (a.length !== b.length) return true;
|
|
37
|
+
return a.some((entry, index) => {
|
|
38
|
+
const other = b[index];
|
|
39
|
+
return !other || entry.exists !== other.exists || entry.mtimeMs !== other.mtimeMs || entry.size !== other.size;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function startMcpConfigAutoReload(options: McpRuntimeReloadOptions): McpRuntimeReloadHandle {
|
|
44
|
+
const paths = candidateMcpConfigPaths(options.roots);
|
|
45
|
+
const intervalMs = Math.max(500, options.intervalMs ?? 2_000);
|
|
46
|
+
let stopped = false;
|
|
47
|
+
let reloading = false;
|
|
48
|
+
let last = paths.map(signatureFor);
|
|
49
|
+
|
|
50
|
+
const reload = async (): Promise<void> => {
|
|
51
|
+
if (stopped || reloading) return;
|
|
52
|
+
reloading = true;
|
|
53
|
+
try {
|
|
54
|
+
await options.registry.reload(options.roots);
|
|
55
|
+
const servers = options.registry.listServerSecurity();
|
|
56
|
+
options.onReload?.({
|
|
57
|
+
connected: servers.filter((server) => server.connected).length,
|
|
58
|
+
total: servers.length,
|
|
59
|
+
});
|
|
60
|
+
} catch (error) {
|
|
61
|
+
options.onError?.(error);
|
|
62
|
+
} finally {
|
|
63
|
+
reloading = false;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const interval = setInterval(() => {
|
|
68
|
+
const next = paths.map(signatureFor);
|
|
69
|
+
if (!signaturesDiffer(last, next)) return;
|
|
70
|
+
last = next;
|
|
71
|
+
void reload();
|
|
72
|
+
}, intervalMs);
|
|
73
|
+
interval.unref?.();
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
stop() {
|
|
77
|
+
stopped = true;
|
|
78
|
+
clearInterval(interval);
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -13,7 +13,6 @@ import { LocalAuthPanel } from '../local-auth-panel.ts';
|
|
|
13
13
|
import { ProviderAccountsPanel } from '../provider-accounts-panel.ts';
|
|
14
14
|
import { SettingsSyncPanel } from '../settings-sync-panel.ts';
|
|
15
15
|
import { WorktreePanel } from '../worktree-panel.ts';
|
|
16
|
-
import { McpPanel } from '../mcp-panel.ts';
|
|
17
16
|
import { HooksPanel } from '../hooks-panel.ts';
|
|
18
17
|
import { SecurityPanel } from '../security-panel.ts';
|
|
19
18
|
import { MarketplacePanel } from '../marketplace-panel.ts';
|
|
@@ -37,7 +36,7 @@ import {
|
|
|
37
36
|
} from '../../runtime/ui-service-queries.ts';
|
|
38
37
|
import { createRuntimeProviderApi } from '@/runtime/index.ts';
|
|
39
38
|
import type { ResolvedBuiltinPanelDeps } from './shared.ts';
|
|
40
|
-
import { requireAutomationManager, requireControlPlanePanelDeps, requireHookPanelDeps,
|
|
39
|
+
import { requireAutomationManager, requireControlPlanePanelDeps, requireHookPanelDeps, requirePluginManager, requireUiServices } from './shared.ts';
|
|
41
40
|
|
|
42
41
|
export function registerOperationsPanels(manager: PanelManager, deps: ResolvedBuiltinPanelDeps): void {
|
|
43
42
|
const ui = requireUiServices(deps);
|
|
@@ -185,15 +184,6 @@ export function registerOperationsPanels(manager: PanelManager, deps: ResolvedBu
|
|
|
185
184
|
factory: () => new WorktreePanel(deps.worktreeRegistry),
|
|
186
185
|
});
|
|
187
186
|
|
|
188
|
-
manager.registerType({
|
|
189
|
-
id: 'mcp',
|
|
190
|
-
name: 'MCP',
|
|
191
|
-
icon: 'Z',
|
|
192
|
-
category: 'monitoring',
|
|
193
|
-
description: 'MCP trust, role, path scope, host scope, and connection status',
|
|
194
|
-
factory: () => new McpPanel(requireMcpRegistry(deps)),
|
|
195
|
-
});
|
|
196
|
-
|
|
197
187
|
manager.registerType({
|
|
198
188
|
id: 'hooks',
|
|
199
189
|
name: 'Hooks',
|
|
@@ -112,7 +112,7 @@ export interface BuiltinPanelDeps {
|
|
|
112
112
|
hookWorkbench?: HookWorkbench;
|
|
113
113
|
/** Shared hook activity tracker for the hooks control-room panel. */
|
|
114
114
|
hookActivityTracker?: Pick<HookActivityTracker, 'listRecent'>;
|
|
115
|
-
/** Shared MCP registry for
|
|
115
|
+
/** Shared MCP registry for security panels and MCP workspace commands. */
|
|
116
116
|
mcpRegistry?: McpRegistry;
|
|
117
117
|
}
|
|
118
118
|
|
|
@@ -268,7 +268,7 @@ export function requireHookPanelDeps(deps: BuiltinPanelDeps): {
|
|
|
268
268
|
|
|
269
269
|
export function requireMcpRegistry(deps: BuiltinPanelDeps): McpRegistry {
|
|
270
270
|
if (!deps.mcpRegistry) {
|
|
271
|
-
throw new Error('MCP registry must be wired at bootstrap for
|
|
271
|
+
throw new Error('MCP registry must be wired at bootstrap for security panels and MCP workspace commands.');
|
|
272
272
|
}
|
|
273
273
|
return deps.mcpRegistry;
|
|
274
274
|
}
|