/ services / mcp / utils.ts
utils.ts
  1  import { createHash } from 'crypto'
  2  import { join } from 'path'
  3  import { getIsNonInteractiveSession } from '../../bootstrap/state.js'
  4  import type { Command } from '../../commands.js'
  5  import type { AgentMcpServerInfo } from '../../components/mcp/types.js'
  6  import type { Tool } from '../../Tool.js'
  7  import type { AgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js'
  8  import { getCwd } from '../../utils/cwd.js'
  9  import { getGlobalClaudeFile } from '../../utils/env.js'
 10  import { isSettingSourceEnabled } from '../../utils/settings/constants.js'
 11  import {
 12    getSettings_DEPRECATED,
 13    hasSkipDangerousModePermissionPrompt,
 14  } from '../../utils/settings/settings.js'
 15  import { jsonStringify } from '../../utils/slowOperations.js'
 16  import { getEnterpriseMcpFilePath, getMcpConfigByName } from './config.js'
 17  import { mcpInfoFromString } from './mcpStringUtils.js'
 18  import { normalizeNameForMCP } from './normalization.js'
 19  import {
 20    type ConfigScope,
 21    ConfigScopeSchema,
 22    type MCPServerConnection,
 23    type McpHTTPServerConfig,
 24    type McpServerConfig,
 25    type McpSSEServerConfig,
 26    type McpStdioServerConfig,
 27    type McpWebSocketServerConfig,
 28    type ScopedMcpServerConfig,
 29    type ServerResource,
 30  } from './types.js'
 31  
 32  /**
 33   * Filters tools by MCP server name
 34   *
 35   * @param tools Array of tools to filter
 36   * @param serverName Name of the MCP server
 37   * @returns Tools belonging to the specified server
 38   */
 39  export function filterToolsByServer(tools: Tool[], serverName: string): Tool[] {
 40    const prefix = `mcp__${normalizeNameForMCP(serverName)}__`
 41    return tools.filter(tool => tool.name?.startsWith(prefix))
 42  }
 43  
 44  /**
 45   * True when a command belongs to the given MCP server.
 46   *
 47   * MCP **prompts** are named `mcp__<server>__<prompt>` (wire-format constraint);
 48   * MCP **skills** are named `<server>:<skill>` (matching plugin/nested-dir skill
 49   * naming). Both live in `mcp.commands`, so cleanup and filtering must match
 50   * either shape.
 51   */
 52  export function commandBelongsToServer(
 53    command: Command,
 54    serverName: string,
 55  ): boolean {
 56    const normalized = normalizeNameForMCP(serverName)
 57    const name = command.name
 58    if (!name) return false
 59    return (
 60      name.startsWith(`mcp__${normalized}__`) || name.startsWith(`${normalized}:`)
 61    )
 62  }
 63  
 64  /**
 65   * Filters commands by MCP server name
 66   * @param commands Array of commands to filter
 67   * @param serverName Name of the MCP server
 68   * @returns Commands belonging to the specified server
 69   */
 70  export function filterCommandsByServer(
 71    commands: Command[],
 72    serverName: string,
 73  ): Command[] {
 74    return commands.filter(c => commandBelongsToServer(c, serverName))
 75  }
 76  
 77  /**
 78   * Filters MCP **prompts** (not skills) by server. Used by the `/mcp` menu
 79   * capabilities display — skills are a separate feature shown in `/skills`,
 80   * so they mustn't inflate the "prompts" capability badge.
 81   *
 82   * The distinguisher is `loadedFrom === 'mcp'`: MCP skills set it, MCP
 83   * prompts don't (they use `isMcp: true` instead).
 84   */
 85  export function filterMcpPromptsByServer(
 86    commands: Command[],
 87    serverName: string,
 88  ): Command[] {
 89    return commands.filter(
 90      c =>
 91        commandBelongsToServer(c, serverName) &&
 92        !(c.type === 'prompt' && c.loadedFrom === 'mcp'),
 93    )
 94  }
 95  
 96  /**
 97   * Filters resources by MCP server name
 98   * @param resources Array of resources to filter
 99   * @param serverName Name of the MCP server
100   * @returns Resources belonging to the specified server
101   */
102  export function filterResourcesByServer(
103    resources: ServerResource[],
104    serverName: string,
105  ): ServerResource[] {
106    return resources.filter(resource => resource.server === serverName)
107  }
108  
109  /**
110   * Removes tools belonging to a specific MCP server
111   * @param tools Array of tools
112   * @param serverName Name of the MCP server to exclude
113   * @returns Tools not belonging to the specified server
114   */
115  export function excludeToolsByServer(
116    tools: Tool[],
117    serverName: string,
118  ): Tool[] {
119    const prefix = `mcp__${normalizeNameForMCP(serverName)}__`
120    return tools.filter(tool => !tool.name?.startsWith(prefix))
121  }
122  
123  /**
124   * Removes commands belonging to a specific MCP server
125   * @param commands Array of commands
126   * @param serverName Name of the MCP server to exclude
127   * @returns Commands not belonging to the specified server
128   */
129  export function excludeCommandsByServer(
130    commands: Command[],
131    serverName: string,
132  ): Command[] {
133    return commands.filter(c => !commandBelongsToServer(c, serverName))
134  }
135  
136  /**
137   * Removes resources belonging to a specific MCP server
138   * @param resources Map of server resources
139   * @param serverName Name of the MCP server to exclude
140   * @returns Resources map without the specified server
141   */
142  export function excludeResourcesByServer(
143    resources: Record<string, ServerResource[]>,
144    serverName: string,
145  ): Record<string, ServerResource[]> {
146    const result = { ...resources }
147    delete result[serverName]
148    return result
149  }
150  
151  /**
152   * Stable hash of an MCP server config for change detection on /reload-plugins.
153   * Excludes `scope` (provenance, not content — moving a server from .mcp.json
154   * to settings.json shouldn't reconnect it). Keys sorted so `{a:1,b:2}` and
155   * `{b:2,a:1}` hash the same.
156   */
157  export function hashMcpConfig(config: ScopedMcpServerConfig): string {
158    const { scope: _scope, ...rest } = config
159    const stable = jsonStringify(rest, (_k, v: unknown) => {
160      if (v && typeof v === 'object' && !Array.isArray(v)) {
161        const obj = v as Record<string, unknown>
162        const sorted: Record<string, unknown> = {}
163        for (const k of Object.keys(obj).sort()) sorted[k] = obj[k]
164        return sorted
165      }
166      return v
167    })
168    return createHash('sha256').update(stable).digest('hex').slice(0, 16)
169  }
170  
171  /**
172   * Remove stale MCP clients and their tools/commands/resources. A client is
173   * stale if:
174   *   - scope 'dynamic' and name no longer in configs (plugin disabled), or
175   *   - config hash changed (args/url/env edited in .mcp.json) — any scope
176   *
177   * The removal case is scoped to 'dynamic' so /reload-plugins can't
178   * accidentally disconnect a user-configured server that's just temporarily
179   * absent from the in-memory config (e.g. during a partial reload). The
180   * config-changed case applies to all scopes — if the config actually changed
181   * on disk, reconnecting is what you want.
182   *
183   * Returns the stale clients so the caller can disconnect them (clearServerCache).
184   */
185  export function excludeStalePluginClients(
186    mcp: {
187      clients: MCPServerConnection[]
188      tools: Tool[]
189      commands: Command[]
190      resources: Record<string, ServerResource[]>
191    },
192    configs: Record<string, ScopedMcpServerConfig>,
193  ): {
194    clients: MCPServerConnection[]
195    tools: Tool[]
196    commands: Command[]
197    resources: Record<string, ServerResource[]>
198    stale: MCPServerConnection[]
199  } {
200    const stale = mcp.clients.filter(c => {
201      const fresh = configs[c.name]
202      if (!fresh) return c.config.scope === 'dynamic'
203      return hashMcpConfig(c.config) !== hashMcpConfig(fresh)
204    })
205    if (stale.length === 0) {
206      return { ...mcp, stale: [] }
207    }
208  
209    let { tools, commands, resources } = mcp
210    for (const s of stale) {
211      tools = excludeToolsByServer(tools, s.name)
212      commands = excludeCommandsByServer(commands, s.name)
213      resources = excludeResourcesByServer(resources, s.name)
214    }
215    const staleNames = new Set(stale.map(c => c.name))
216  
217    return {
218      clients: mcp.clients.filter(c => !staleNames.has(c.name)),
219      tools,
220      commands,
221      resources,
222      stale,
223    }
224  }
225  
226  /**
227   * Checks if a tool name belongs to a specific MCP server
228   * @param toolName The tool name to check
229   * @param serverName The server name to match against
230   * @returns True if the tool belongs to the specified server
231   */
232  export function isToolFromMcpServer(
233    toolName: string,
234    serverName: string,
235  ): boolean {
236    const info = mcpInfoFromString(toolName)
237    return info?.serverName === serverName
238  }
239  
240  /**
241   * Checks if a tool belongs to any MCP server
242   * @param tool The tool to check
243   * @returns True if the tool is from an MCP server
244   */
245  export function isMcpTool(tool: Tool): boolean {
246    return tool.name?.startsWith('mcp__') || tool.isMcp === true
247  }
248  
249  /**
250   * Checks if a command belongs to any MCP server
251   * @param command The command to check
252   * @returns True if the command is from an MCP server
253   */
254  export function isMcpCommand(command: Command): boolean {
255    return command.name?.startsWith('mcp__') || command.isMcp === true
256  }
257  
258  /**
259   * Describe the file path for a given MCP config scope.
260   * @param scope The config scope ('user', 'project', 'local', or 'dynamic')
261   * @returns A description of where the config is stored
262   */
263  export function describeMcpConfigFilePath(scope: ConfigScope): string {
264    switch (scope) {
265      case 'user':
266        return getGlobalClaudeFile()
267      case 'project':
268        return join(getCwd(), '.mcp.json')
269      case 'local':
270        return `${getGlobalClaudeFile()} [project: ${getCwd()}]`
271      case 'dynamic':
272        return 'Dynamically configured'
273      case 'enterprise':
274        return getEnterpriseMcpFilePath()
275      case 'claudeai':
276        return 'claude.ai'
277      default:
278        return scope
279    }
280  }
281  
282  export function getScopeLabel(scope: ConfigScope): string {
283    switch (scope) {
284      case 'local':
285        return 'Local config (private to you in this project)'
286      case 'project':
287        return 'Project config (shared via .mcp.json)'
288      case 'user':
289        return 'User config (available in all your projects)'
290      case 'dynamic':
291        return 'Dynamic config (from command line)'
292      case 'enterprise':
293        return 'Enterprise config (managed by your organization)'
294      case 'claudeai':
295        return 'claude.ai config'
296      default:
297        return scope
298    }
299  }
300  
301  export function ensureConfigScope(scope?: string): ConfigScope {
302    if (!scope) return 'local'
303  
304    if (!ConfigScopeSchema().options.includes(scope as ConfigScope)) {
305      throw new Error(
306        `Invalid scope: ${scope}. Must be one of: ${ConfigScopeSchema().options.join(', ')}`,
307      )
308    }
309  
310    return scope as ConfigScope
311  }
312  
313  export function ensureTransport(type?: string): 'stdio' | 'sse' | 'http' {
314    if (!type) return 'stdio'
315  
316    if (type !== 'stdio' && type !== 'sse' && type !== 'http') {
317      throw new Error(
318        `Invalid transport type: ${type}. Must be one of: stdio, sse, http`,
319      )
320    }
321  
322    return type as 'stdio' | 'sse' | 'http'
323  }
324  
325  export function parseHeaders(headerArray: string[]): Record<string, string> {
326    const headers: Record<string, string> = {}
327  
328    for (const header of headerArray) {
329      const colonIndex = header.indexOf(':')
330      if (colonIndex === -1) {
331        throw new Error(
332          `Invalid header format: "${header}". Expected format: "Header-Name: value"`,
333        )
334      }
335  
336      const key = header.substring(0, colonIndex).trim()
337      const value = header.substring(colonIndex + 1).trim()
338  
339      if (!key) {
340        throw new Error(
341          `Invalid header: "${header}". Header name cannot be empty.`,
342        )
343      }
344  
345      headers[key] = value
346    }
347  
348    return headers
349  }
350  
351  export function getProjectMcpServerStatus(
352    serverName: string,
353  ): 'approved' | 'rejected' | 'pending' {
354    const settings = getSettings_DEPRECATED()
355    const normalizedName = normalizeNameForMCP(serverName)
356  
357    // TODO: This fails an e2e test if the ?. is not present. This is likely a bug in the e2e test.
358    // Will fix this in a follow-up PR.
359    if (
360      settings?.disabledMcpjsonServers?.some(
361        name => normalizeNameForMCP(name) === normalizedName,
362      )
363    ) {
364      return 'rejected'
365    }
366  
367    if (
368      settings?.enabledMcpjsonServers?.some(
369        name => normalizeNameForMCP(name) === normalizedName,
370      ) ||
371      settings?.enableAllProjectMcpServers
372    ) {
373      return 'approved'
374    }
375  
376    // In bypass permissions mode (--dangerously-skip-permissions), there's no way
377    // to show an approval popup. Auto-approve if projectSettings is enabled since
378    // the user has explicitly chosen to bypass all permission checks.
379    // SECURITY: We intentionally only check skipDangerousModePermissionPrompt via
380    // hasSkipDangerousModePermissionPrompt(), which reads from userSettings/localSettings/
381    // flagSettings/policySettings but NOT projectSettings (repo-level .claude/settings.json).
382    // This is intentional: a repo should not be able to accept the bypass dialog on behalf of
383    // users. We also do NOT check getSessionBypassPermissionsMode() here because
384    // sessionBypassPermissionsMode can be set from project settings before the dialog is shown,
385    // which would allow RCE attacks via malicious project settings.
386    if (
387      hasSkipDangerousModePermissionPrompt() &&
388      isSettingSourceEnabled('projectSettings')
389    ) {
390      return 'approved'
391    }
392  
393    // In non-interactive mode (SDK, claude -p, piped input), there's no way to
394    // show an approval popup. Auto-approve if projectSettings is enabled since:
395    // 1. The user/developer explicitly chose to run in this mode
396    // 2. For SDK, projectSettings is off by default - they must explicitly enable it
397    // 3. For -p mode, the help text warns to only use in trusted directories
398    if (
399      getIsNonInteractiveSession() &&
400      isSettingSourceEnabled('projectSettings')
401    ) {
402      return 'approved'
403    }
404  
405    return 'pending'
406  }
407  
408  /**
409   * Get the scope/settings source for an MCP server from a tool name
410   * @param toolName MCP tool name (format: mcp__serverName__toolName)
411   * @returns ConfigScope or null if not an MCP tool or server not found
412   */
413  export function getMcpServerScopeFromToolName(
414    toolName: string,
415  ): ConfigScope | null {
416    if (!isMcpTool({ name: toolName } as Tool)) {
417      return null
418    }
419  
420    // Extract server name from tool name (format: mcp__serverName__toolName)
421    const mcpInfo = mcpInfoFromString(toolName)
422    if (!mcpInfo) {
423      return null
424    }
425  
426    // Look up server config
427    const serverConfig = getMcpConfigByName(mcpInfo.serverName)
428  
429    // Fallback: claude.ai servers have normalized names starting with "claude_ai_"
430    // but aren't in getMcpConfigByName (they're fetched async separately)
431    if (!serverConfig && mcpInfo.serverName.startsWith('claude_ai_')) {
432      return 'claudeai'
433    }
434  
435    return serverConfig?.scope ?? null
436  }
437  
438  // Type guards for MCP server config types
439  function isStdioConfig(
440    config: McpServerConfig,
441  ): config is McpStdioServerConfig {
442    return config.type === 'stdio' || config.type === undefined
443  }
444  
445  function isSSEConfig(config: McpServerConfig): config is McpSSEServerConfig {
446    return config.type === 'sse'
447  }
448  
449  function isHTTPConfig(config: McpServerConfig): config is McpHTTPServerConfig {
450    return config.type === 'http'
451  }
452  
453  function isWebSocketConfig(
454    config: McpServerConfig,
455  ): config is McpWebSocketServerConfig {
456    return config.type === 'ws'
457  }
458  
459  /**
460   * Extracts MCP server definitions from agent frontmatter and groups them by server name.
461   * This is used to show agent-specific MCP servers in the /mcp command.
462   *
463   * @param agents Array of agent definitions
464   * @returns Array of AgentMcpServerInfo, grouped by server name with list of source agents
465   */
466  export function extractAgentMcpServers(
467    agents: AgentDefinition[],
468  ): AgentMcpServerInfo[] {
469    // Map: server name -> { config, sourceAgents }
470    const serverMap = new Map<
471      string,
472      {
473        config: McpServerConfig & { name: string }
474        sourceAgents: string[]
475      }
476    >()
477  
478    for (const agent of agents) {
479      if (!agent.mcpServers?.length) continue
480  
481      for (const spec of agent.mcpServers) {
482        // Skip string references - these refer to servers already in global config
483        if (typeof spec === 'string') continue
484  
485        // Inline definition as { [name]: config }
486        const entries = Object.entries(spec)
487        if (entries.length !== 1) continue
488  
489        const [serverName, serverConfig] = entries[0]!
490        const existing = serverMap.get(serverName)
491  
492        if (existing) {
493          // Add this agent as another source
494          if (!existing.sourceAgents.includes(agent.agentType)) {
495            existing.sourceAgents.push(agent.agentType)
496          }
497        } else {
498          // New server
499          serverMap.set(serverName, {
500            config: { ...serverConfig, name: serverName } as McpServerConfig & {
501              name: string
502            },
503            sourceAgents: [agent.agentType],
504          })
505        }
506      }
507    }
508  
509    // Convert map to array of AgentMcpServerInfo
510    // Only include transport types supported by AgentMcpServerInfo
511    const result: AgentMcpServerInfo[] = []
512    for (const [name, { config, sourceAgents }] of serverMap) {
513      // Use type guards to properly narrow the discriminated union type
514      // Only include transport types that are supported by AgentMcpServerInfo
515      if (isStdioConfig(config)) {
516        result.push({
517          name,
518          sourceAgents,
519          transport: 'stdio',
520          command: config.command,
521          needsAuth: false,
522        })
523      } else if (isSSEConfig(config)) {
524        result.push({
525          name,
526          sourceAgents,
527          transport: 'sse',
528          url: config.url,
529          needsAuth: true,
530        })
531      } else if (isHTTPConfig(config)) {
532        result.push({
533          name,
534          sourceAgents,
535          transport: 'http',
536          url: config.url,
537          needsAuth: true,
538        })
539      } else if (isWebSocketConfig(config)) {
540        result.push({
541          name,
542          sourceAgents,
543          transport: 'ws',
544          url: config.url,
545          needsAuth: false,
546        })
547      }
548      // Skip unsupported transport types (sdk, claudeai-proxy, sse-ide, ws-ide)
549      // These are internal types not meant for agent MCP server display
550    }
551  
552    return result.sort((a, b) => a.name.localeCompare(b.name))
553  }
554  
555  /**
556   * Extracts the MCP server base URL (without query string) for analytics logging.
557   * Query strings are stripped because they can contain access tokens.
558   * Trailing slashes are also removed for normalization.
559   * Returns undefined for stdio/sdk servers or if URL parsing fails.
560   */
561  export function getLoggingSafeMcpBaseUrl(
562    config: McpServerConfig,
563  ): string | undefined {
564    if (!('url' in config) || typeof config.url !== 'string') {
565      return undefined
566    }
567  
568    try {
569      const url = new URL(config.url)
570      url.search = ''
571      return url.toString().replace(/\/$/, '')
572    } catch {
573      return undefined
574    }
575  }