fix: skills/MCP/completion system overhaul — separate skill kinds, fix MCP remote servers, deduplicate completion processing

Skills system:
- Replace `CommandBadge.isSkill: boolean` with `kind: SkillKind` (agent_skill | slash_command | sdk_command | codepilot_command)
- handleSubmit dispatches by kind: agent_skill retains skill name in prompt, slash/sdk commands pass through to SDK, codepilot commands expand via COMMAND_PROMPTS
- Popover groups commands into Commands / Slash Commands / Agent Skills
- API `/api/skills` returns `kind` field; `displayOverride` persisted through full chain (frontend → API → DB)
- Forward SDK `system:init` slash_commands/skills metadata to frontend via SSE

MCP management:
- Fix remote server (SSE/HTTP) creation: validate `url` instead of `command`
- Fix dual-config read/write asymmetry: PUT routes servers by `_source` to correct file (~/.claude.json vs ~/.claude/settings.json)
- DELETE checks both config files; POST checks both for name collision
- McpManager preserves `_source` tags on edit/rename; JSON editor only manages settings.json servers

Completion processing:
- Extract core logic into `onboarding-processor.ts` and `checkin-processor.ts` (shared by route handlers and server-side detection)
- Remove frontend `detectAssistantCompletion` from ChatView — server-side path in `finally` block is now the single source of truth
- Replace hard-coded `fetch("http://localhost:PORT/...")` with direct function imports
- Both processors are internally idempotent (check state before processing)

Other:
- `settingSources` always includes 'user' so SDK loads ~/.claude/skills/
- Add 24 unit tests for SkillKind dispatch logic
- Add onboarding-completion extraction tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-03-10 12:49:43 +08:00
1 parent 789b453e92
commit 281e7f9502
24 files changed
+1972 -465

No files matched your search

