mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 22:35:51 +00:00
456 lines
16 KiB
TypeScript
456 lines
16 KiB
TypeScript
/**
|
||
* MCP subcommand handlers — extracted from main.tsx for lazy loading.
|
||
* These are dynamically imported only when the corresponding `claude mcp *` command runs.
|
||
*/
|
||
|
||
import { stat } from 'fs/promises'
|
||
import pMap from 'p-map'
|
||
import { cwd } from 'process'
|
||
import React from 'react'
|
||
import { MCPServerDesktopImportDialog } from '../../components/MCPServerDesktopImportDialog.js'
|
||
import { wrappedRender as render } from '@anthropic/ink'
|
||
import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js'
|
||
import {
|
||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
logEvent,
|
||
} from '../../services/analytics/index.js'
|
||
import {
|
||
clearMcpClientConfig,
|
||
clearServerTokensFromLocalStorage,
|
||
getMcpClientConfig,
|
||
readClientSecret,
|
||
saveMcpClientSecret,
|
||
} from '../../services/mcp/auth.js'
|
||
import {
|
||
connectToServer,
|
||
getMcpServerConnectionBatchSize,
|
||
} from '../../services/mcp/client.js'
|
||
import {
|
||
addMcpConfig,
|
||
getAllMcpConfigs,
|
||
getMcpConfigByName,
|
||
getMcpConfigsByScope,
|
||
removeMcpConfig,
|
||
} from '../../services/mcp/config.js'
|
||
import type {
|
||
ConfigScope,
|
||
ScopedMcpServerConfig,
|
||
} from '../../services/mcp/types.js'
|
||
import {
|
||
describeMcpConfigFilePath,
|
||
ensureConfigScope,
|
||
getScopeLabel,
|
||
} from '../../services/mcp/utils.js'
|
||
import { AppStateProvider } from '../../state/AppState.js'
|
||
import {
|
||
getCurrentProjectConfig,
|
||
getGlobalConfig,
|
||
saveCurrentProjectConfig,
|
||
} from '../../utils/config.js'
|
||
import { isFsInaccessible } from '../../utils/errors.js'
|
||
import { gracefulShutdown } from '../../utils/gracefulShutdown.js'
|
||
import { safeParseJSON } from '../../utils/json.js'
|
||
import { getPlatform } from '../../utils/platform.js'
|
||
import { cliError, cliOk } from '../exit.js'
|
||
|
||
async function checkMcpServerHealth(
|
||
name: string,
|
||
server: ScopedMcpServerConfig,
|
||
): Promise<string> {
|
||
try {
|
||
const result = await connectToServer(name, server)
|
||
if (result.type === 'connected') {
|
||
return '✓ Connected'
|
||
} else if (result.type === 'needs-auth') {
|
||
return '! Needs authentication'
|
||
} else {
|
||
return '✗ Failed to connect'
|
||
}
|
||
} catch (_error) {
|
||
return '✗ Connection error'
|
||
}
|
||
}
|
||
|
||
// mcp serve (lines 4512–4532)
|
||
export async function mcpServeHandler({
|
||
debug,
|
||
verbose,
|
||
}: {
|
||
debug?: boolean
|
||
verbose?: boolean
|
||
}): Promise<void> {
|
||
const providedCwd = cwd()
|
||
logEvent('tengu_mcp_start', {})
|
||
|
||
try {
|
||
await stat(providedCwd)
|
||
} catch (error) {
|
||
if (isFsInaccessible(error)) {
|
||
cliError(`Error: Directory ${providedCwd} does not exist`)
|
||
}
|
||
throw error
|
||
}
|
||
|
||
try {
|
||
const { setup } = await import('../../setup.js')
|
||
await setup(providedCwd, 'default', false, false, undefined, false)
|
||
const { startMCPServer } = await import('../../entrypoints/mcp.js')
|
||
await startMCPServer(providedCwd, debug ?? false, verbose ?? false)
|
||
} catch (error) {
|
||
cliError(`Error: Failed to start MCP server: ${error}`)
|
||
}
|
||
}
|
||
|
||
// mcp remove (lines 4545–4635)
|
||
export async function mcpRemoveHandler(
|
||
name: string,
|
||
options: { scope?: string },
|
||
): Promise<void> {
|
||
// Look up config before removing so we can clean up secure storage
|
||
const serverBeforeRemoval = getMcpConfigByName(name)
|
||
|
||
const cleanupSecureStorage = () => {
|
||
if (
|
||
serverBeforeRemoval &&
|
||
(serverBeforeRemoval.type === 'sse' ||
|
||
serverBeforeRemoval.type === 'http')
|
||
) {
|
||
clearServerTokensFromLocalStorage(name, serverBeforeRemoval)
|
||
clearMcpClientConfig(name, serverBeforeRemoval)
|
||
}
|
||
}
|
||
|
||
try {
|
||
if (options.scope) {
|
||
const scope = ensureConfigScope(options.scope)
|
||
logEvent('tengu_mcp_delete', {
|
||
name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
scope:
|
||
scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
})
|
||
|
||
await removeMcpConfig(name, scope)
|
||
cleanupSecureStorage()
|
||
process.stdout.write(`Removed MCP server ${name} from ${scope} config\n`)
|
||
cliOk(`File modified: ${describeMcpConfigFilePath(scope)}`)
|
||
}
|
||
|
||
// If no scope specified, check where the server exists
|
||
const projectConfig = getCurrentProjectConfig()
|
||
const globalConfig = getGlobalConfig()
|
||
|
||
// Check if server exists in project scope (.mcp.json)
|
||
const { servers: projectServers } = getMcpConfigsByScope('project')
|
||
const mcpJsonExists = !!projectServers[name]
|
||
|
||
// Count how many scopes contain this server
|
||
const scopes: Array<Exclude<ConfigScope, 'dynamic'>> = []
|
||
if (projectConfig.mcpServers?.[name]) scopes.push('local')
|
||
if (mcpJsonExists) scopes.push('project')
|
||
if (globalConfig.mcpServers?.[name]) scopes.push('user')
|
||
|
||
if (scopes.length === 0) {
|
||
cliError(`No MCP server found with name: "${name}"`)
|
||
} else if (scopes.length === 1) {
|
||
// Server exists in only one scope, remove it
|
||
const scope = scopes[0]!
|
||
logEvent('tengu_mcp_delete', {
|
||
name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
scope:
|
||
scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
})
|
||
|
||
await removeMcpConfig(name, scope)
|
||
cleanupSecureStorage()
|
||
process.stdout.write(
|
||
`Removed MCP server "${name}" from ${scope} config\n`,
|
||
)
|
||
cliOk(`File modified: ${describeMcpConfigFilePath(scope)}`)
|
||
} else {
|
||
// Server exists in multiple scopes
|
||
process.stderr.write(`MCP server "${name}" exists in multiple scopes:\n`)
|
||
scopes.forEach(scope => {
|
||
process.stderr.write(
|
||
` - ${getScopeLabel(scope)} (${describeMcpConfigFilePath(scope)})\n`,
|
||
)
|
||
})
|
||
process.stderr.write('\nTo remove from a specific scope, use:\n')
|
||
scopes.forEach(scope => {
|
||
process.stderr.write(` claude mcp remove "${name}" -s ${scope}\n`)
|
||
})
|
||
cliError()
|
||
}
|
||
} catch (error) {
|
||
cliError((error as Error).message)
|
||
}
|
||
}
|
||
|
||
// mcp list (lines 4641–4688)
|
||
export async function mcpListHandler(): Promise<void> {
|
||
logEvent('tengu_mcp_list', {})
|
||
const { servers: configs } = await getAllMcpConfigs()
|
||
if (Object.keys(configs).length === 0) {
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(
|
||
'No MCP servers configured. Use `claude mcp add` to add a server.',
|
||
)
|
||
} else {
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log('Checking MCP server health...\n')
|
||
|
||
// Check servers concurrently
|
||
const entries = Object.entries(configs)
|
||
const results = await pMap(
|
||
entries,
|
||
async ([name, server]) => ({
|
||
name,
|
||
server,
|
||
status: await checkMcpServerHealth(name, server),
|
||
}),
|
||
{ concurrency: getMcpServerConnectionBatchSize() },
|
||
)
|
||
|
||
for (const { name, server, status } of results) {
|
||
// Intentionally excluding sse-ide servers here since they're internal
|
||
if (server.type === 'sse') {
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(`${name}: ${server.url} (SSE) - ${status}`)
|
||
} else if (server.type === 'http') {
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(`${name}: ${server.url} (HTTP) - ${status}`)
|
||
} else if (server.type === 'claudeai-proxy') {
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(`${name}: ${server.url} - ${status}`)
|
||
} else if (!server.type || server.type === 'stdio') {
|
||
const args = Array.isArray(server.args) ? server.args : []
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(`${name}: ${server.command} ${args.join(' ')} - ${status}`)
|
||
}
|
||
}
|
||
}
|
||
// Use gracefulShutdown to properly clean up MCP server connections
|
||
// (process.exit bypasses cleanup handlers, leaving child processes orphaned)
|
||
await gracefulShutdown(0)
|
||
}
|
||
|
||
// mcp get (lines 4694–4786)
|
||
export async function mcpGetHandler(name: string): Promise<void> {
|
||
logEvent('tengu_mcp_get', {
|
||
name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
})
|
||
const server = getMcpConfigByName(name)
|
||
if (!server) {
|
||
cliError(`No MCP server found with name: ${name}`)
|
||
}
|
||
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(`${name}:`)
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(` Scope: ${getScopeLabel(server.scope)}`)
|
||
|
||
// Check server health
|
||
const status = await checkMcpServerHealth(name, server)
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(` Status: ${status}`)
|
||
|
||
// Intentionally excluding sse-ide servers here since they're internal
|
||
if (server.type === 'sse') {
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(` Type: sse`)
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(` URL: ${server.url}`)
|
||
if (server.headers) {
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(' Headers:')
|
||
for (const [key, value] of Object.entries(server.headers)) {
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(` ${key}: ${value}`)
|
||
}
|
||
}
|
||
if (server.oauth?.clientId || server.oauth?.callbackPort) {
|
||
const parts: string[] = []
|
||
if (server.oauth.clientId) {
|
||
parts.push('client_id configured')
|
||
const clientConfig = getMcpClientConfig(name, server)
|
||
if (clientConfig?.clientSecret) parts.push('client_secret configured')
|
||
}
|
||
if (server.oauth.callbackPort)
|
||
parts.push(`callback_port ${server.oauth.callbackPort}`)
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(` OAuth: ${parts.join(', ')}`)
|
||
}
|
||
} else if (server.type === 'http') {
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(` Type: http`)
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(` URL: ${server.url}`)
|
||
if (server.headers) {
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(' Headers:')
|
||
for (const [key, value] of Object.entries(server.headers)) {
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(` ${key}: ${value}`)
|
||
}
|
||
}
|
||
if (server.oauth?.clientId || server.oauth?.callbackPort) {
|
||
const parts: string[] = []
|
||
if (server.oauth.clientId) {
|
||
parts.push('client_id configured')
|
||
const clientConfig = getMcpClientConfig(name, server)
|
||
if (clientConfig?.clientSecret) parts.push('client_secret configured')
|
||
}
|
||
if (server.oauth.callbackPort)
|
||
parts.push(`callback_port ${server.oauth.callbackPort}`)
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(` OAuth: ${parts.join(', ')}`)
|
||
}
|
||
} else if (server.type === 'stdio') {
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(` Type: stdio`)
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(` Command: ${server.command}`)
|
||
const args = Array.isArray(server.args) ? server.args : []
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(` Args: ${args.join(' ')}`)
|
||
if (server.env) {
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(' Environment:')
|
||
for (const [key, value] of Object.entries(server.env)) {
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(` ${key}=${value}`)
|
||
}
|
||
}
|
||
}
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(
|
||
`\nTo remove this server, run: claude mcp remove "${name}" -s ${server.scope}`,
|
||
)
|
||
// Use gracefulShutdown to properly clean up MCP server connections
|
||
// (process.exit bypasses cleanup handlers, leaving child processes orphaned)
|
||
await gracefulShutdown(0)
|
||
}
|
||
|
||
// mcp add-json (lines 4801–4870)
|
||
export async function mcpAddJsonHandler(
|
||
name: string,
|
||
json: string,
|
||
options: { scope?: string; clientSecret?: true },
|
||
): Promise<void> {
|
||
try {
|
||
const scope = ensureConfigScope(options.scope)
|
||
const parsedJson = safeParseJSON(json)
|
||
|
||
// Read secret before writing config so cancellation doesn't leave partial state
|
||
const needsSecret =
|
||
options.clientSecret &&
|
||
parsedJson &&
|
||
typeof parsedJson === 'object' &&
|
||
'type' in parsedJson &&
|
||
(parsedJson.type === 'sse' || parsedJson.type === 'http') &&
|
||
'url' in parsedJson &&
|
||
typeof parsedJson.url === 'string' &&
|
||
'oauth' in parsedJson &&
|
||
parsedJson.oauth &&
|
||
typeof parsedJson.oauth === 'object' &&
|
||
'clientId' in parsedJson.oauth
|
||
const clientSecret = needsSecret ? await readClientSecret() : undefined
|
||
|
||
await addMcpConfig(name, parsedJson, scope)
|
||
|
||
const transportType =
|
||
parsedJson && typeof parsedJson === 'object' && 'type' in parsedJson
|
||
? String(parsedJson.type || 'stdio')
|
||
: 'stdio'
|
||
|
||
if (
|
||
clientSecret &&
|
||
parsedJson &&
|
||
typeof parsedJson === 'object' &&
|
||
'type' in parsedJson &&
|
||
(parsedJson.type === 'sse' || parsedJson.type === 'http') &&
|
||
'url' in parsedJson &&
|
||
typeof parsedJson.url === 'string'
|
||
) {
|
||
saveMcpClientSecret(
|
||
name,
|
||
{ type: parsedJson.type, url: parsedJson.url },
|
||
clientSecret,
|
||
)
|
||
}
|
||
|
||
logEvent('tengu_mcp_add', {
|
||
scope:
|
||
scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
source:
|
||
'json' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
type: transportType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
})
|
||
|
||
cliOk(`Added ${transportType} MCP server ${name} to ${scope} config`)
|
||
} catch (error) {
|
||
cliError((error as Error).message)
|
||
}
|
||
}
|
||
|
||
// mcp add-from-claude-desktop (lines 4881–4927)
|
||
export async function mcpAddFromDesktopHandler(options: {
|
||
scope?: string
|
||
}): Promise<void> {
|
||
try {
|
||
const scope = ensureConfigScope(options.scope)
|
||
const platform = getPlatform()
|
||
|
||
logEvent('tengu_mcp_add', {
|
||
scope:
|
||
scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
platform:
|
||
platform as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
source:
|
||
'desktop' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
})
|
||
|
||
const { readClaudeDesktopMcpServers } = await import(
|
||
'../../utils/claudeDesktop.js'
|
||
)
|
||
const servers = await readClaudeDesktopMcpServers()
|
||
|
||
if (Object.keys(servers).length === 0) {
|
||
cliOk(
|
||
'No MCP servers found in Claude Desktop configuration or configuration file does not exist.',
|
||
)
|
||
}
|
||
|
||
const { unmount } = await render(
|
||
<AppStateProvider>
|
||
<KeybindingSetup>
|
||
<MCPServerDesktopImportDialog
|
||
servers={servers}
|
||
scope={scope}
|
||
onDone={() => {
|
||
unmount()
|
||
}}
|
||
/>
|
||
</KeybindingSetup>
|
||
</AppStateProvider>,
|
||
{ exitOnCtrlC: true },
|
||
)
|
||
} catch (error) {
|
||
cliError((error as Error).message)
|
||
}
|
||
}
|
||
|
||
// mcp reset-project-choices (lines 4935–4952)
|
||
export async function mcpResetChoicesHandler(): Promise<void> {
|
||
logEvent('tengu_mcp_reset_mcpjson_choices', {})
|
||
saveCurrentProjectConfig(current => ({
|
||
...current,
|
||
enabledMcpjsonServers: [],
|
||
disabledMcpjsonServers: [],
|
||
enableAllProjectMcpServers: false,
|
||
}))
|
||
cliOk(
|
||
'All project-scoped (.mcp.json) server approvals and rejections have been reset.\n' +
|
||
'You will be prompted for approval next time you start Claude Code.',
|
||
)
|
||
}
|