@plmbr/notebook-intelligence 5.0.0

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 (137) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +412 -0
  3. package/lib/api.d.ts +288 -0
  4. package/lib/api.js +927 -0
  5. package/lib/cell-output-bundle.d.ts +25 -0
  6. package/lib/cell-output-bundle.js +129 -0
  7. package/lib/cell-output-toolbar.d.ts +26 -0
  8. package/lib/cell-output-toolbar.js +188 -0
  9. package/lib/chat-progress-feedback.d.ts +3 -0
  10. package/lib/chat-progress-feedback.js +27 -0
  11. package/lib/chat-sidebar.d.ts +92 -0
  12. package/lib/chat-sidebar.js +3452 -0
  13. package/lib/command-ids.d.ts +39 -0
  14. package/lib/command-ids.js +44 -0
  15. package/lib/components/ask-user-question.d.ts +2 -0
  16. package/lib/components/ask-user-question.js +85 -0
  17. package/lib/components/checkbox.d.ts +2 -0
  18. package/lib/components/checkbox.js +30 -0
  19. package/lib/components/claude-mcp-panel.d.ts +2 -0
  20. package/lib/components/claude-mcp-panel.js +275 -0
  21. package/lib/components/claude-mcp-paste.d.ts +7 -0
  22. package/lib/components/claude-mcp-paste.js +104 -0
  23. package/lib/components/claude-session-picker.d.ts +8 -0
  24. package/lib/components/claude-session-picker.js +127 -0
  25. package/lib/components/form-dialog.d.ts +25 -0
  26. package/lib/components/form-dialog.js +35 -0
  27. package/lib/components/launcher-picker.d.ts +6 -0
  28. package/lib/components/launcher-picker.js +135 -0
  29. package/lib/components/mcp-util.d.ts +2 -0
  30. package/lib/components/mcp-util.js +37 -0
  31. package/lib/components/notebook-generation-popover.d.ts +7 -0
  32. package/lib/components/notebook-generation-popover.js +60 -0
  33. package/lib/components/pill.d.ts +2 -0
  34. package/lib/components/pill.js +5 -0
  35. package/lib/components/plugins-panel.d.ts +3 -0
  36. package/lib/components/plugins-panel.js +466 -0
  37. package/lib/components/settings-panel.d.ts +11 -0
  38. package/lib/components/settings-panel.js +742 -0
  39. package/lib/components/skills-panel.d.ts +2 -0
  40. package/lib/components/skills-panel.js +1264 -0
  41. package/lib/handler.d.ts +8 -0
  42. package/lib/handler.js +36 -0
  43. package/lib/icons.d.ts +45 -0
  44. package/lib/icons.js +54 -0
  45. package/lib/index.d.ts +8 -0
  46. package/lib/index.js +2079 -0
  47. package/lib/markdown-renderer.d.ts +10 -0
  48. package/lib/markdown-renderer.js +64 -0
  49. package/lib/notebook-generation-toolbar.d.ts +16 -0
  50. package/lib/notebook-generation-toolbar.js +197 -0
  51. package/lib/notebook-generation.d.ts +8 -0
  52. package/lib/notebook-generation.js +12 -0
  53. package/lib/open-file-refresh-watcher-env.d.ts +4 -0
  54. package/lib/open-file-refresh-watcher-env.js +33 -0
  55. package/lib/open-file-refresh-watcher.d.ts +97 -0
  56. package/lib/open-file-refresh-watcher.js +190 -0
  57. package/lib/shell-utils.d.ts +6 -0
  58. package/lib/shell-utils.js +9 -0
  59. package/lib/task-target-notebook.d.ts +2 -0
  60. package/lib/task-target-notebook.js +28 -0
  61. package/lib/terminal-drag-format.d.ts +9 -0
  62. package/lib/terminal-drag-format.js +23 -0
  63. package/lib/terminal-drag.d.ts +12 -0
  64. package/lib/terminal-drag.js +268 -0
  65. package/lib/tokens.d.ts +149 -0
  66. package/lib/tokens.js +88 -0
  67. package/lib/tour/tour-anchors.d.ts +18 -0
  68. package/lib/tour/tour-anchors.js +18 -0
  69. package/lib/tour/tour-config.d.ts +66 -0
  70. package/lib/tour/tour-config.js +99 -0
  71. package/lib/tour/tour-defaults.json +58 -0
  72. package/lib/tour/tour-events.d.ts +19 -0
  73. package/lib/tour/tour-events.js +30 -0
  74. package/lib/tour/tour-overlay.d.ts +6 -0
  75. package/lib/tour/tour-overlay.js +350 -0
  76. package/lib/tour/tour-state.d.ts +20 -0
  77. package/lib/tour/tour-state.js +81 -0
  78. package/lib/tour/tour-steps.d.ts +33 -0
  79. package/lib/tour/tour-steps.js +216 -0
  80. package/lib/utils.d.ts +53 -0
  81. package/lib/utils.js +385 -0
  82. package/package.json +258 -0
  83. package/schema/plugin.json +42 -0
  84. package/src/api.ts +1424 -0
  85. package/src/cell-output-bundle.ts +176 -0
  86. package/src/cell-output-toolbar.ts +232 -0
  87. package/src/chat-progress-feedback.ts +35 -0
  88. package/src/chat-sidebar.tsx +5147 -0
  89. package/src/command-ids.ts +67 -0
  90. package/src/components/ask-user-question.tsx +151 -0
  91. package/src/components/checkbox.tsx +62 -0
  92. package/src/components/claude-mcp-panel.tsx +543 -0
  93. package/src/components/claude-mcp-paste.ts +132 -0
  94. package/src/components/claude-session-picker.tsx +214 -0
  95. package/src/components/form-dialog.tsx +75 -0
  96. package/src/components/launcher-picker.tsx +237 -0
  97. package/src/components/mcp-util.ts +53 -0
  98. package/src/components/notebook-generation-popover.tsx +127 -0
  99. package/src/components/pill.tsx +15 -0
  100. package/src/components/plugins-panel.tsx +774 -0
  101. package/src/components/settings-panel.tsx +1631 -0
  102. package/src/components/skills-panel.tsx +2084 -0
  103. package/src/handler.ts +51 -0
  104. package/src/icons.ts +71 -0
  105. package/src/index.ts +2583 -0
  106. package/src/markdown-renderer.tsx +153 -0
  107. package/src/notebook-generation-toolbar.tsx +281 -0
  108. package/src/notebook-generation.ts +23 -0
  109. package/src/open-file-refresh-watcher-env.ts +52 -0
  110. package/src/open-file-refresh-watcher.ts +260 -0
  111. package/src/shell-utils.ts +10 -0
  112. package/src/svg.d.ts +4 -0
  113. package/src/task-target-notebook.ts +37 -0
  114. package/src/terminal-drag-format.ts +29 -0
  115. package/src/terminal-drag.ts +382 -0
  116. package/src/tokens.ts +171 -0
  117. package/src/tour/tour-anchors.ts +21 -0
  118. package/src/tour/tour-config.ts +160 -0
  119. package/src/tour/tour-events.ts +34 -0
  120. package/src/tour/tour-overlay.tsx +474 -0
  121. package/src/tour/tour-state.ts +87 -0
  122. package/src/tour/tour-steps.ts +281 -0
  123. package/src/utils.ts +455 -0
  124. package/style/base.css +3238 -0
  125. package/style/icons/cell-toolbar-bug.svg +5 -0
  126. package/style/icons/cell-toolbar-chat.svg +5 -0
  127. package/style/icons/cell-toolbar-sparkle.svg +5 -0
  128. package/style/icons/claude.svg +1 -0
  129. package/style/icons/copilot-warning.svg +1 -0
  130. package/style/icons/copilot.svg +1 -0
  131. package/style/icons/copy.svg +1 -0
  132. package/style/icons/openai.svg +1 -0
  133. package/style/icons/opencode.svg +1 -0
  134. package/style/icons/sparkles-warning.svg +5 -0
  135. package/style/icons/sparkles.svg +1 -0
  136. package/style/index.css +1 -0
  137. package/style/index.js +1 -0