+50 -66
View File
@@ -102,6 +102,8 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
const pendingImageNoticesRef = useRef<string[]>([]);
// Ref for sendMessage to allow self-referencing in timeout auto-retry
const sendMessageRef = useRef<(content: string, files?: FileAttachment[]) => Promise<void>>(undefined);
// Init metadata from SDK (tools, slash_commands, skills) — populated on first status event
const initMetaRef = useRef<{ tools?: unknown; slash_commands?: unknown; skills?: unknown } | null>(null);
const handleModeChange = useCallback((newMode: string) => {
setMode(newMode);
@@ -151,7 +153,8 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
// If stream completed while this ChatView was unmounted, consume finalMessageContent now.
// Re-fetch messages from DB to avoid duplicates (backend already persisted the reply).
if (existing.phase !== 'active' && existing.finalMessageContent) {
detectAssistantCompletion(existing.finalMessageContent);
// Completion processing (onboarding/checkin) is handled server-side only
// in the chat API's finally block — no frontend duplicate needed.
fetch(`/api/chat/sessions/${sessionId}/messages?limit=50`)
.then(res => res.ok ? res.json() : null)
.then(data => {
@@ -200,8 +203,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
// Append the final assistant message to the messages list
const finalContent = event.snapshot.finalMessageContent;
if (finalContent) {
// Check for assistant project completion signals
detectAssistantCompletion(finalContent);
// Completion processing (onboarding/checkin) is handled server-side only.
const assistantMessage: Message = {
id: 'temp-assistant-' + Date.now(),
@@ -278,6 +280,41 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
if (!needsOnboarding && !needsCheckIn) return;
// ── Compensation: check if a past message already contains a completion fence ──
// This handles the case where the server-side detection also missed (e.g. crash/restart)
// and the frontend is about to re-trigger onboarding unnecessarily.
if (needsOnboarding && initialMessages.length > 0) {
try {
const { extractCompletion } = await import('@/lib/onboarding-completion');
// Scan assistant messages from newest to oldest for an unprocessed completion
for (let i = initialMessages.length - 1; i >= 0; i--) {
const msg = initialMessages[i];
if (msg.role !== 'assistant') continue;
const completion = extractCompletion(msg.content);
if (completion?.type === 'onboarding') {
console.log('[ChatView] Found unprocessed onboarding completion in message history, compensating...');
const resp = await fetch('/api/workspace/onboarding', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answers: completion.answers, sessionId }),
});
if (resp.ok) {
await fetch('/api/workspace/hook-triggered', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: '__clear__' }),
}).catch(() => {});
console.log('[ChatView] Onboarding compensation succeeded, skipping re-trigger');
return; // Don't re-trigger onboarding
}
break; // Found fence but processing failed — fall through to re-trigger
}
}
} catch (e) {
console.error('[ChatView] Onboarding compensation check failed:', e);
}
}
// For daily check-in, only trigger in the most recent session for this workspace.
// This prevents older sessions from hijacking the check-in when reopened.
if (needsCheckIn) {
@@ -317,6 +354,10 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
sendMessageFn: (retryContent: string, retryFiles?: FileAttachment[]) => {
sendMessageRef.current?.(retryContent, retryFiles);
},
onInitMeta: (meta) => {
initMetaRef.current = meta;
console.log('[ChatView] SDK init meta received:', meta);
},
});
} catch (e) {
console.error('[ChatView] Assistant auto-trigger failed:', e);
@@ -433,69 +474,6 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
[sessionId, setPendingApprovalSessionId]
);
// Detect assistant project completion signals in final message content.
// Only processes fences when this session's workingDirectory matches the current assistant workspace path.
const detectAssistantCompletion = useCallback(async (content: string) => {
// Guard: only allow fence processing for the current assistant workspace session
if (!workingDirectory) return;
try {
const wsRes = await fetch('/api/settings/workspace');
if (!wsRes.ok) return;
const wsData = await wsRes.json();
if (!wsData.path || wsData.path !== workingDirectory) return;
} catch {
return;
}
const clearHookTriggered = () =>
fetch('/api/workspace/hook-triggered', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: '__clear__' }),
}).catch(() => {});
// Check for onboarding completion
const onboardingMatch = content.match(/```onboarding-complete\s*\r?\n([\s\S]*?)\r?\n\s*```/);
if (onboardingMatch) {
try {
const answers = JSON.parse(onboardingMatch[1].trim());
const resp = await fetch('/api/workspace/onboarding', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answers, sessionId }),
});
if (resp.ok) {
await clearHookTriggered();
} else {
console.error('[ChatView] Onboarding API returned', resp.status);
}
} catch (e) {
console.error('[ChatView] Onboarding completion failed:', e);
}
return;
}
// Check for check-in completion
const checkinMatch = content.match(/```checkin-complete\s*\r?\n([\s\S]*?)\r?\n\s*```/);
if (checkinMatch) {
try {
const answers = JSON.parse(checkinMatch[1].trim());
const resp = await fetch('/api/workspace/checkin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answers, sessionId }),
});
if (resp.ok) {
await clearHookTriggered();
} else {
console.error('[ChatView] Check-in API returned', resp.status);
}
} catch (e) {
console.error('[ChatView] Check-in completion failed:', e);
}
}
}, [sessionId, workingDirectory]);
// Build SDK thinking config from settings
const buildThinkingConfig = useCallback((): { type: string } | undefined => {
if (!thinkingMode || thinkingMode === 'adaptive') return { type: 'adaptive' };
@@ -550,6 +528,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
pendingImageNotices: notices,
effort: selectedEffort,
thinking: buildThinkingConfig(),
displayOverride,
onModeChanged: (sdkMode) => {
const uiMode = sdkMode === 'plan' ? 'plan' : 'code';
handleModeChange(uiMode);
@@ -557,6 +536,10 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
sendMessageFn: (retryContent: string, retryFiles?: FileAttachment[]) => {
sendMessageRef.current?.(retryContent, retryFiles);
},
onInitMeta: (meta) => {
initMetaRef.current = meta;
console.log('[ChatView] SDK init meta received:', meta);
},
});
},
[sessionId, isStreaming, mode, currentModel, currentProviderId, selectedEffort, buildThinkingConfig, handleModeChange]
@@ -734,6 +717,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
onAssistantTrigger={checkAssistantTrigger}
effort={selectedEffort}
onEffortChange={setSelectedEffort}
sdkInitMeta={initMetaRef.current}
/>
<ChatComposerActionBar
left={<ImageGenToggle />}
+124 -57
View File
@@ -33,7 +33,7 @@ import {
usePromptInputAttachments,
} from '@/components/ai-elements/prompt-input';
import type { ChatStatus } from 'ai';
import type { FileAttachment, ProviderModelGroup } from '@/types';
import type { FileAttachment, ProviderModelGroup, SkillKind } from '@/types';
import { nanoid } from 'nanoid';
import { SlashCommandButton } from './SlashCommandButton';
import { useImageGen } from '@/hooks/useImageGen';
@@ -101,6 +101,8 @@ interface MessageInputProps {
/** Effort selection lifted to parent for inclusion in the stream chain */
effort?: string;
onEffortChange?: (effort: string | undefined) => void;
/** SDK init metadata — when available, used to validate command/skill availability */
sdkInitMeta?: { tools?: unknown; slash_commands?: unknown; skills?: unknown } | null;
}
interface PopoverItem {
@@ -111,7 +113,8 @@ interface PopoverItem {
builtIn?: boolean;
immediate?: boolean;
installedSource?: "agents" | "claude";
source?: "global" | "project" | "plugin" | "installed";
source?: "global" | "project" | "plugin" | "installed" | "sdk";
kind?: SkillKind;
icon?: typeof CommandLineIcon;
}
@@ -119,7 +122,7 @@ interface CommandBadge {
command: string;
label: string;
description: string;
isSkill: boolean;
kind: SkillKind;
installedSource?: "agents" | "claude";
}
@@ -137,12 +140,12 @@ const BUILT_IN_COMMANDS: PopoverItem[] = [
{ label: 'help', value: '/help', description: 'Show available commands and tips', descriptionKey: 'messageInput.helpDesc', builtIn: true, immediate: true, icon: HelpCircleIcon },
{ label: 'clear', value: '/clear', description: 'Clear conversation history', descriptionKey: 'messageInput.clearDesc', builtIn: true, immediate: true, icon: Delete02Icon },
{ label: 'cost', value: '/cost', description: 'Show token usage statistics', descriptionKey: 'messageInput.costDesc', builtIn: true, immediate: true, icon: Coins01Icon },
{ label: 'compact', value: '/compact', description: 'Compress conversation context', descriptionKey: 'messageInput.compactDesc', builtIn: true, icon: FileZipIcon },
{ label: 'doctor', value: '/doctor', description: 'Diagnose project health', descriptionKey: 'messageInput.doctorDesc', builtIn: true, icon: Stethoscope02Icon },
{ label: 'init', value: '/init', description: 'Initialize CLAUDE.md for project', descriptionKey: 'messageInput.initDesc', builtIn: true, icon: FileEditIcon },
{ label: 'review', value: '/review', description: 'Review code quality', descriptionKey: 'messageInput.reviewDesc', builtIn: true, icon: SearchList01Icon },
{ label: 'terminal-setup', value: '/terminal-setup', description: 'Configure terminal settings', descriptionKey: 'messageInput.terminalSetupDesc', builtIn: true, icon: CommandLineIcon },
{ label: 'memory', value: '/memory', description: 'Edit project memory file', descriptionKey: 'messageInput.memoryDesc', builtIn: true, icon: BrainIcon },
{ label: 'compact', value: '/compact', description: 'Compress conversation context', descriptionKey: 'messageInput.compactDesc', builtIn: true, kind: 'sdk_command', icon: FileZipIcon },
{ label: 'doctor', value: '/doctor', description: 'Diagnose project health', descriptionKey: 'messageInput.doctorDesc', builtIn: true, kind: 'codepilot_command', icon: Stethoscope02Icon },
{ label: 'init', value: '/init', description: 'Initialize CLAUDE.md for project', descriptionKey: 'messageInput.initDesc', builtIn: true, kind: 'sdk_command', icon: FileEditIcon },
{ label: 'review', value: '/review', description: 'Review code quality', descriptionKey: 'messageInput.reviewDesc', builtIn: true, kind: 'sdk_command', icon: SearchList01Icon },
{ label: 'terminal-setup', value: '/terminal-setup', description: 'Configure terminal settings', descriptionKey: 'messageInput.terminalSetupDesc', builtIn: true, kind: 'codepilot_command', icon: CommandLineIcon },
{ label: 'memory', value: '/memory', description: 'Edit project memory file', descriptionKey: 'messageInput.memoryDesc', builtIn: true, kind: 'codepilot_command', icon: BrainIcon },
];
interface ModeOption {
@@ -380,6 +383,7 @@ export function MessageInput({
onAssistantTrigger,
effort: effortProp,
onEffortChange,
sdkInitMeta,
}: MessageInputProps) {
const { t } = useTranslation();
const imageGen = useImageGen();
@@ -489,25 +493,78 @@ export function MessageInput({
const data = await res.json();
const skills = data.skills || [];
apiSkills = skills
.map((s: { name: string; description: string; source?: "global" | "project" | "plugin" | "installed"; installedSource?: "agents" | "claude" }) => ({
.map((s: { name: string; description: string; source?: "global" | "project" | "plugin" | "installed" | "sdk"; kind?: SkillKind; installedSource?: "agents" | "claude" }) => ({
label: s.name,
value: `/${s.name}`,
description: s.description || "",
builtIn: false,
installedSource: s.installedSource,
source: s.source,
kind: s.kind || 'slash_command',
}));
}
} catch {
// API not available - just use built-in commands
}
// When SDK init metadata is available, use it as the truth source:
// 1. Filter out filesystem-scanned items that the SDK session didn't actually load
// 2. Add any SDK-reported commands/skills missing from the filesystem scan
// Note: SDK system:init reports slash_commands and skills as string[] (names only)
if (sdkInitMeta) {
const rawCmds = sdkInitMeta.slash_commands;
const rawSkills = sdkInitMeta.skills;
const sdkCommandNames = new Set(
Array.isArray(rawCmds) ? rawCmds.map(c => typeof c === 'string' ? c : (c as { name?: string })?.name).filter(Boolean) as string[] : []
);
const sdkSkillNames = new Set(
Array.isArray(rawSkills) ? rawSkills.map(s => typeof s === 'string' ? s : (s as { name?: string })?.name).filter(Boolean) as string[] : []
);
// Only filter if SDK actually reported capabilities (non-empty arrays)
if (sdkCommandNames.size > 0 || sdkSkillNames.size > 0) {
apiSkills = apiSkills.filter(item => {
if (item.kind === 'agent_skill') return sdkSkillNames.has(item.label);
return sdkCommandNames.has(item.label);
});
}
const existingNames = new Set(apiSkills.map(s => s.label));
// Add SDK-reported commands not found in filesystem scan
for (const cmdName of sdkCommandNames) {
if (!existingNames.has(cmdName)) {
apiSkills.push({
label: cmdName,
value: `/${cmdName}`,
description: `SDK command: /${cmdName}`,
builtIn: false,
source: 'sdk',
kind: 'sdk_command',
});
}
}
// Add SDK-reported skills not found in filesystem scan
for (const skillName of sdkSkillNames) {
if (!existingNames.has(skillName)) {
apiSkills.push({
label: skillName,
value: `/${skillName}`,
description: `Skill: /${skillName}`,
builtIn: false,
kind: 'agent_skill',
});
}
}
}
// Deduplicate: remove API skills that share a name with built-in commands
const builtInNames = new Set(BUILT_IN_COMMANDS.map(c => c.label));
const uniqueSkills = apiSkills.filter(s => !builtInNames.has(s.label));
return [...BUILT_IN_COMMANDS, ...uniqueSkills];
}, [workingDirectory]);
}, [workingDirectory, sdkInitMeta]);
// Close popover
const closePopover = useCallback(() => {
@@ -553,7 +610,7 @@ export function MessageInput({
command: item.value,
label: item.label,
description: item.description || '',
isSkill: !item.builtIn,
kind: item.kind || 'slash_command',
installedSource: item.installedSource,
});
setInputValue('');
@@ -682,41 +739,49 @@ export function MessageInput({
return;
}
// If badge is active, expand the command/skill and send
// If badge is active, dispatch by kind
if (badge && !isStreaming) {
let expandedPrompt = '';
if (badge.isSkill) {
// Fetch skill content from API
try {
const detailParams = new URLSearchParams();
if (badge.installedSource) detailParams.set("source", badge.installedSource);
if (workingDirectory) detailParams.set("cwd", workingDirectory);
const qs = detailParams.toString();
const res = await fetch(
`/api/skills/${encodeURIComponent(badge.label)}${qs ? `?${qs}` : ""}`
);
if (res.ok) {
const data = await res.json();
expandedPrompt = data.skill?.content || '';
}
} catch {
// Fallback: use command name
}
} else {
// Built-in prompt command expansion
expandedPrompt = COMMAND_PROMPTS[badge.command] || '';
}
const finalPrompt = content
? `${expandedPrompt}\n\nUser context: ${content}`
: expandedPrompt || badge.command;
const files = await convertFiles();
setBadge(null);
setInputValue('');
onSend(finalPrompt, files.length > 0 ? files : undefined);
return;
const displayLabel = `/${badge.label}`;
switch (badge.kind) {
case 'agent_skill': {
// Agent skills: SDK loads SKILL.md natively from filesystem.
// Always include skill name so Claude knows which skill was selected.
// User context is appended after the skill reference.
const agentPrompt = content
? `Use the ${badge.label} skill. User context: ${content}`
: `Please use the ${badge.label} skill.`;
setBadge(null);
setInputValue('');
onSend(agentPrompt, files.length > 0 ? files : undefined, undefined, displayLabel);
return;
}
case 'slash_command':
case 'sdk_command': {
// Slash commands & SDK commands: send "/{command} {context}" as-is for SDK to handle
const slashPrompt = content
? `${badge.command} ${content}`
: badge.command;
setBadge(null);
setInputValue('');
onSend(slashPrompt, files.length > 0 ? files : undefined, undefined, displayLabel);
return;
}
case 'codepilot_command': {
// CodePilot-specific commands: expand via COMMAND_PROMPTS, show /command in bubble
const expandedPrompt = COMMAND_PROMPTS[badge.command] || '';
const finalPrompt = content
? `${expandedPrompt}\n\nUser context: ${content}`
: expandedPrompt || badge.command;
setBadge(null);
setInputValue('');
onSend(finalPrompt, files.length > 0 ? files : undefined, undefined, displayLabel);
return;
}
}
}
const files = await convertFiles();
@@ -738,20 +803,20 @@ export function MessageInput({
command: cmd.value,
label: cmd.label,
description: cmd.description || '',
isSkill: false,
kind: cmd.kind || 'sdk_command',
});
setInputValue('');
return;
}
// Not a built-in command — treat as a skill
// Not a built-in command — default to slash_command (SDK will handle)
const skillName = content.slice(1);
if (skillName) {
setBadge({
command: content,
label: skillName,
description: '',
isSkill: true,
kind: 'slash_command',
});
setInputValue('');
return;
@@ -972,8 +1037,8 @@ export function MessageInput({
{/* Popover */}
{popoverMode && (allDisplayedItems.length > 0 || aiSearchLoading) && (() => {
const builtInItems = filteredItems.filter(item => item.builtIn);
const projectItems = filteredItems.filter(item => !item.builtIn && item.source === 'project');
const skillItems = filteredItems.filter(item => !item.builtIn && item.source !== 'project');
const slashCommandItems = filteredItems.filter(item => !item.builtIn && item.kind !== 'agent_skill');
const agentSkillItems = filteredItems.filter(item => !item.builtIn && item.kind === 'agent_skill');
let globalIdx = 0;
const renderItem = (item: PopoverItem, idx: number) => (
@@ -991,7 +1056,9 @@ export function MessageInput({
<HugeiconsIcon icon={AtIcon} className="h-4 w-4 shrink-0 text-muted-foreground" />
) : item.builtIn && item.icon ? (
<HugeiconsIcon icon={item.icon} className="h-4 w-4 shrink-0 text-muted-foreground" />
) : !item.builtIn && item.source === 'project' ? (
) : item.kind === 'agent_skill' ? (
<HugeiconsIcon icon={BrainIcon} className="h-4 w-4 shrink-0 text-muted-foreground" />
) : item.kind === 'slash_command' ? (
<HugeiconsIcon icon={FileEditIcon} className="h-4 w-4 shrink-0 text-muted-foreground" />
) : !item.builtIn ? (
<HugeiconsIcon icon={GlobalIcon} className="h-4 w-4 shrink-0 text-muted-foreground" />
@@ -1077,23 +1144,23 @@ export function MessageInput({
})}
</>
)}
{projectItems.length > 0 && (
{slashCommandItems.length > 0 && (
<>
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
Project Commands
Slash Commands
</div>
{projectItems.map((item) => {
{slashCommandItems.map((item) => {
const idx = globalIdx++;
return renderItem(item, idx);
})}
</>
)}
{skillItems.length > 0 && (
{agentSkillItems.length > 0 && (
<>
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
Skills
Agent Skills
</div>
{skillItems.map((item) => {
{agentSkillItems.map((item) => {
const idx = globalIdx++;
return renderItem(item, idx);
})}
+44 -7
View File
@@ -19,9 +19,11 @@ interface McpRuntimeStatus {
serverInfo?: { name: string; version: string };
}
type MCPServerWithSource = MCPServer & { _source?: string };
export function McpManager() {
const { t } = useTranslation();
const [servers, setServers] = useState<Record<string, MCPServer>>({});
const [servers, setServers] = useState<Record<string, MCPServerWithSource>>({});
const [loading, setLoading] = useState(true);
const [editorOpen, setEditorOpen] = useState(false);
const [editingName, setEditingName] = useState<string | undefined>();
@@ -116,9 +118,11 @@ export function McpManager() {
async function handleSave(name: string, server: MCPServer) {
if (editingName && editingName !== name) {
// Rename: preserve _source from the original entry
const original = servers[editingName];
const updated = { ...servers };
delete updated[editingName];
updated[name] = server;
updated[name] = original?._source ? { ...server, _source: original._source } : server;
try {
await fetch("/api/plugins/mcp", {
method: "PUT",
@@ -130,7 +134,10 @@ export function McpManager() {
console.error("Failed to save MCP server:", err);
}
} else if (editingName) {
const updated = { ...servers, [name]: server };
// Edit in-place: preserve _source
const original = servers[editingName];
const serverWithSource = original?._source ? { ...server, _source: original._source } : server;
const updated = { ...servers, [name]: serverWithSource };
try {
await fetch("/api/plugins/mcp", {
method: "PUT",
@@ -162,13 +169,26 @@ export function McpManager() {
async function handleJsonSave(jsonStr: string) {
try {
const parsed = JSON.parse(jsonStr);
const parsed = JSON.parse(jsonStr) as Record<string, MCPServer>;
// JSON editor only manages settings.json servers.
// Merge back: keep claude.json servers untouched, replace settings.json servers.
const claudeJsonServers: Record<string, MCPServerWithSource> = {};
for (const [name, server] of Object.entries(servers)) {
if (server._source === 'claude.json') {
claudeJsonServers[name] = server;
}
}
const settingsServers: Record<string, MCPServerWithSource> = {};
for (const [name, server] of Object.entries(parsed)) {
settingsServers[name] = { ...server, _source: 'settings.json' };
}
const merged = { ...claudeJsonServers, ...settingsServers };
await fetch("/api/plugins/mcp", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mcpServers: parsed }),
body: JSON.stringify({ mcpServers: merged }),
});
setServers(parsed);
setServers(merged);
} catch (err) {
console.error("Failed to save MCP config:", err);
}
@@ -234,8 +254,25 @@ export function McpManager() {
</TabsContent>
<TabsContent value="json" className="mt-4">
{Object.values(servers).some(s => s._source === 'claude.json') && (
<p className="text-xs text-muted-foreground mb-2">
Servers from ~/.claude.json are managed by Claude CLI and not shown here.
Use the list tab to edit or delete them.
</p>
)}
<ConfigEditor
value={JSON.stringify(servers, null, 2)}
value={JSON.stringify(
Object.fromEntries(
Object.entries(servers)
.filter(([, v]) => v._source !== 'claude.json')
.map(([k, v]) => {
const { _source, ...rest } = v;
return [k, rest];
})
),
null,
2,
)}
onSave={handleJsonSave}
label={t('mcp.serverConfig')}
/>
@@ -162,7 +162,6 @@ export function McpServerEditor({
...(env ? { env } : {}),
}
: {
command: '',
type: serverType,
...(url ? { url: url.trim() } : {}),
...(serverArgs.length > 0 ? { args: serverArgs } : {}),