@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.
- package/LICENSE +674 -0
- package/README.md +412 -0
- package/lib/api.d.ts +288 -0
- package/lib/api.js +927 -0
- package/lib/cell-output-bundle.d.ts +25 -0
- package/lib/cell-output-bundle.js +129 -0
- package/lib/cell-output-toolbar.d.ts +26 -0
- package/lib/cell-output-toolbar.js +188 -0
- package/lib/chat-progress-feedback.d.ts +3 -0
- package/lib/chat-progress-feedback.js +27 -0
- package/lib/chat-sidebar.d.ts +92 -0
- package/lib/chat-sidebar.js +3452 -0
- package/lib/command-ids.d.ts +39 -0
- package/lib/command-ids.js +44 -0
- package/lib/components/ask-user-question.d.ts +2 -0
- package/lib/components/ask-user-question.js +85 -0
- package/lib/components/checkbox.d.ts +2 -0
- package/lib/components/checkbox.js +30 -0
- package/lib/components/claude-mcp-panel.d.ts +2 -0
- package/lib/components/claude-mcp-panel.js +275 -0
- package/lib/components/claude-mcp-paste.d.ts +7 -0
- package/lib/components/claude-mcp-paste.js +104 -0
- package/lib/components/claude-session-picker.d.ts +8 -0
- package/lib/components/claude-session-picker.js +127 -0
- package/lib/components/form-dialog.d.ts +25 -0
- package/lib/components/form-dialog.js +35 -0
- package/lib/components/launcher-picker.d.ts +6 -0
- package/lib/components/launcher-picker.js +135 -0
- package/lib/components/mcp-util.d.ts +2 -0
- package/lib/components/mcp-util.js +37 -0
- package/lib/components/notebook-generation-popover.d.ts +7 -0
- package/lib/components/notebook-generation-popover.js +60 -0
- package/lib/components/pill.d.ts +2 -0
- package/lib/components/pill.js +5 -0
- package/lib/components/plugins-panel.d.ts +3 -0
- package/lib/components/plugins-panel.js +466 -0
- package/lib/components/settings-panel.d.ts +11 -0
- package/lib/components/settings-panel.js +742 -0
- package/lib/components/skills-panel.d.ts +2 -0
- package/lib/components/skills-panel.js +1264 -0
- package/lib/handler.d.ts +8 -0
- package/lib/handler.js +36 -0
- package/lib/icons.d.ts +45 -0
- package/lib/icons.js +54 -0
- package/lib/index.d.ts +8 -0
- package/lib/index.js +2079 -0
- package/lib/markdown-renderer.d.ts +10 -0
- package/lib/markdown-renderer.js +64 -0
- package/lib/notebook-generation-toolbar.d.ts +16 -0
- package/lib/notebook-generation-toolbar.js +197 -0
- package/lib/notebook-generation.d.ts +8 -0
- package/lib/notebook-generation.js +12 -0
- package/lib/open-file-refresh-watcher-env.d.ts +4 -0
- package/lib/open-file-refresh-watcher-env.js +33 -0
- package/lib/open-file-refresh-watcher.d.ts +97 -0
- package/lib/open-file-refresh-watcher.js +190 -0
- package/lib/shell-utils.d.ts +6 -0
- package/lib/shell-utils.js +9 -0
- package/lib/task-target-notebook.d.ts +2 -0
- package/lib/task-target-notebook.js +28 -0
- package/lib/terminal-drag-format.d.ts +9 -0
- package/lib/terminal-drag-format.js +23 -0
- package/lib/terminal-drag.d.ts +12 -0
- package/lib/terminal-drag.js +268 -0
- package/lib/tokens.d.ts +149 -0
- package/lib/tokens.js +88 -0
- package/lib/tour/tour-anchors.d.ts +18 -0
- package/lib/tour/tour-anchors.js +18 -0
- package/lib/tour/tour-config.d.ts +66 -0
- package/lib/tour/tour-config.js +99 -0
- package/lib/tour/tour-defaults.json +58 -0
- package/lib/tour/tour-events.d.ts +19 -0
- package/lib/tour/tour-events.js +30 -0
- package/lib/tour/tour-overlay.d.ts +6 -0
- package/lib/tour/tour-overlay.js +350 -0
- package/lib/tour/tour-state.d.ts +20 -0
- package/lib/tour/tour-state.js +81 -0
- package/lib/tour/tour-steps.d.ts +33 -0
- package/lib/tour/tour-steps.js +216 -0
- package/lib/utils.d.ts +53 -0
- package/lib/utils.js +385 -0
- package/package.json +258 -0
- package/schema/plugin.json +42 -0
- package/src/api.ts +1424 -0
- package/src/cell-output-bundle.ts +176 -0
- package/src/cell-output-toolbar.ts +232 -0
- package/src/chat-progress-feedback.ts +35 -0
- package/src/chat-sidebar.tsx +5147 -0
- package/src/command-ids.ts +67 -0
- package/src/components/ask-user-question.tsx +151 -0
- package/src/components/checkbox.tsx +62 -0
- package/src/components/claude-mcp-panel.tsx +543 -0
- package/src/components/claude-mcp-paste.ts +132 -0
- package/src/components/claude-session-picker.tsx +214 -0
- package/src/components/form-dialog.tsx +75 -0
- package/src/components/launcher-picker.tsx +237 -0
- package/src/components/mcp-util.ts +53 -0
- package/src/components/notebook-generation-popover.tsx +127 -0
- package/src/components/pill.tsx +15 -0
- package/src/components/plugins-panel.tsx +774 -0
- package/src/components/settings-panel.tsx +1631 -0
- package/src/components/skills-panel.tsx +2084 -0
- package/src/handler.ts +51 -0
- package/src/icons.ts +71 -0
- package/src/index.ts +2583 -0
- package/src/markdown-renderer.tsx +153 -0
- package/src/notebook-generation-toolbar.tsx +281 -0
- package/src/notebook-generation.ts +23 -0
- package/src/open-file-refresh-watcher-env.ts +52 -0
- package/src/open-file-refresh-watcher.ts +260 -0
- package/src/shell-utils.ts +10 -0
- package/src/svg.d.ts +4 -0
- package/src/task-target-notebook.ts +37 -0
- package/src/terminal-drag-format.ts +29 -0
- package/src/terminal-drag.ts +382 -0
- package/src/tokens.ts +171 -0
- package/src/tour/tour-anchors.ts +21 -0
- package/src/tour/tour-config.ts +160 -0
- package/src/tour/tour-events.ts +34 -0
- package/src/tour/tour-overlay.tsx +474 -0
- package/src/tour/tour-state.ts +87 -0
- package/src/tour/tour-steps.ts +281 -0
- package/src/utils.ts +455 -0
- package/style/base.css +3238 -0
- package/style/icons/cell-toolbar-bug.svg +5 -0
- package/style/icons/cell-toolbar-chat.svg +5 -0
- package/style/icons/cell-toolbar-sparkle.svg +5 -0
- package/style/icons/claude.svg +1 -0
- package/style/icons/copilot-warning.svg +1 -0
- package/style/icons/copilot.svg +1 -0
- package/style/icons/copy.svg +1 -0
- package/style/icons/openai.svg +1 -0
- package/style/icons/opencode.svg +1 -0
- package/style/icons/sparkles-warning.svg +5 -0
- package/style/icons/sparkles.svg +1 -0
- package/style/index.css +1 -0
- 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
|
+
}
|