feat: 工具层及 mcp 大重构 (#252)

* feat: 第一版大重构

* fix: 修复类型问题

* chore: 更新版本到 1.3.2

* Add brave as alternative WebSearchTool

* fix: 修正顺序

* fix: 修复对穷鬼模式的 auto dream 和 session memory 越过

* feat: 穷鬼模式去除 session-summary

* feat: 创建 builtin-tools 包,搬运所有工具实现

将 src/tools/ 下的全部 60 个工具目录迁移至 packages/builtin-tools/src/tools/,
内部导入路径已更新为 src/ alias 模式。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: 更新 src/ 中所有工具引用至 builtin-tools 包,删除 src/tools/

- src/tools.ts 及 178 个 src/ 文件的 import 路径从 ./tools/ 改为 builtin-tools/tools/
- 删除 src/tools/ 整个目录(已迁移至 packages/builtin-tools/)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: 添加 builtin-tools 路径别名至 tsconfig,更新 bun.lock

- tsconfig.json 新增 builtin-tools/* 和 builtin-tools 路径映射
- 新增 packages/builtin-tools/src 至 include

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: 为 builtin-tools、mcp-client、agent-tools 添加 @claude-code-best 作用域前缀

所有包名及 import 路径统一添加 @claude-code-best/ 前缀:
- builtin-tools → @claude-code-best/builtin-tools
- mcp-client → @claude-code-best/mcp-client
- agent-tools → @claude-code-best/agent-tools

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: 修复 node 环境没有 bun 的问题

---------

Co-authored-by: Eric-Guo <eric.guocz@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-04-13 09:52:05 +08:00
committed by GitHub
parent bbb8b613a9
commit 2fb1c9dcd8
559 changed files with 9346 additions and 1837 deletions

View File

@@ -0,0 +1,77 @@
import { z } from 'zod/v4'
import { buildTool, type ToolDef } from 'src/Tool.js'
import { lazySchema } from 'src/utils/lazySchema.js'
import type { PermissionResult } from 'src/utils/permissions/PermissionResult.js'
import { isOutputLineTruncated } from 'src/utils/terminal.js'
import { DESCRIPTION, PROMPT } from './prompt.js'
import {
renderToolResultMessage,
renderToolUseMessage,
renderToolUseProgressMessage,
} from './UI.js'
// Allow any input object since MCP tools define their own schemas
export const inputSchema = lazySchema(() => z.object({}).passthrough())
type InputSchema = ReturnType<typeof inputSchema>
export const outputSchema = lazySchema(() =>
z.string().describe('MCP tool execution result'),
)
type OutputSchema = ReturnType<typeof outputSchema>
export type Output = z.infer<OutputSchema>
// Re-export MCPProgress from centralized types to break import cycles
export type { MCPProgress } from 'src/types/tools.js'
export const MCPTool = buildTool({
isMcp: true,
// Overridden in mcpClient.ts with the real MCP tool name + args
isOpenWorld() {
return false
},
// Overridden in mcpClient.ts
name: 'mcp',
maxResultSizeChars: 100_000,
// Overridden in mcpClient.ts
async description() {
return DESCRIPTION
},
// Overridden in mcpClient.ts
async prompt() {
return PROMPT
},
get inputSchema(): InputSchema {
return inputSchema()
},
get outputSchema(): OutputSchema {
return outputSchema()
},
// Overridden in mcpClient.ts
async call() {
return {
data: '',
}
},
async checkPermissions(): Promise<PermissionResult> {
return {
behavior: 'passthrough',
message: 'MCPTool requires permission.',
}
},
renderToolUseMessage,
// Overridden in mcpClient.ts
userFacingName: () => 'mcp',
renderToolUseProgressMessage,
renderToolResultMessage,
isResultTruncated(output: Output): boolean {
return isOutputLineTruncated(output)
},
mapToolResultToToolResultBlockParam(content, toolUseID) {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content,
}
},
} satisfies ToolDef<InputSchema, Output>)

View File

@@ -0,0 +1,395 @@
import { feature } from 'bun:bundle'
import figures from 'figures'
import * as React from 'react'
import type { z } from 'zod/v4'
import { ProgressBar } from '@anthropic/ink'
import { MessageResponse } from 'src/components/MessageResponse.js'
import {
linkifyUrlsInText,
OutputLine,
} from 'src/components/shell/OutputLine.js'
import { Ansi, Box, Text, stringWidth } from '@anthropic/ink'
import { createHyperlink } from 'src/utils/hyperlink.js'
import type { ToolProgressData } from 'src/Tool.js'
import type { ProgressMessage } from 'src/types/message.js'
import type { MCPProgress } from 'src/types/tools.js'
import { formatNumber } from 'src/utils/format.js'
import {
getContentSizeEstimate,
type MCPToolResult,
} from 'src/utils/mcpValidation.js'
import { jsonParse, jsonStringify } from 'src/utils/slowOperations.js'
import type { inputSchema } from './MCPTool.js'
// Threshold for displaying warning about large MCP responses
const MCP_OUTPUT_WARNING_THRESHOLD_TOKENS = 10_000
// In non-verbose mode, truncate individual input values to keep the header
// compact. Matches BashTool's philosophy of showing enough to identify the
// call without dumping the entire payload inline.
const MAX_INPUT_VALUE_CHARS = 80
// Max number of top-level keys before we fall back to raw JSON display.
// Beyond this a flat k:v list is more noise than help.
const MAX_FLAT_JSON_KEYS = 12
// Don't attempt flat-object parsing for large blobs.
const MAX_FLAT_JSON_CHARS = 5_000
// Don't attempt to parse JSON blobs larger than this (perf safety).
const MAX_JSON_PARSE_CHARS = 200_000
// A string value is "dominant text payload" if it has newlines or is
// long enough that inline display would be worse than unwrapping.
const UNWRAP_MIN_STRING_LEN = 200
export function renderToolUseMessage(
input: z.infer<ReturnType<typeof inputSchema>>,
{ verbose }: { verbose: boolean },
): React.ReactNode {
if (Object.keys(input).length === 0) {
return ''
}
return Object.entries(input)
.map(([key, value]) => {
let rendered = jsonStringify(value)
if (
feature('MCP_RICH_OUTPUT') &&
!verbose &&
rendered.length > MAX_INPUT_VALUE_CHARS
) {
rendered = rendered.slice(0, MAX_INPUT_VALUE_CHARS).trimEnd() + '…'
}
return `${key}: ${rendered}`
})
.join(', ')
}
export function renderToolUseProgressMessage(
progressMessagesForMessage: ProgressMessage<MCPProgress>[],
): React.ReactNode {
const lastProgress = progressMessagesForMessage.at(-1)
if (!lastProgress?.data) {
return (
<MessageResponse height={1}>
<Text dimColor>Running</Text>
</MessageResponse>
)
}
const { progress, total, progressMessage } = lastProgress.data
if (progress === undefined) {
return (
<MessageResponse height={1}>
<Text dimColor>Running</Text>
</MessageResponse>
)
}
if (total !== undefined && total > 0) {
const ratio = Math.min(1, Math.max(0, progress / total))
const percentage = Math.round(ratio * 100)
return (
<MessageResponse>
<Box flexDirection="column">
{progressMessage && <Text dimColor>{progressMessage}</Text>}
<Box flexDirection="row" gap={1}>
<ProgressBar ratio={ratio} width={20} />
<Text dimColor>{percentage}%</Text>
</Box>
</Box>
</MessageResponse>
)
}
return (
<MessageResponse height={1}>
<Text dimColor>{progressMessage ?? `Processing… ${progress}`}</Text>
</MessageResponse>
)
}
export function renderToolResultMessage(
output: string | MCPToolResult,
_progressMessagesForMessage: ProgressMessage<ToolProgressData>[],
{ verbose, input }: { verbose: boolean; input?: unknown },
): React.ReactNode {
const mcpOutput = output as MCPToolResult
if (!verbose) {
const slackSend = trySlackSendCompact(mcpOutput, input)
if (slackSend !== null) {
return (
<MessageResponse height={1}>
<Text>
Sent a message to{' '}
<Ansi>{createHyperlink(slackSend.url, slackSend.channel)}</Ansi>
</Text>
</MessageResponse>
)
}
}
const estimatedTokens = getContentSizeEstimate(mcpOutput)
const showWarning = estimatedTokens > MCP_OUTPUT_WARNING_THRESHOLD_TOKENS
const warningMessage = showWarning
? `${figures.warning} Large MCP response (~${formatNumber(estimatedTokens)} tokens), this can fill up context quickly`
: null
let contentElement: React.ReactNode
if (Array.isArray(mcpOutput)) {
const contentBlocks = mcpOutput.map((item, i) => {
if (item.type === 'image') {
return (
<Box
key={i}
justifyContent="space-between"
overflowX="hidden"
width="100%"
>
<MessageResponse height={1}>
<Text>[Image]</Text>
</MessageResponse>
</Box>
)
}
// For text blocks and any other block types, extract text if available
const textContent =
item.type === 'text' &&
'text' in item &&
item.text !== null &&
item.text !== undefined
? String(item.text)
: ''
return feature('MCP_RICH_OUTPUT') ? (
<MCPTextOutput key={i} content={textContent} verbose={verbose} />
) : (
<OutputLine key={i} content={textContent} verbose={verbose} />
)
})
// Wrap array content in a column layout
contentElement = (
<Box flexDirection="column" width="100%">
{contentBlocks}
</Box>
)
} else if (!mcpOutput) {
contentElement = (
<Box justifyContent="space-between" overflowX="hidden" width="100%">
<MessageResponse height={1}>
<Text dimColor>(No content)</Text>
</MessageResponse>
</Box>
)
} else {
contentElement = feature('MCP_RICH_OUTPUT') ? (
<MCPTextOutput content={mcpOutput} verbose={verbose} />
) : (
<OutputLine content={mcpOutput} verbose={verbose} />
)
}
if (warningMessage) {
return (
<Box flexDirection="column">
<MessageResponse height={1}>
<Text color="warning">{warningMessage}</Text>
</MessageResponse>
{contentElement}
</Box>
)
}
return contentElement
}
/**
* Render MCP text output. Tries three strategies in order:
* 1. If JSON wraps a single dominant text payload (e.g. slack's
* {"messages":"line1\nline2..."}), unwrap and let OutputLine truncate.
* 2. If JSON is a small flat-ish object, render as aligned key: value.
* 3. Otherwise fall through to OutputLine (pretty-print + truncate).
*/
function MCPTextOutput({
content,
verbose,
}: {
content: string
verbose: boolean
}): React.ReactNode {
const unwrapped = tryUnwrapTextPayload(content)
if (unwrapped !== null) {
return (
<MessageResponse>
<Box flexDirection="column">
{unwrapped.extras.length > 0 && (
<Text dimColor>
{unwrapped.extras.map(([k, v]) => `${k}: ${v}`).join(' · ')}
</Text>
)}
<OutputLine content={unwrapped.body} verbose={verbose} linkifyUrls />
</Box>
</MessageResponse>
)
}
const flat = tryFlattenJson(content)
if (flat !== null) {
const maxKeyWidth = Math.max(...flat.map(([k]) => stringWidth(k)))
return (
<MessageResponse>
<Box flexDirection="column">
{flat.map(([key, value], i) => (
<Text key={i}>
<Text dimColor>{key.padEnd(maxKeyWidth)}: </Text>
<Ansi>{linkifyUrlsInText(value)}</Ansi>
</Text>
))}
</Box>
</MessageResponse>
)
}
return <OutputLine content={content} verbose={verbose} linkifyUrls />
}
/**
* Parse content as a JSON object and return its entries. Null if content
* doesn't parse, isn't an object, is too large, or has 0/too-many keys.
*/
function parseJsonEntries(
content: string,
{ maxChars, maxKeys }: { maxChars: number; maxKeys: number },
): [string, unknown][] | null {
const trimmed = content.trim()
if (trimmed.length === 0 || trimmed.length > maxChars || trimmed[0] !== '{') {
return null
}
let parsed: unknown
try {
parsed = jsonParse(trimmed)
} catch {
return null
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
return null
}
const entries = Object.entries(parsed)
if (entries.length === 0 || entries.length > maxKeys) {
return null
}
return entries
}
/**
* If content parses as a JSON object where every value is a scalar or a
* small nested object, flatten it to [key, displayValue] pairs. Nested
* objects get one-line JSON. Returns null if content doesn't qualify.
*/
export function tryFlattenJson(content: string): [string, string][] | null {
const entries = parseJsonEntries(content, {
maxChars: MAX_FLAT_JSON_CHARS,
maxKeys: MAX_FLAT_JSON_KEYS,
})
if (entries === null) return null
const result: [string, string][] = []
for (const [key, value] of entries) {
if (typeof value === 'string') {
result.push([key, value])
} else if (
value === null ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
result.push([key, String(value)])
} else if (typeof value === 'object') {
const compact = jsonStringify(value)
if (compact.length > 120) return null
result.push([key, compact])
} else {
return null
}
}
return result
}
/**
* If content is a JSON object where one key holds a dominant string payload
* (multiline or long) and all siblings are small scalars, unwrap it. This
* handles the common MCP pattern of {"messages":"line1\nline2..."} where
* pretty-printing keeps \n escaped but we want real line breaks + truncation.
*/
export function tryUnwrapTextPayload(
content: string,
): { body: string; extras: [string, string][] } | null {
const entries = parseJsonEntries(content, {
maxChars: MAX_JSON_PARSE_CHARS,
maxKeys: 4,
})
if (entries === null) return null
// Find the one dominant string payload. Trim first: a trailing \n on a
// short sibling (e.g. pagination hints) shouldn't make it "dominant".
let body: string | null = null
const extras: [string, string][] = []
for (const [key, value] of entries) {
if (typeof value === 'string') {
const t = value.trimEnd()
const isDominant =
t.length > UNWRAP_MIN_STRING_LEN || (t.includes('\n') && t.length > 50)
if (isDominant) {
if (body !== null) return null // two big strings — ambiguous
body = t
continue
}
if (t.length > 150) return null
extras.push([key, t.replace(/\s+/g, ' ')])
} else if (
value === null ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
extras.push([key, String(value)])
} else {
return null // nested object/array — use flat or pretty-print path
}
}
if (body === null) return null
return { body, extras }
}
const SLACK_ARCHIVES_RE =
/^https:\/\/[a-z0-9-]+\.slack\.com\/archives\/([A-Z0-9]+)\/p\d+$/
/**
* Detect a Slack send-message result and return a compact {channel, url} pair.
* Matches both hosted (claude.ai Slack) and community MCP server shapes —
* both return `message_link` in the result. The channel label prefers the
* tool input (may be a name like "#foo" or an ID like "C09EVDAN1NK") and
* falls back to the ID parsed from the archives URL.
*/
export function trySlackSendCompact(
output: string | MCPToolResult,
input: unknown,
): { channel: string; url: string } | null {
let text: unknown = output
if (Array.isArray(output)) {
const block = output.find(b => b.type === 'text')
text = block && 'text' in block ? block.text : undefined
}
if (typeof text !== 'string' || !text.includes('"message_link"')) {
return null
}
const entries = parseJsonEntries(text, { maxChars: 2000, maxKeys: 6 })
const url = entries?.find(([k]) => k === 'message_link')?.[1]
if (typeof url !== 'string') return null
const m = SLACK_ARCHIVES_RE.exec(url)
if (!m) return null
const inp = input as { channel_id?: unknown; channel?: unknown } | undefined
const raw = inp?.channel_id ?? inp?.channel ?? m[1]
const label = typeof raw === 'string' && raw ? raw : 'slack'
return { channel: label.startsWith('#') ? label : `#${label}`, url }
}

View File

@@ -0,0 +1,146 @@
import { describe, expect, test } from "bun:test";
import { classifyMcpToolForCollapse } from "../classifyForCollapse";
describe("classifyMcpToolForCollapse", () => {
// Search tools
test("classifies Slack slack_search_public as search", () => {
expect(classifyMcpToolForCollapse("slack", "slack_search_public")).toEqual({
isSearch: true,
isRead: false,
});
});
test("classifies GitHub search_code as search", () => {
expect(classifyMcpToolForCollapse("github", "search_code")).toEqual({
isSearch: true,
isRead: false,
});
});
test("classifies Linear search_issues as search", () => {
expect(classifyMcpToolForCollapse("linear", "search_issues")).toEqual({
isSearch: true,
isRead: false,
});
});
test("classifies Datadog search_logs as search", () => {
expect(classifyMcpToolForCollapse("datadog", "search_logs")).toEqual({
isSearch: true,
isRead: false,
});
});
test("classifies Notion search as search", () => {
expect(classifyMcpToolForCollapse("notion", "search")).toEqual({
isSearch: true,
isRead: false,
});
});
test("classifies Brave brave_web_search as search", () => {
expect(classifyMcpToolForCollapse("brave-search", "brave_web_search")).toEqual({
isSearch: true,
isRead: false,
});
});
// Read tools
test("classifies Slack slack_read_channel as read", () => {
expect(classifyMcpToolForCollapse("slack", "slack_read_channel")).toEqual({
isSearch: false,
isRead: true,
});
});
test("classifies GitHub get_file_contents as read", () => {
expect(classifyMcpToolForCollapse("github", "get_file_contents")).toEqual({
isSearch: false,
isRead: true,
});
});
test("classifies Linear get_issue as read", () => {
expect(classifyMcpToolForCollapse("linear", "get_issue")).toEqual({
isSearch: false,
isRead: true,
});
});
test("classifies Filesystem read_file as read", () => {
expect(classifyMcpToolForCollapse("filesystem", "read_file")).toEqual({
isSearch: false,
isRead: true,
});
});
test("classifies GitHub list_commits as read", () => {
expect(classifyMcpToolForCollapse("github", "list_commits")).toEqual({
isSearch: false,
isRead: true,
});
});
test("classifies Slack slack_list_channels as read", () => {
expect(classifyMcpToolForCollapse("slack", "slack_list_channels")).toEqual({
isSearch: false,
isRead: true,
});
});
// Unknown tools
test("unknown tool returns { isSearch: false, isRead: false }", () => {
expect(classifyMcpToolForCollapse("unknown", "do_something")).toEqual({
isSearch: false,
isRead: false,
});
});
// normalize: camelCase -> snake_case
test("tool name with camelCase variant still matches after normalize", () => {
// searchCode -> search_code
expect(classifyMcpToolForCollapse("github", "searchCode")).toEqual({
isSearch: true,
isRead: false,
});
});
// normalize: kebab-case -> snake_case
test("tool name with kebab-case variant still matches after normalize", () => {
// search-code -> search_code
expect(classifyMcpToolForCollapse("github", "search-code")).toEqual({
isSearch: true,
isRead: false,
});
});
// Server name doesn't affect classification
test("server name parameter does not affect classification", () => {
const r1 = classifyMcpToolForCollapse("server-a", "search_code");
const r2 = classifyMcpToolForCollapse("server-b", "search_code");
expect(r1).toEqual(r2);
});
// Edge cases
test("empty tool name returns false/false", () => {
expect(classifyMcpToolForCollapse("server", "")).toEqual({
isSearch: false,
isRead: false,
});
});
// normalize lowercases, so SEARCH_CODE -> search_code -> matches
test("uppercase input normalizes to match", () => {
expect(classifyMcpToolForCollapse("github", "SEARCH_CODE")).toEqual({
isSearch: true,
isRead: false,
});
});
test("handles tool names with numbers", () => {
expect(classifyMcpToolForCollapse("server", "search2_things")).toEqual({
isSearch: false,
isRead: false,
});
});
});

View File

@@ -0,0 +1,604 @@
/**
* Classify an MCP tool as a search/read operation for UI collapsing.
* Returns { isSearch: false, isRead: false } for tools that should not
* collapse (e.g., send_message, create_*, update_*).
*
* Uses explicit per-tool allowlists for the most common MCP servers.
* Tool names are stable across installs (even when the server name varies,
* e.g., "slack" vs "claude_ai_Slack"), so matching is keyed on the tool
* name alone after normalizing camelCase/kebab-case to snake_case.
* Unknown tool names don't collapse (conservative).
*/
// prettier-ignore
const SEARCH_TOOLS = new Set([
// Slack (hosted + @modelcontextprotocol/server-slack)
'slack_search_public',
'slack_search_public_and_private',
'slack_search_channels',
'slack_search_users',
// GitHub (github/github-mcp-server)
'search_code',
'search_repositories',
'search_issues',
'search_pull_requests',
'search_orgs',
'search_users',
// Linear (mcp.linear.app)
'search_documentation',
// Datadog (mcp.datadoghq.com)
'search_logs',
'search_spans',
'search_rum_events',
'search_audit_logs',
'search_monitors',
'search_monitor_groups',
'find_slow_spans',
'find_monitors_matching_pattern',
// Sentry (getsentry/sentry-mcp)
'search_docs',
'search_events',
'search_issue_events',
'find_organizations',
'find_teams',
'find_projects',
'find_releases',
'find_dsns',
// Notion (mcp.notion.com — kebab-case, normalized)
'search',
// Gmail (claude.ai hosted)
'gmail_search_messages',
// Google Drive (claude.ai hosted + @modelcontextprotocol/server-gdrive)
'google_drive_search',
// Google Calendar (claude.ai hosted)
'gcal_find_my_free_time',
'gcal_find_meeting_times',
'gcal_find_user_emails',
// Atlassian/Jira (mcp.atlassian.com — camelCase, normalized)
'search_jira_issues_using_jql',
'search_confluence_using_cql',
'lookup_jira_account_id',
// Community Atlassian (sooperset/mcp-atlassian)
'confluence_search',
'jira_search',
'jira_search_fields',
// Asana (mcp.asana.com)
'asana_search_tasks',
'asana_typeahead_search',
// Filesystem (@modelcontextprotocol/server-filesystem)
'search_files',
// Memory (@modelcontextprotocol/server-memory)
'search_nodes',
// Brave Search
'brave_web_search',
'brave_local_search',
// Git (mcp-server-git)
// (git has no search verbs)
// Grafana (grafana/mcp-grafana)
'search_dashboards',
'search_folders',
// PagerDuty
// (pagerduty reads all use get_/list_, no search verbs)
// Supabase
'search_docs',
// Stripe
'search_stripe_resources',
'search_stripe_documentation',
// PubMed (claude.ai hosted + community)
'search_articles',
'find_related_articles',
'lookup_article_by_citation',
'search_papers',
'search_pubmed',
'search_pubmed_key_words',
'search_pubmed_advanced',
'pubmed_search',
'pubmed_mesh_lookup',
// Firecrawl
'firecrawl_search',
// Exa
'web_search_exa',
'web_search_advanced_exa',
'people_search_exa',
'linkedin_search_exa',
'deep_search_exa',
// Perplexity
'perplexity_search',
'perplexity_search_web',
// Tavily
'tavily_search',
// Obsidian (MarkusPfundstein)
'obsidian_simple_search',
'obsidian_complex_search',
// MongoDB
'find',
'search_knowledge',
// Neo4j
'search_memories',
'find_memories_by_name',
// Airtable
'search_records',
// Todoist (Doist — kebab-case, normalized)
'find_tasks',
'find_tasks_by_date',
'find_completed_tasks',
'find_projects',
'find_sections',
'find_comments',
'find_project_collaborators',
'find_activity',
'find_labels',
'find_filters',
// AWS
'search_documentation',
'search_catalog',
// Terraform
'search_modules',
'search_providers',
'search_policies',
])
// prettier-ignore
const READ_TOOLS = new Set([
// Slack (hosted + @modelcontextprotocol/server-slack)
'slack_read_channel',
'slack_read_thread',
'slack_read_canvas',
'slack_read_user_profile',
'slack_list_channels',
'slack_get_channel_history',
'slack_get_thread_replies',
'slack_get_users',
'slack_get_user_profile',
// GitHub (github/github-mcp-server)
'get_me',
'get_team_members',
'get_teams',
'get_commit',
'get_file_contents',
'get_repository_tree',
'list_branches',
'list_commits',
'list_releases',
'list_tags',
'get_latest_release',
'get_release_by_tag',
'get_tag',
'list_issues',
'issue_read',
'list_issue_types',
'get_label',
'list_label',
'pull_request_read',
'get_gist',
'list_gists',
'list_notifications',
'get_notification_details',
'projects_list',
'projects_get',
'actions_get',
'actions_list',
'get_job_logs',
'get_code_scanning_alert',
'list_code_scanning_alerts',
'get_dependabot_alert',
'list_dependabot_alerts',
'get_secret_scanning_alert',
'list_secret_scanning_alerts',
'get_global_security_advisory',
'list_global_security_advisories',
'list_org_repository_security_advisories',
'list_repository_security_advisories',
'get_discussion',
'get_discussion_comments',
'list_discussion_categories',
'list_discussions',
'list_starred_repositories',
'get_issue',
'get_pull_request',
'list_pull_requests',
'get_pull_request_files',
'get_pull_request_status',
'get_pull_request_comments',
'get_pull_request_reviews',
// Linear (mcp.linear.app)
'list_comments',
'list_cycles',
'get_document',
'list_documents',
'list_issue_statuses',
'get_issue_status',
'list_my_issues',
'list_issue_labels',
'list_projects',
'get_project',
'list_project_labels',
'list_teams',
'get_team',
'list_users',
'get_user',
// Datadog (mcp.datadoghq.com)
'aggregate_logs',
'list_spans',
'aggregate_spans',
'analyze_trace',
'trace_critical_path',
'query_metrics',
'aggregate_rum_events',
'list_rum_metrics',
'get_rum_metric',
'list_monitors',
'get_monitor',
'check_can_delete_monitor',
'validate_monitor',
'validate_existing_monitor',
'list_dashboards',
'get_dashboard',
'query_dashboard_widget',
'list_notebooks',
'get_notebook',
'query_notebook_cell',
'get_profiling_metrics',
'compare_profiling_metrics',
// Sentry (getsentry/sentry-mcp)
'whoami',
'get_issue_details',
'get_issue_tag_values',
'get_trace_details',
'get_event_attachment',
'get_doc',
'get_sentry_resource',
'list_events',
'list_issue_events',
'get_sentry_issue',
// Notion (mcp.notion.com — kebab-case, normalized)
'fetch',
'get_comments',
'get_users',
'get_self',
// Gmail (claude.ai hosted)
'gmail_get_profile',
'gmail_read_message',
'gmail_read_thread',
'gmail_list_drafts',
'gmail_list_labels',
// Google Drive (claude.ai hosted + @modelcontextprotocol/server-gdrive)
'google_drive_fetch',
'google_drive_export',
// Google Calendar (claude.ai hosted)
'gcal_list_calendars',
'gcal_list_events',
'gcal_get_event',
// Atlassian/Jira (mcp.atlassian.com — camelCase, normalized)
'atlassian_user_info',
'get_accessible_atlassian_resources',
'get_visible_jira_projects',
'get_jira_project_issue_types_metadata',
'get_jira_issue',
'get_transitions_for_jira_issue',
'get_jira_issue_remote_issue_links',
'get_confluence_spaces',
'get_confluence_page',
'get_pages_in_confluence_space',
'get_confluence_page_ancestors',
'get_confluence_page_descendants',
'get_confluence_page_footer_comments',
'get_confluence_page_inline_comments',
// Community Atlassian (sooperset/mcp-atlassian)
'confluence_get_page',
'confluence_get_page_children',
'confluence_get_comments',
'confluence_get_labels',
'jira_get_issue',
'jira_get_transitions',
'jira_get_worklog',
'jira_get_agile_boards',
'jira_get_board_issues',
'jira_get_sprints_from_board',
'jira_get_sprint_issues',
'jira_get_link_types',
'jira_download_attachments',
'jira_batch_get_changelogs',
'jira_get_user_profile',
'jira_get_project_issues',
'jira_get_project_versions',
// Asana (mcp.asana.com)
'asana_get_attachment',
'asana_get_attachments_for_object',
'asana_get_goal',
'asana_get_goals',
'asana_get_parent_goals_for_goal',
'asana_get_portfolio',
'asana_get_portfolios',
'asana_get_items_for_portfolio',
'asana_get_project',
'asana_get_projects',
'asana_get_project_sections',
'asana_get_project_status',
'asana_get_project_statuses',
'asana_get_project_task_counts',
'asana_get_projects_for_team',
'asana_get_projects_for_workspace',
'asana_get_task',
'asana_get_tasks',
'asana_get_stories_for_task',
'asana_get_teams_for_workspace',
'asana_get_teams_for_user',
'asana_get_team_users',
'asana_get_time_period',
'asana_get_time_periods',
'asana_get_user',
'asana_get_workspace_users',
'asana_list_workspaces',
// Filesystem (@modelcontextprotocol/server-filesystem)
'read_file',
'read_text_file',
'read_media_file',
'read_multiple_files',
'list_directory',
'list_directory_with_sizes',
'directory_tree',
'get_file_info',
'list_allowed_directories',
// Memory (@modelcontextprotocol/server-memory)
'read_graph',
'open_nodes',
// Postgres (@modelcontextprotocol/server-postgres)
'query',
// SQLite (@modelcontextprotocol/server-sqlite)
'read_query',
'list_tables',
'describe_table',
// Git (mcp-server-git)
'git_status',
'git_diff',
'git_diff_unstaged',
'git_diff_staged',
'git_log',
'git_show',
'git_branch',
// Grafana (grafana/mcp-grafana)
'list_teams',
'list_users_by_org',
'get_dashboard_by_uid',
'get_dashboard_summary',
'get_dashboard_property',
'get_dashboard_panel_queries',
'run_panel_query',
'list_datasources',
'get_datasource',
'get_query_examples',
'query_prometheus',
'query_prometheus_histogram',
'list_prometheus_metric_metadata',
'list_prometheus_metric_names',
'list_prometheus_label_names',
'list_prometheus_label_values',
'query_loki_logs',
'query_loki_stats',
'query_loki_patterns',
'list_loki_label_names',
'list_loki_label_values',
'list_incidents',
'get_incident',
'list_sift_investigations',
'get_sift_investigation',
'get_sift_analysis',
'list_oncall_schedules',
'get_oncall_shift',
'get_current_oncall_users',
'list_oncall_teams',
'list_oncall_users',
'list_alert_groups',
'get_alert_group',
'get_annotations',
'get_annotation_tags',
'get_panel_image',
// PagerDuty (PagerDuty/pagerduty-mcp-server)
'list_incidents',
'get_incident',
'get_outlier_incident',
'get_past_incidents',
'get_related_incidents',
'list_incident_notes',
'list_incident_workflows',
'get_incident_workflow',
'list_services',
'get_service',
'list_team_members',
'get_user_data',
'list_schedules',
'get_schedule',
'list_schedule_users',
'list_oncalls',
'list_log_entries',
'get_log_entry',
'list_escalation_policies',
'get_escalation_policy',
'list_event_orchestrations',
'get_event_orchestration',
'list_status_pages',
'get_status_page_post',
'list_alerts_from_incident',
'get_alert_from_incident',
'list_change_events',
'get_change_event',
// Supabase (supabase-community/supabase-mcp)
'list_organizations',
'get_organization',
'get_cost',
'list_extensions',
'list_migrations',
'get_logs',
'get_advisors',
'get_project_url',
'get_publishable_keys',
'generate_typescript_types',
'list_edge_functions',
'get_edge_function',
'list_storage_buckets',
'get_storage_config',
// Stripe (stripe/agent-toolkit)
'get_stripe_account_info',
'retrieve_balance',
'list_customers',
'list_products',
'list_prices',
'list_invoices',
'list_payment_intents',
'list_subscriptions',
'list_coupons',
'list_disputes',
'fetch_stripe_resources',
// PubMed (claude.ai hosted + community)
'get_article_metadata',
'get_full_text_article',
'convert_article_ids',
'get_copyright_status',
'download_paper',
'list_papers',
'read_paper',
'get_paper_fulltext',
'get_pubmed_article_metadata',
'download_pubmed_pdf',
'pubmed_fetch',
'pubmed_pmc_fetch',
'pubmed_spell',
'pubmed_cite',
'pubmed_related',
// BigQuery (claude.ai hosted + community)
'bigquery_query',
'bigquery_schema',
'list_dataset_ids',
'list_table_ids',
'get_dataset_info',
'get_table_info',
// Firecrawl
'firecrawl_scrape',
'firecrawl_map',
'firecrawl_crawl',
'firecrawl_check_crawl_status',
'firecrawl_extract',
// Exa
'get_code_context_exa',
'company_research_exa',
'crawling_exa',
'deep_researcher_check',
// Perplexity
'perplexity_ask',
'perplexity_research',
'perplexity_reason',
// Tavily
'tavily_extract',
'tavily_crawl',
'tavily_map',
'tavily_research',
// Obsidian (MarkusPfundstein)
'obsidian_list_files_in_vault',
'obsidian_list_files_in_dir',
'obsidian_get_file_contents',
'obsidian_batch_get_file_contents',
'obsidian_get_periodic_note',
'obsidian_get_recent_periodic_notes',
'obsidian_get_recent_changes',
// Figma (GLips/Figma-Context-MCP)
'get_figma_data',
'download_figma_images',
// Playwright (microsoft/playwright-mcp)
'browser_console_messages',
'browser_network_requests',
'browser_take_screenshot',
'browser_snapshot',
'browser_get_config',
'browser_route_list',
'browser_cookie_list',
'browser_cookie_get',
'browser_localstorage_list',
'browser_localstorage_get',
'browser_sessionstorage_list',
'browser_sessionstorage_get',
'browser_storage_state',
// Puppeteer (@modelcontextprotocol/server-puppeteer)
'puppeteer_screenshot',
// MongoDB
'list_databases',
'list_collections',
'collection_indexes',
'collection_schema',
'collection_storage_size',
'db_stats',
'explain',
'mongodb_logs',
'aggregate',
'count',
'export',
// Neo4j
'get_neo4j_schema',
'read_neo4j_cypher',
'list_instances',
'get_instance_details',
'get_instance_by_name',
// Elasticsearch (elastic)
'list_indices',
'get_mappings',
'esql',
'get_shards',
// Airtable
'list_records',
'list_bases',
'get_record',
// Todoist (Doist — kebab-case, normalized)
'get_productivity_stats',
'get_overview',
'fetch_object',
'user_info',
'list_workspaces',
'view_attachment',
// AWS (awslabs/mcp)
'get_available_services',
'read_documentation',
'read_sections',
'recommend',
'analyze_log_group',
'analyze_metric',
'describe_log_groups',
'get_active_alarms',
'get_alarm_history',
'get_metric_data',
'get_metric_metadata',
// Kubernetes
'kubectl_get',
'kubectl_describe',
'kubectl_logs',
'kubectl_context',
'explain_resource',
'list_api_resources',
'namespaces_list',
'nodes_log',
'nodes_top',
'pods_get',
'pods_list',
'pods_list_in_namespace',
'pods_log',
'pods_top',
'resources_get',
'resources_list',
])
function normalize(name: string): string {
return name
.replace(/([a-z])([A-Z])/g, '$1_$2')
.replace(/-/g, '_')
.toLowerCase()
}
export function classifyMcpToolForCollapse(
_serverName: string,
toolName: string,
): { isSearch: boolean; isRead: boolean } {
const normalized = normalize(toolName)
return {
isSearch: SEARCH_TOOLS.has(normalized),
isRead: READ_TOOLS.has(normalized),
}
}

View File

@@ -0,0 +1,3 @@
// Actual prompt and description are overridden in mcpClient.ts
export const PROMPT = ''
export const DESCRIPTION = ''