@@ -0,0 +1,543 @@
1
+ // Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>
2
+
3
+ import React, { useEffect, useRef, useState } from 'react';
4
+ import { Dialog, showDialog } from '@jupyterlab/apputils';
5
+ import {
6
+ ClaudeMCPScope,
7
+ ClaudeMCPTransport,
8
+ IClaudeMCPAddInput,
9
+ IClaudeMCPServer,
10
+ NBIAPI
11
+ } from '../api';
12
+ import { FormDialog } from './form-dialog';
13
+ import {
14
+ configToInput,
15
+ parseKVLines,
16
+ parseMcpJsonEntry
17
+ } from './claude-mcp-paste';
18
+
19
+ const SCOPES: ClaudeMCPScope[] = ['user', 'project', 'local'];
20
+ const TRANSPORTS: ClaudeMCPTransport[] = ['stdio', 'sse', 'http'];
21
+
22
+ const SCOPE_HINT: Record<ClaudeMCPScope, string> = {
23
+ user: 'available in all your projects',
24
+ project: 'shared via the project repo (.mcp.json)',
25
+ local: 'this project, this user only'
26
+ };
27
+
28
+ export function SettingsPanelComponentClaudeMCP(_props: any): JSX.Element {
29
+ const [servers, setServers] = useState<IClaudeMCPServer[]>([]);
30
+ const [loading, setLoading] = useState(true);
31
+ const [error, setError] = useState<string | null>(null);
32
+ const [addOpen, setAddOpen] = useState(false);
33
+ const [pendingRemoval, setPendingRemoval] = useState<IClaudeMCPServer | null>(
34
+ null
35
+ );
36
+ const [pendingToggle, setPendingToggle] = useState<IClaudeMCPServer | null>(
37
+ null
38
+ );
39
+
40
+ const refresh = async () => {
41
+ setLoading(true);
42
+ setError(null);
43
+ try {
44
+ const list = await NBIAPI.listClaudeMCPServers();
45
+ setServers(list);
46
+ } catch (e: any) {
47
+ setError(e?.message ?? String(e));
48
+ } finally {
49
+ setLoading(false);
50
+ }
51
+ };
52
+
53
+ useEffect(() => {
54
+ refresh();
55
+ }, []);
56
+
57
+ const handleRemove = async (srv: IClaudeMCPServer) => {
58
+ const ok = await showDialog({
59
+ title: 'Remove MCP server?',
60
+ body: `"${srv.name}" will be removed from Claude's ${srv.scope}-scope config.`,
61
+ buttons: [Dialog.cancelButton(), Dialog.warnButton({ label: 'Remove' })]
62
+ });
63
+ if (!ok.button.accept) {
64
+ return;
65
+ }
66
+ setPendingRemoval(srv);
67
+ try {
68
+ await NBIAPI.removeClaudeMCPServer(srv.name, srv.scope);
69
+ await refresh();
70
+ } catch (e: any) {
71
+ setError(`Failed to remove: ${e?.message ?? e}`);
72
+ } finally {
73
+ setPendingRemoval(null);
74
+ }
75
+ };
76
+
77
+ const handleToggleDisabled = async (srv: IClaudeMCPServer) => {
78
+ setPendingToggle(srv);
79
+ try {
80
+ await NBIAPI.setClaudeMCPServerDisabled(
81
+ srv.name,
82
+ srv.scope,
83
+ !srv.disabledForWorkspace
84
+ );
85
+ await refresh();
86
+ } catch (e: any) {
87
+ setError(`Failed to update workspace state: ${e?.message ?? e}`);
88
+ } finally {
89
+ setPendingToggle(null);
90
+ }
91
+ };
92
+
93
+ const handleAddSubmit = async (input: IClaudeMCPAddInput) => {
94
+ // Errors are rendered inside the dialog so they're not hidden behind the
95
+ // modal backdrop; rethrow so the dialog can keep itself open.
96
+ await NBIAPI.addClaudeMCPServer(input);
97
+ setAddOpen(false);
98
+ await refresh();
99
+ };
100
+
101
+ const grouped: Record<ClaudeMCPScope, IClaudeMCPServer[]> = {
102
+ user: [],
103
+ project: [],
104
+ local: []
105
+ };
106
+ for (const srv of servers) {
107
+ (grouped[srv.scope as ClaudeMCPScope] ?? grouped.user).push(srv);
108
+ }
109
+
110
+ return (
111
+ <div className="config-dialog-body nbi-skills-panel">
112
+ <div className="nbi-skills-header">
113
+ <div className="nbi-skills-title">Claude MCP</div>
114
+ <div className="nbi-skills-header-actions">
115
+ <button
116
+ className="jp-Dialog-button jp-mod-reject jp-mod-styled"
117
+ onClick={refresh}
118
+ disabled={loading}
119
+ title="Re-read Claude's MCP config from disk"
120
+ >
121
+ <div className="jp-Dialog-buttonLabel">
122
+ {loading ? 'Refreshing…' : 'Refresh'}
123
+ </div>
124
+ </button>
125
+ <button
126
+ className="jp-Dialog-button jp-mod-reject jp-mod-styled"
127
+ onClick={() => setAddOpen(true)}
128
+ >
129
+ <div className="jp-Dialog-buttonLabel">Add server</div>
130
+ </button>
131
+ </div>
132
+ </div>
133
+
134
+ <div className="nbi-info-banner" role="note">
135
+ These are Claude Code's own MCP servers (read from{' '}
136
+ <code>~/.claude.json</code>, project <code>.mcp.json</code>, and the
137
+ per-user-per-project block). Independent of the NBI MCP Servers tab.
138
+ </div>
139
+
140
+ {error && (
141
+ <div className="nbi-skills-error" role="alert">
142
+ {error}
143
+ </div>
144
+ )}
145
+
146
+ {SCOPES.map(scope => (
147
+ <ClaudeMCPScopeSection
148
+ key={scope}
149
+ scope={scope}
150
+ servers={grouped[scope]}
151
+ loading={loading}
152
+ pendingRemoval={pendingRemoval}
153
+ pendingToggle={pendingToggle}
154
+ onRemove={handleRemove}
155
+ onToggleDisabled={handleToggleDisabled}
156
+ />
157
+ ))}
158
+
159
+ {addOpen && (
160
+ <ClaudeMCPAddDialog
161
+ onCancel={() => setAddOpen(false)}
162
+ onSubmit={handleAddSubmit}
163
+ />
164
+ )}
165
+ </div>
166
+ );
167
+ }
168
+
169
+ function ClaudeMCPScopeSection(props: {
170
+ scope: ClaudeMCPScope;
171
+ servers: IClaudeMCPServer[];
172
+ loading: boolean;
173
+ pendingRemoval: IClaudeMCPServer | null;
174
+ pendingToggle: IClaudeMCPServer | null;
175
+ onRemove: (srv: IClaudeMCPServer) => void;
176
+ onToggleDisabled: (srv: IClaudeMCPServer) => void;
177
+ }) {
178
+ return (
179
+ <div className="nbi-skills-section">
180
+ <div
181
+ className="nbi-skills-section-caption"
182
+ title={SCOPE_HINT[props.scope]}
183
+ >
184
+ {props.scope.toUpperCase()}
185
+ </div>
186
+ {props.servers.length === 0 ? (
187
+ <div className="nbi-skills-empty">
188
+ {props.loading ? 'Loading…' : 'No servers in this scope.'}
189
+ </div>
190
+ ) : (
191
+ props.servers.map(srv => (
192
+ <ClaudeMCPRow
193
+ key={`${srv.scope}-${srv.name}`}
194
+ srv={srv}
195
+ removing={
196
+ props.pendingRemoval?.name === srv.name &&
197
+ props.pendingRemoval?.scope === srv.scope
198
+ }
199
+ toggling={
200
+ props.pendingToggle?.name === srv.name &&
201
+ props.pendingToggle?.scope === srv.scope
202
+ }
203
+ onRemove={() => props.onRemove(srv)}
204
+ onToggleDisabled={() => props.onToggleDisabled(srv)}
205
+ />
206
+ ))
207
+ )}
208
+ </div>
209
+ );
210
+ }
211
+
212
+ function ClaudeMCPRow(props: {
213
+ srv: IClaudeMCPServer;
214
+ removing: boolean;
215
+ toggling: boolean;
216
+ onRemove: () => void;
217
+ onToggleDisabled: () => void;
218
+ }) {
219
+ const { srv } = props;
220
+ const summary =
221
+ srv.transport === 'stdio'
222
+ ? [srv.command, ...srv.args].filter(Boolean).join(' ')
223
+ : srv.url;
224
+ const disabled = srv.disabledForWorkspace;
225
+ const toggleLabel = disabled
226
+ ? props.toggling
227
+ ? 'Enabling…'
228
+ : 'Enable for workspace'
229
+ : props.toggling
230
+ ? 'Disabling…'
231
+ : 'Disable for workspace';
232
+ const toggleTitle = disabled
233
+ ? 'Re-enable this server for the current Jupyter workspace'
234
+ : 'Hide this server from Claude in the current Jupyter workspace (other workspaces unaffected)';
235
+ return (
236
+ <div
237
+ className={`nbi-skill-row${disabled ? ' nbi-skill-row-disabled' : ''}`}
238
+ >
239
+ <div className="nbi-skill-row-main">
240
+ <div className="nbi-skill-row-name">
241
+ {srv.name}
242
+ {disabled && (
243
+ <span className="nbi-skill-row-badge">Disabled for workspace</span>
244
+ )}
245
+ </div>
246
+ <div className="nbi-skill-row-description">
247
+ <code>{srv.transport}</code>
248
+ {summary && <span>: {summary}</span>}
249
+ </div>
250
+ </div>
251
+ <div className="nbi-skill-row-actions" onClick={e => e.stopPropagation()}>
252
+ <button
253
+ className="jp-Dialog-button jp-mod-reject jp-mod-styled"
254
+ onClick={props.onToggleDisabled}
255
+ disabled={props.toggling}
256
+ title={toggleTitle}
257
+ >
258
+ <div className="jp-Dialog-buttonLabel">{toggleLabel}</div>
259
+ </button>
260
+ <button
261
+ className="jp-Dialog-button jp-mod-reject jp-mod-styled"
262
+ onClick={props.onRemove}
263
+ disabled={props.removing}
264
+ >
265
+ <div className="jp-Dialog-buttonLabel">
266
+ {props.removing ? 'Removing…' : 'Remove'}
267
+ </div>
268
+ </button>
269
+ </div>
270
+ </div>
271
+ );
272
+ }
273
+
274
+ type AddDialogMode = 'form' | 'json';
275
+
276
+ const JSON_PLACEHOLDER = `"server-key": {
277
+ "command": "uvx",
278
+ "args": ["server-package@latest"]
279
+ }`;
280
+
281
+ const FORM_TAB_ID = 'nbi-mcp-add-tab-form';
282
+ const JSON_TAB_ID = 'nbi-mcp-add-tab-json';
283
+ const FORM_PANEL_ID = 'nbi-mcp-add-panel-form';
284
+ const JSON_PANEL_ID = 'nbi-mcp-add-panel-json';
285
+
286
+ function ClaudeMCPAddDialog(props: {
287
+ onCancel: () => void;
288
+ onSubmit: (input: IClaudeMCPAddInput) => Promise<void>;
289
+ }) {
290
+ const [scope, setScope] = useState<ClaudeMCPScope>('user');
291
+ const [mode, setMode] = useState<AddDialogMode>('form');
292
+ const [transport, setTransport] = useState<ClaudeMCPTransport>('stdio');
293
+ const [name, setName] = useState('');
294
+ const [commandOrUrl, setCommandOrUrl] = useState('');
295
+ const [argsText, setArgsText] = useState('');
296
+ const [envText, setEnvText] = useState('');
297
+ const [headersText, setHeadersText] = useState('');
298
+ const [jsonText, setJsonText] = useState('');
299
+ const [submitting, setSubmitting] = useState(false);
300
+ const [submitError, setSubmitError] = useState<string | null>(null);
301
+
302
+ const nameInputRef = useRef<HTMLInputElement>(null);
303
+ const jsonInputRef = useRef<HTMLTextAreaElement>(null);
304
+ const formTabRef = useRef<HTMLButtonElement>(null);
305
+ const jsonTabRef = useRef<HTMLButtonElement>(null);
306
+ // Skip the first effect run so we don't steal focus from the Scope select
307
+ // when the dialog mounts; only re-focus when the user actively switches.
308
+ const hasMountedRef = useRef(false);
309
+
310
+ useEffect(() => {
311
+ if (!hasMountedRef.current) {
312
+ hasMountedRef.current = true;
313
+ return;
314
+ }
315
+ if (mode === 'json') {
316
+ jsonInputRef.current?.focus();
317
+ } else {
318
+ nameInputRef.current?.focus();
319
+ }
320
+ }, [mode]);
321
+
322
+ const canSubmit =
323
+ mode === 'json'
324
+ ? jsonText.trim().length > 0 && !submitting
325
+ : name.trim() && commandOrUrl.trim() && !submitting;
326
+
327
+ const handleSubmit = async () => {
328
+ if (!canSubmit) {
329
+ return;
330
+ }
331
+ setSubmitError(null);
332
+ setSubmitting(true);
333
+ try {
334
+ let input: IClaudeMCPAddInput;
335
+ if (mode === 'json') {
336
+ const { name: parsedName, config } = parseMcpJsonEntry(jsonText);
337
+ input = configToInput(parsedName, config, scope);
338
+ } else {
339
+ const argsList = argsText
340
+ .split('\n')
341
+ .map(line => line.trim())
342
+ .filter(Boolean);
343
+ input = {
344
+ name: name.trim(),
345
+ scope,
346
+ transport,
347
+ commandOrUrl: commandOrUrl.trim(),
348
+ args: transport === 'stdio' ? argsList : undefined,
349
+ env: transport === 'stdio' ? parseKVLines(envText, '=') : undefined,
350
+ headers:
351
+ transport === 'stdio' ? undefined : parseKVLines(headersText, ':')
352
+ };
353
+ }
354
+ await props.onSubmit(input);
355
+ } catch (e: any) {
356
+ setSubmitError(e?.message ?? String(e));
357
+ } finally {
358
+ setSubmitting(false);
359
+ }
360
+ };
361
+
362
+ const onTabsKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
363
+ if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') {
364
+ return;
365
+ }
366
+ event.preventDefault();
367
+ const next: AddDialogMode = mode === 'form' ? 'json' : 'form';
368
+ setMode(next);
369
+ (next === 'form' ? formTabRef : jsonTabRef).current?.focus();
370
+ };
371
+
372
+ return (
373
+ <FormDialog
374
+ title="Add MCP server"
375
+ submitLabel="Add"
376
+ submitInProgressLabel="Adding…"
377
+ canSubmit={Boolean(canSubmit)}
378
+ submitting={submitting}
379
+ error={submitError}
380
+ onCancel={props.onCancel}
381
+ onSubmit={handleSubmit}
382
+ >
383
+ <div className="nbi-form-field">
384
+ <label htmlFor="nbi-mcp-add-scope">Scope</label>
385
+ <select
386
+ id="nbi-mcp-add-scope"
387
+ value={scope}
388
+ onChange={e => setScope(e.target.value as ClaudeMCPScope)}
389
+ >
390
+ {SCOPES.map(s => (
391
+ <option key={s} value={s}>
392
+ {s} — {SCOPE_HINT[s]}
393
+ </option>
394
+ ))}
395
+ </select>
396
+ </div>
397
+ <div className="nbi-form-field">
398
+ <label id="nbi-mcp-add-input-mode-label">Input mode</label>
399
+ <div
400
+ className="nbi-segmented-control"
401
+ role="tablist"
402
+ aria-labelledby="nbi-mcp-add-input-mode-label"
403
+ onKeyDown={onTabsKeyDown}
404
+ >
405
+ <button
406
+ type="button"
407
+ role="tab"
408
+ id={FORM_TAB_ID}
409
+ ref={formTabRef}
410
+ aria-selected={mode === 'form'}
411
+ aria-controls={FORM_PANEL_ID}
412
+ tabIndex={mode === 'form' ? 0 : -1}
413
+ className={`nbi-segmented-control-option${mode === 'form' ? ' is-active' : ''}`}
414
+ onClick={() => setMode('form')}
415
+ >
416
+ Form
417
+ </button>
418
+ <button
419
+ type="button"
420
+ role="tab"
421
+ id={JSON_TAB_ID}
422
+ ref={jsonTabRef}
423
+ aria-selected={mode === 'json'}
424
+ aria-controls={JSON_PANEL_ID}
425
+ tabIndex={mode === 'json' ? 0 : -1}
426
+ className={`nbi-segmented-control-option${mode === 'json' ? ' is-active' : ''}`}
427
+ onClick={() => setMode('json')}
428
+ >
429
+ JSON
430
+ </button>
431
+ </div>
432
+ </div>
433
+ {mode === 'json' ? (
434
+ <div
435
+ className="nbi-form-field"
436
+ role="tabpanel"
437
+ id={JSON_PANEL_ID}
438
+ aria-labelledby={JSON_TAB_ID}
439
+ >
440
+ <label htmlFor="nbi-mcp-add-json">JSON</label>
441
+ <textarea
442
+ id="nbi-mcp-add-json"
443
+ ref={jsonInputRef}
444
+ rows={10}
445
+ value={jsonText}
446
+ onChange={e => setJsonText(e.target.value)}
447
+ placeholder={JSON_PLACEHOLDER}
448
+ spellCheck={false}
449
+ autoFocus
450
+ />
451
+ </div>
452
+ ) : (
453
+ <div
454
+ role="tabpanel"
455
+ id={FORM_PANEL_ID}
456
+ aria-labelledby={FORM_TAB_ID}
457
+ className="nbi-mcp-add-form-panel"
458
+ >
459
+ <div className="nbi-form-field">
460
+ <label htmlFor="nbi-mcp-add-name">Name</label>
461
+ <input
462
+ id="nbi-mcp-add-name"
463
+ ref={nameInputRef}
464
+ type="text"
465
+ value={name}
466
+ onChange={e => setName(e.target.value)}
467
+ placeholder="my-server"
468
+ autoFocus
469
+ />
470
+ </div>
471
+ <div className="nbi-form-field">
472
+ <label htmlFor="nbi-mcp-add-transport">Transport</label>
473
+ <select
474
+ id="nbi-mcp-add-transport"
475
+ value={transport}
476
+ onChange={e => setTransport(e.target.value as ClaudeMCPTransport)}
477
+ >
478
+ {TRANSPORTS.map(t => (
479
+ <option key={t} value={t}>
480
+ {t}
481
+ </option>
482
+ ))}
483
+ </select>
484
+ </div>
485
+ <div className="nbi-form-field">
486
+ <label htmlFor="nbi-mcp-add-command-or-url">
487
+ {transport === 'stdio' ? 'Command' : 'URL'}
488
+ </label>
489
+ <input
490
+ id="nbi-mcp-add-command-or-url"
491
+ type="text"
492
+ value={commandOrUrl}
493
+ onChange={e => setCommandOrUrl(e.target.value)}
494
+ placeholder={
495
+ transport === 'stdio' ? 'npx' : 'https://example.com/mcp'
496
+ }
497
+ />
498
+ </div>
499
+ {transport === 'stdio' && (
500
+ <div className="nbi-form-field">
501
+ <label htmlFor="nbi-mcp-add-args">Args (one per line)</label>
502
+ <textarea
503
+ id="nbi-mcp-add-args"
504
+ rows={3}
505
+ value={argsText}
506
+ onChange={e => setArgsText(e.target.value)}
507
+ placeholder={'-y\n@scope/package@latest'}
508
+ />
509
+ </div>
510
+ )}
511
+ {transport === 'stdio' && (
512
+ <div className="nbi-form-field">
513
+ <label htmlFor="nbi-mcp-add-env">
514
+ Environment (KEY=value, one per line)
515
+ </label>
516
+ <textarea
517
+ id="nbi-mcp-add-env"
518
+ rows={3}
519
+ value={envText}
520
+ onChange={e => setEnvText(e.target.value)}
521
+ placeholder="API_KEY=…"
522
+ />
523
+ </div>
524
+ )}
525
+ {transport !== 'stdio' && (
526
+ <div className="nbi-form-field">
527
+ <label htmlFor="nbi-mcp-add-headers">
528
+ Headers (Name: value, one per line)
529
+ </label>
530
+ <textarea
531
+ id="nbi-mcp-add-headers"
532
+ rows={3}
533
+ value={headersText}
534
+ onChange={e => setHeadersText(e.target.value)}
535
+ placeholder="Authorization: Bearer …"
536
+ />
537
+ </div>
538
+ )}
539
+ </div>
540
+ )}
541
+ </FormDialog>
542
+ );
543
+ }
@@ -0,0 +1,132 @@
1
+ // Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>
2
+
3
+ import { ClaudeMCPScope, ClaudeMCPTransport, IClaudeMCPAddInput } from '../api';
4
+
5
+ const VALID_TRANSPORTS: ReadonlyArray<ClaudeMCPTransport> = [
6
+ 'stdio',
7
+ 'sse',
8
+ 'http'
9
+ ];
10
+
11
+ // Accept any of these paste shapes:
12
+ // "server-key": { ... } (bare key + object)
13
+ // { "server-key": { ... } } (wrapped, one entry)
14
+ // { "mcpServers": { "server-key": { ... } } } (full mcp.json)
15
+ export function parseMcpJsonEntry(raw: string): {
16
+ name: string;
17
+ config: Record<string, any>;
18
+ } {
19
+ const trimmed = raw.trim().replace(/^,|,$/g, '');
20
+ if (!trimmed) {
21
+ throw new Error('JSON is empty.');
22
+ }
23
+
24
+ let parsed: any;
25
+ try {
26
+ parsed = JSON.parse(trimmed);
27
+ } catch {
28
+ try {
29
+ parsed = JSON.parse(`{${trimmed}}`);
30
+ } catch (e) {
31
+ throw new Error(`Invalid JSON: ${(e as Error).message}`);
32
+ }
33
+ }
34
+
35
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
36
+ throw new Error('JSON must describe a server entry.');
37
+ }
38
+ if (parsed.mcpServers && typeof parsed.mcpServers === 'object') {
39
+ parsed = parsed.mcpServers;
40
+ }
41
+
42
+ const keys = Object.keys(parsed);
43
+ if (keys.length === 0) {
44
+ throw new Error('Expected at least one server entry.');
45
+ }
46
+ if (keys.length > 1) {
47
+ throw new Error('Multiple servers found; paste one entry at a time.');
48
+ }
49
+ const name = keys[0];
50
+ const config = parsed[name];
51
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
52
+ throw new Error(`Server "${name}" config must be an object.`);
53
+ }
54
+ return { name, config };
55
+ }
56
+
57
+ export function configToInput(
58
+ name: string,
59
+ config: Record<string, any>,
60
+ scope: ClaudeMCPScope
61
+ ): IClaudeMCPAddInput {
62
+ const url = typeof config.url === 'string' ? config.url.trim() : '';
63
+ const command =
64
+ typeof config.command === 'string' ? config.command.trim() : '';
65
+ if (!url && !command) {
66
+ throw new Error('Server config must include "command" or "url".');
67
+ }
68
+
69
+ const explicitTransport =
70
+ typeof config.transport === 'string' &&
71
+ (VALID_TRANSPORTS as ReadonlyArray<string>).includes(config.transport)
72
+ ? (config.transport as ClaudeMCPTransport)
73
+ : null;
74
+
75
+ // If the user pasted an explicit `transport`, it must match the shape of
76
+ // the rest of the config: `stdio` requires `command`, `sse`/`http` require
77
+ // `url`. A mismatch is more likely a typo than intent, and silently
78
+ // downgrading to whatever shape we see is the footgun reviewers flagged.
79
+ if (explicitTransport === 'stdio' && !command) {
80
+ throw new Error('transport: "stdio" requires a "command" field.');
81
+ }
82
+ if ((explicitTransport === 'http' || explicitTransport === 'sse') && !url) {
83
+ throw new Error(
84
+ `transport: "${explicitTransport}" requires a "url" field.`
85
+ );
86
+ }
87
+
88
+ const transport: ClaudeMCPTransport =
89
+ explicitTransport ?? (url ? 'http' : 'stdio');
90
+ const commandOrUrl = transport === 'stdio' ? command : url;
91
+
92
+ return {
93
+ name,
94
+ scope,
95
+ transport,
96
+ commandOrUrl,
97
+ args:
98
+ transport === 'stdio' && Array.isArray(config.args)
99
+ ? config.args.map(String)
100
+ : undefined,
101
+ env: transport === 'stdio' ? toStringRecord(config.env) : undefined,
102
+ headers: transport !== 'stdio' ? toStringRecord(config.headers) : undefined
103
+ };
104
+ }
105
+
106
+ export function parseKVLines(
107
+ text: string,
108
+ separator: string
109
+ ): Record<string, string> {
110
+ const out: Record<string, string> = {};
111
+ for (const line of text.split('\n')) {
112
+ const trimmed = line.trim();
113
+ if (!trimmed) {
114
+ continue;
115
+ }
116
+ const idx = trimmed.indexOf(separator);
117
+ if (idx > 0) {
118
+ out[trimmed.slice(0, idx).trim()] = trimmed.slice(idx + 1).trim();
119
+ }
120
+ }
121
+ return out;
122
+ }
123
+
124
+ function toStringRecord(obj: any): Record<string, string> {
125
+ const out: Record<string, string> = {};
126
+ if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
127
+ for (const [k, v] of Object.entries(obj)) {
128
+ out[String(k)] = String(v);
129
+ }
130
+ }
131
+ return out;
132
+ }