Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
119 kB · 3348 lines
TypeScript
at main
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349import { feature } from 'bun:bundle'import type { Base64ImageSource, ContentBlockParam, MessageParam,} from '@anthropic-ai/sdk/resources/index.mjs'import { Client } from '@modelcontextprotocol/sdk/client/index.js'import { SSEClientTransport, type SSEClientTransportOptions,} from '@modelcontextprotocol/sdk/client/sse.js'import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'import { StreamableHTTPClientTransport, type StreamableHTTPClientTransportOptions,} from '@modelcontextprotocol/sdk/client/streamableHttp.js'import { createFetchWithInit, type FetchLike, type Transport,} from '@modelcontextprotocol/sdk/shared/transport.js'import { CallToolResultSchema, ElicitRequestSchema, type ElicitRequestURLParams, type ElicitResult, ErrorCode, type JSONRPCMessage, type ListPromptsResult, ListPromptsResultSchema, ListResourcesResultSchema, ListRootsRequestSchema, type ListToolsResult, ListToolsResultSchema, McpError, type PromptMessage, type ResourceLink,} from '@modelcontextprotocol/sdk/types.js'import mapValues from 'lodash-es/mapValues.js'import memoize from 'lodash-es/memoize.js'import zipObject from 'lodash-es/zipObject.js'import pMap from 'p-map'import { getOriginalCwd, getSessionId } from '../../bootstrap/state.js'import type { Command } from '../../commands.js'import { getOauthConfig } from '../../constants/oauth.js'import { PRODUCT_URL } from '../../constants/product.js'import type { AppState } from '../../state/AppState.js'import { type Tool, type ToolCallProgress, toolMatchesName,} from '../../Tool.js'import { ListMcpResourcesTool } from '../../tools/ListMcpResourcesTool/ListMcpResourcesTool.js'import { type MCPProgress, MCPTool } from '../../tools/MCPTool/MCPTool.js'import { createMcpAuthTool } from '../../tools/McpAuthTool/McpAuthTool.js'import { ReadMcpResourceTool } from '../../tools/ReadMcpResourceTool/ReadMcpResourceTool.js'import { createAbortController } from '../../utils/abortController.js'import { count } from '../../utils/array.js'import { checkAndRefreshOAuthTokenIfNeeded, getClaudeAIOAuthTokens, handleOAuth401Error,} from '../../utils/auth.js'import { registerCleanup } from '../../utils/cleanupRegistry.js'import { detectCodeIndexingFromMcpServerName } from '../../utils/codeIndexing.js'import { logForDebugging } from '../../utils/debug.js'import { isEnvDefinedFalsy, isEnvTruthy } from '../../utils/envUtils.js'import { errorMessage, TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,} from '../../utils/errors.js'import { getMCPUserAgent } from '../../utils/http.js'import { maybeNotifyIDEConnected } from '../../utils/ide.js'import { maybeResizeAndDownsampleImageBuffer } from '../../utils/imageResizer.js'import { logMCPDebug, logMCPError } from '../../utils/log.js'import { getBinaryBlobSavedMessage, getFormatDescription, getLargeOutputInstructions, persistBinaryContent,} from '../../utils/mcpOutputStorage.js'import { getContentSizeEstimate, type MCPToolResult, mcpContentNeedsTruncation, truncateMcpContentIfNeeded,} from '../../utils/mcpValidation.js'import { WebSocketTransport } from '../../utils/mcpWebSocketTransport.js'import { memoizeWithLRU } from '../../utils/memoize.js'import { getWebSocketTLSOptions } from '../../utils/mtls.js'import { getProxyFetchOptions, getWebSocketProxyAgent, getWebSocketProxyUrl,} from '../../utils/proxy.js'import { recursivelySanitizeUnicode } from '../../utils/sanitization.js'import { getSessionIngressAuthToken } from '../../utils/sessionIngressAuth.js'import { subprocessEnv } from '../../utils/subprocessEnv.js'import { isPersistError, persistToolResult,} from '../../utils/toolResultStorage.js'import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent,} from '../analytics/index.js'import { type ElicitationWaitingState, runElicitationHooks, runElicitationResultHooks,} from './elicitationHandler.js'import { buildMcpToolName } from './mcpStringUtils.js'import { normalizeNameForMCP } from './normalization.js'import { getLoggingSafeMcpBaseUrl } from './utils.js'
/* eslint-disable @typescript-eslint/no-require-imports */const fetchMcpSkillsForClient = feature('MCP_SKILLS') ? ( require('../../skills/mcpSkills.js') as typeof import('../../skills/mcpSkills.js') ).fetchMcpSkillsForClient : null
import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'import type { AssistantMessage } from 'src/types/message.js'/* eslint-enable @typescript-eslint/no-require-imports */import { classifyMcpToolForCollapse } from '../../tools/MCPTool/classifyForCollapse.js'import { clearKeychainCache } from '../../utils/secureStorage/macOsKeychainHelpers.js'import { sleep } from '../../utils/sleep.js'import { ClaudeAuthProvider, hasMcpDiscoveryButNoToken, wrapFetchWithStepUpDetection,} from './auth.js'import { markClaudeAiMcpConnected } from './claudeai.js'import { getAllMcpConfigs, isMcpServerDisabled } from './config.js'import { getMcpServerHeaders } from './headersHelper.js'import { SdkControlClientTransport } from './SdkControlTransport.js'import type { ConnectedMCPServer, MCPServerConnection, McpSdkServerConfig, ScopedMcpServerConfig, ServerResource,} from './types.js'
/** * Custom error class to indicate that an MCP tool call failed due to * authentication issues (e.g., expired OAuth token returning 401). * This error should be caught at the tool execution layer to update * the client's status to 'needs-auth'. */export class McpAuthError extends Error { serverName: string constructor(serverName: string, message: string) { super(message) this.name = 'McpAuthError' this.serverName = serverName }}
/** * Thrown when an MCP session has expired and the connection cache has been cleared. * The caller should get a fresh client via ensureConnectedClient and retry. */class McpSessionExpiredError extends Error { constructor(serverName: string) { super(`MCP server "${serverName}" session expired`) this.name = 'McpSessionExpiredError' }}
/** * Thrown when an MCP tool returns `isError: true`. Carries the result's `_meta` * so SDK consumers can still receive it — per the MCP spec, `_meta` is on the * base Result type and is valid on error results. */export class McpToolCallError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS extends TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS { constructor( message: string, telemetryMessage: string, readonly mcpMeta?: { _meta?: Record<string, unknown> }, ) { super(message, telemetryMessage) this.name = 'McpToolCallError' }}
/** * Detects whether an error is an MCP "Session not found" error (HTTP 404 + JSON-RPC code -32001). * Per the MCP spec, servers return 404 when a session ID is no longer valid. * We check both signals to avoid false positives from generic 404s (wrong URL, server gone, etc.). */export function isMcpSessionExpiredError(error: Error): boolean { const httpStatus = 'code' in error ? (error as Error & { code?: number }).code : undefined if (httpStatus !== 404) { return false } // The SDK embeds the response body text in the error message. // MCP servers return: {"error":{"code":-32001,"message":"Session not found"},...} // Check for the JSON-RPC error code to distinguish from generic web server 404s. return ( error.message.includes('"code":-32001') || error.message.includes('"code": -32001') )}
/** * Default timeout for MCP tool calls (effectively infinite - ~27.8 hours). */const DEFAULT_MCP_TOOL_TIMEOUT_MS = 100_000_000
/** * Cap on MCP tool descriptions and server instructions sent to the model. * OpenAPI-generated MCP servers have been observed dumping 15-60KB of endpoint * docs into tool.description; this caps the p95 tail without losing the intent. */const MAX_MCP_DESCRIPTION_LENGTH = 2048
/** * Gets the timeout for MCP tool calls in milliseconds. * Uses MCP_TOOL_TIMEOUT environment variable if set, otherwise defaults to ~27.8 hours. */function getMcpToolTimeoutMs(): number { return ( parseInt(process.env.MCP_TOOL_TIMEOUT || '', 10) || DEFAULT_MCP_TOOL_TIMEOUT_MS )}
import { isClaudeInChromeMCPServer } from '../../utils/claudeInChrome/common.js'
// Lazy: toolRendering.tsx pulls React/ink; only needed when Claude-in-Chrome MCP server is connected/* eslint-disable @typescript-eslint/no-require-imports */const claudeInChromeToolRendering = (): typeof import('../../utils/claudeInChrome/toolRendering.js') => require('../../utils/claudeInChrome/toolRendering.js')// Lazy: wrapper.tsx → hostAdapter.ts → executor.ts pulls both native modules// (@ant/computer-use-input + @ant/computer-use-swift). Runtime-gated by// GrowthBook tengu_malort_pedway (see gates.ts).const computerUseWrapper = feature('CHICAGO_MCP') ? (): typeof import('../../utils/computerUse/wrapper.js') => require('../../utils/computerUse/wrapper.js') : undefinedconst isComputerUseMCPServer = feature('CHICAGO_MCP') ? ( require('../../utils/computerUse/common.js') as typeof import('../../utils/computerUse/common.js') ).isComputerUseMCPServer : undefined
import { mkdir, readFile, unlink, writeFile } from 'fs/promises'import { dirname, join } from 'path'import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'/* eslint-enable @typescript-eslint/no-require-imports */import { jsonParse, jsonStringify } from '../../utils/slowOperations.js'
const MCP_AUTH_CACHE_TTL_MS = 15 * 60 * 1000 // 15 min
type McpAuthCacheData = Record<string, { timestamp: number }>
function getMcpAuthCachePath(): string { return join(getClaudeConfigHomeDir(), 'mcp-needs-auth-cache.json')}
// Memoized so N concurrent isMcpAuthCached() calls during batched connection// share a single file read instead of N reads of the same file. Invalidated// on write (setMcpAuthCacheEntry) and clear (clearMcpAuthCache). Not using// lodash memoize because we need to null out the cache, not delete by key.let authCachePromise: Promise<McpAuthCacheData> | null = null
function getMcpAuthCache(): Promise<McpAuthCacheData> { if (!authCachePromise) { authCachePromise = readFile(getMcpAuthCachePath(), 'utf-8') .then(data => jsonParse(data) as McpAuthCacheData) .catch(() => ({})) } return authCachePromise}
async function isMcpAuthCached(serverId: string): Promise<boolean> { const cache = await getMcpAuthCache() const entry = cache[serverId] if (!entry) { return false } return Date.now() - entry.timestamp < MCP_AUTH_CACHE_TTL_MS}
// Serialize cache writes through a promise chain to prevent concurrent// read-modify-write races when multiple servers return 401 in the same batchlet writeChain = Promise.resolve()
function setMcpAuthCacheEntry(serverId: string): void { writeChain = writeChain .then(async () => { const cache = await getMcpAuthCache() cache[serverId] = { timestamp: Date.now() } const cachePath = getMcpAuthCachePath() await mkdir(dirname(cachePath), { recursive: true }) await writeFile(cachePath, jsonStringify(cache)) // Invalidate the read cache so subsequent reads see the new entry. // Safe because writeChain serializes writes: the next write's // getMcpAuthCache() call will re-read the file with this entry present. authCachePromise = null }) .catch(() => { // Best-effort cache write })}
export function clearMcpAuthCache(): void { authCachePromise = null void unlink(getMcpAuthCachePath()).catch(() => { // Cache file may not exist })}
/** * Spread-ready analytics field for the server's base URL. Calls * getLoggingSafeMcpBaseUrl once (not twice like the inline ternary it replaces). * Typed as AnalyticsMetadata since the URL is query-stripped and safe to log. */function mcpBaseUrlAnalytics(serverRef: ScopedMcpServerConfig): { mcpServerBaseUrl?: AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS} { const url = getLoggingSafeMcpBaseUrl(serverRef) return url ? { mcpServerBaseUrl: url as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, } : {}}
/** * Shared handler for sse/http/claudeai-proxy auth failures during connect: * emits tengu_mcp_server_needs_auth, caches the needs-auth entry, and returns * the needs-auth connection result. */function handleRemoteAuthFailure( name: string, serverRef: ScopedMcpServerConfig, transportType: 'sse' | 'http' | 'claudeai-proxy',): MCPServerConnection { logEvent('tengu_mcp_server_needs_auth', { transportType: transportType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, ...mcpBaseUrlAnalytics(serverRef), }) const label: Record<typeof transportType, string> = { sse: 'SSE', http: 'HTTP', 'claudeai-proxy': 'claude.ai proxy', } logMCPDebug( name, `Authentication required for ${label[transportType]} server`, ) setMcpAuthCacheEntry(name) return { name, type: 'needs-auth', config: serverRef }}
/** * Fetch wrapper for claude.ai proxy connections. Attaches the OAuth bearer * token and retries once on 401 via handleOAuth401Error (force-refresh). * * The Anthropic API path has this retry (withRetry.ts, grove.ts) to handle * memoize-cache staleness and clock drift. Without the same here, a single * stale token mass-401s every claude.ai connector and sticks them all in the * 15-min needs-auth cache. */export function createClaudeAiProxyFetch(innerFetch: FetchLike): FetchLike { return async (url, init) => { const doRequest = async () => { await checkAndRefreshOAuthTokenIfNeeded() const currentTokens = getClaudeAIOAuthTokens() if (!currentTokens) { throw new Error('No claude.ai OAuth token available') } // eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins const headers = new Headers(init?.headers) headers.set('Authorization', `Bearer ${currentTokens.accessToken}`) const response = await innerFetch(url, { ...init, headers }) // Return the exact token that was sent. Reading getClaudeAIOAuthTokens() // again after the request is wrong under concurrent 401s: another // connector's handleOAuth401Error clears the memoize cache, so we'd read // the NEW token from keychain, pass it to handleOAuth401Error, which // finds same-as-keychain → returns false → skips retry. Same pattern as // bridgeApi.ts withOAuthRetry (token passed as fn param). return { response, sentToken: currentTokens.accessToken } }
const { response, sentToken } = await doRequest() if (response.status !== 401) { return response } // handleOAuth401Error returns true only if the token actually changed // (keychain had a newer one, or force-refresh succeeded). Gate retry on // that — otherwise we double round-trip time for every connector whose // downstream service genuinely needs auth (the common case: 30+ servers // with "MCP server requires authentication but no OAuth token configured"). const tokenChanged = await handleOAuth401Error(sentToken).catch(() => false) logEvent('tengu_mcp_claudeai_proxy_401', { tokenChanged: tokenChanged as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) if (!tokenChanged) { // ELOCKED contention: another connector may have won the lockfile and refreshed — check if token changed underneath us const now = getClaudeAIOAuthTokens()?.accessToken if (!now || now === sentToken) { return response } } try { return (await doRequest()).response } catch { // Retry itself failed (network error). Return the original 401 so the // outer handler can classify it. return response } }}
// Minimal interface for WebSocket instances passed to mcpWebSocketTransporttype WsClientLike = { readonly readyState: number close(): void send(data: string): void}
/** * Create a ws.WebSocket client with the MCP protocol. * Bun's ws shim types lack the 3-arg constructor (url, protocols, options) * that the real ws package supports, so we cast the constructor here. */async function createNodeWsClient( url: string, options: Record<string, unknown>,): Promise<WsClientLike> { const wsModule = await import('ws') const WS = wsModule.default as unknown as new ( url: string, protocols: string[], options: Record<string, unknown>, ) => WsClientLike return new WS(url, ['mcp'], options)}
const IMAGE_MIME_TYPES = new Set([ 'image/jpeg', 'image/png', 'image/gif', 'image/webp',])
function getConnectionTimeoutMs(): number { return parseInt(process.env.MCP_TIMEOUT || '', 10) || 30000}
/** * Default timeout for individual MCP requests (auth, tool calls, etc.) */const MCP_REQUEST_TIMEOUT_MS = 60000
/** * MCP Streamable HTTP spec requires clients to advertise acceptance of both * JSON and SSE on every POST. Servers that enforce this strictly reject * requests without it (HTTP 406). * https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#sending-messages-to-the-server */const MCP_STREAMABLE_HTTP_ACCEPT = 'application/json, text/event-stream'
/** * Wraps a fetch function to apply a fresh timeout signal to each request. * This avoids the bug where a single AbortSignal.timeout() created at connection * time becomes stale after 60 seconds, causing all subsequent requests to fail * immediately with "The operation timed out." Uses a 60-second timeout. * * Also ensures the Accept header required by the MCP Streamable HTTP spec is * present on POSTs. The MCP SDK sets this inside StreamableHTTPClientTransport.send(), * but it is attached to a Headers instance that passes through an object spread here, * and some runtimes/agents have been observed dropping it before it reaches the wire. * See https://github.com/anthropics/claude-agent-sdk-typescript/issues/202. * Normalizing here (the last wrapper before fetch()) guarantees it is sent. * * GET requests are excluded from the timeout since, for MCP transports, they are * long-lived SSE streams meant to stay open indefinitely. (Auth-related GETs use * a separate fetch wrapper with its own timeout in auth.ts.) * * @param baseFetch - The fetch function to wrap */export function wrapFetchWithTimeout(baseFetch: FetchLike): FetchLike { return async (url: string | URL, init?: RequestInit) => { const method = (init?.method ?? 'GET').toUpperCase()
// Skip timeout for GET requests - in MCP transports, these are long-lived SSE streams. // (OAuth discovery GETs in auth.ts use a separate createAuthFetch() with its own timeout.) if (method === 'GET') { return baseFetch(url, init) }
// Normalize headers and guarantee the Streamable-HTTP Accept value. new Headers() // accepts HeadersInit | undefined and copies from plain objects, tuple arrays, // and existing Headers instances — so whatever shape the SDK handed us, the // Accept value survives the spread below as an own property of a concrete object. // eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins const headers = new Headers(init?.headers) if (!headers.has('accept')) { headers.set('accept', MCP_STREAMABLE_HTTP_ACCEPT) }
// Use setTimeout instead of AbortSignal.timeout() so we can clearTimeout on // completion. AbortSignal.timeout's internal timer is only released when the // signal is GC'd, which in Bun is lazy — ~2.4KB of native memory per request // lingers for the full 60s even when the request completes in milliseconds. const controller = new AbortController() const timer = setTimeout( c => c.abort(new DOMException('The operation timed out.', 'TimeoutError')), MCP_REQUEST_TIMEOUT_MS, controller, ) timer.unref?.()
const parentSignal = init?.signal const abort = () => controller.abort(parentSignal?.reason) parentSignal?.addEventListener('abort', abort) if (parentSignal?.aborted) { controller.abort(parentSignal.reason) }
const cleanup = () => { clearTimeout(timer) parentSignal?.removeEventListener('abort', abort) }
try { const response = await baseFetch(url, { ...init, headers, signal: controller.signal, }) cleanup() return response } catch (error) { cleanup() throw error } }}
export function getMcpServerConnectionBatchSize(): number { return parseInt(process.env.MCP_SERVER_CONNECTION_BATCH_SIZE || '', 10) || 3}
function getRemoteMcpServerConnectionBatchSize(): number { return ( parseInt(process.env.MCP_REMOTE_SERVER_CONNECTION_BATCH_SIZE || '', 10) || 20 )}
function isLocalMcpServer(config: ScopedMcpServerConfig): boolean { return !config.type || config.type === 'stdio' || config.type === 'sdk'}
// For the IDE MCP servers, we only include specific toolsconst ALLOWED_IDE_TOOLS = ['mcp__ide__executeCode', 'mcp__ide__getDiagnostics']function isIncludedMcpTool(tool: Tool): boolean { return ( !tool.name.startsWith('mcp__ide__') || ALLOWED_IDE_TOOLS.includes(tool.name) )}
/** * Generates the cache key for a server connection * @param name Server name * @param serverRef Server configuration * @returns Cache key string */export function getServerCacheKey( name: string, serverRef: ScopedMcpServerConfig,): string { return `${name}-${jsonStringify(serverRef)}`}
/** * TODO (ollie): The memoization here increases complexity by a lot, and im not sure it really improves performance * Attempts to connect to a single MCP server * @param name Server name * @param serverRef Scoped server configuration * @returns A wrapped client (either connected or failed) */export const connectToServer = memoize( async ( name: string, serverRef: ScopedMcpServerConfig, serverStats?: { totalServers: number stdioCount: number sseCount: number httpCount: number sseIdeCount: number wsIdeCount: number }, ): Promise<MCPServerConnection> => { const connectStartTime = Date.now() let inProcessServer: | { connect(t: Transport): Promise<void>; close(): Promise<void> } | undefined try { let transport
// If we have the session ingress JWT, we will connect via the session ingress rather than // to remote MCP's directly. const sessionIngressToken = getSessionIngressAuthToken()
if (serverRef.type === 'sse') { // Create an auth provider for this server const authProvider = new ClaudeAuthProvider(name, serverRef)
// Get combined headers (static + dynamic) const combinedHeaders = await getMcpServerHeaders(name, serverRef)
// Use the auth provider with SSEClientTransport const transportOptions: SSEClientTransportOptions = { authProvider, // Use fresh timeout per request to avoid stale AbortSignal bug. // Step-up detection wraps innermost so the 403 is seen before the // SDK's handler calls auth() → tokens(). fetch: wrapFetchWithTimeout( wrapFetchWithStepUpDetection(createFetchWithInit(), authProvider), ), requestInit: { headers: { 'User-Agent': getMCPUserAgent(), ...combinedHeaders, }, }, }
// IMPORTANT: Always set eventSourceInit with a fetch that does NOT use the // timeout wrapper. The EventSource connection is long-lived (stays open indefinitely // to receive server-sent events), so applying a 60-second timeout would kill it. // The timeout is only meant for individual API requests (POST, auth refresh), not // the persistent SSE stream. transportOptions.eventSourceInit = { fetch: async (url: string | URL, init?: RequestInit) => { // Get auth headers from the auth provider const authHeaders: Record<string, string> = {} const tokens = await authProvider.tokens() if (tokens) { authHeaders.Authorization = `Bearer ${tokens.access_token}` }
const proxyOptions = getProxyFetchOptions() // eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins return fetch(url, { ...init, ...proxyOptions, headers: { 'User-Agent': getMCPUserAgent(), ...authHeaders, ...init?.headers, ...combinedHeaders, Accept: 'text/event-stream', }, }) }, }
transport = new SSEClientTransport( new URL(serverRef.url), transportOptions, ) logMCPDebug(name, `SSE transport initialized, awaiting connection`) } else if (serverRef.type === 'sse-ide') { logMCPDebug(name, `Setting up SSE-IDE transport to ${serverRef.url}`) // IDE servers don't need authentication // TODO: Use the auth token provided in the lockfile const proxyOptions = getProxyFetchOptions() const transportOptions: SSEClientTransportOptions = proxyOptions.dispatcher ? { eventSourceInit: { fetch: async (url: string | URL, init?: RequestInit) => { // eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins return fetch(url, { ...init, ...proxyOptions, headers: { 'User-Agent': getMCPUserAgent(), ...init?.headers, }, }) }, }, } : {}
transport = new SSEClientTransport( new URL(serverRef.url), Object.keys(transportOptions).length > 0 ? transportOptions : undefined, ) } else if (serverRef.type === 'ws-ide') { const tlsOptions = getWebSocketTLSOptions() const wsHeaders = { 'User-Agent': getMCPUserAgent(), ...(serverRef.authToken && { 'X-Claude-Code-Ide-Authorization': serverRef.authToken, }), }
let wsClient: WsClientLike if (typeof Bun !== 'undefined') { // Bun's WebSocket supports headers/proxy/tls options but the DOM typings don't // eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins wsClient = new globalThis.WebSocket(serverRef.url, { protocols: ['mcp'], headers: wsHeaders, proxy: getWebSocketProxyUrl(serverRef.url), tls: tlsOptions || undefined, } as unknown as string[]) } else { wsClient = await createNodeWsClient(serverRef.url, { headers: wsHeaders, agent: getWebSocketProxyAgent(serverRef.url), ...(tlsOptions || {}), }) } transport = new WebSocketTransport(wsClient) } else if (serverRef.type === 'ws') { logMCPDebug( name, `Initializing WebSocket transport to ${serverRef.url}`, )
const combinedHeaders = await getMcpServerHeaders(name, serverRef)
const tlsOptions = getWebSocketTLSOptions() const wsHeaders = { 'User-Agent': getMCPUserAgent(), ...(sessionIngressToken && { Authorization: `Bearer ${sessionIngressToken}`, }), ...combinedHeaders, }
// Redact sensitive headers before logging const wsHeadersForLogging = mapValues(wsHeaders, (value, key) => key.toLowerCase() === 'authorization' ? '[REDACTED]' : value, )
logMCPDebug( name, `WebSocket transport options: ${jsonStringify({ url: serverRef.url, headers: wsHeadersForLogging, hasSessionAuth: !!sessionIngressToken, })}`, )
let wsClient: WsClientLike if (typeof Bun !== 'undefined') { // Bun's WebSocket supports headers/proxy/tls options but the DOM typings don't // eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins wsClient = new globalThis.WebSocket(serverRef.url, { protocols: ['mcp'], headers: wsHeaders, proxy: getWebSocketProxyUrl(serverRef.url), tls: tlsOptions || undefined, } as unknown as string[]) } else { wsClient = await createNodeWsClient(serverRef.url, { headers: wsHeaders, agent: getWebSocketProxyAgent(serverRef.url), ...(tlsOptions || {}), }) } transport = new WebSocketTransport(wsClient) } else if (serverRef.type === 'http') { logMCPDebug(name, `Initializing HTTP transport to ${serverRef.url}`) logMCPDebug( name, `Node version: ${process.version}, Platform: ${process.platform}`, ) logMCPDebug( name, `Environment: ${jsonStringify({ NODE_OPTIONS: process.env.NODE_OPTIONS || 'not set', UV_THREADPOOL_SIZE: process.env.UV_THREADPOOL_SIZE || 'default', HTTP_PROXY: process.env.HTTP_PROXY || 'not set', HTTPS_PROXY: process.env.HTTPS_PROXY || 'not set', NO_PROXY: process.env.NO_PROXY || 'not set', })}`, )
// Create an auth provider for this server const authProvider = new ClaudeAuthProvider(name, serverRef)
// Get combined headers (static + dynamic) const combinedHeaders = await getMcpServerHeaders(name, serverRef)
// Check if this server has stored OAuth tokens. If so, the SDK's // authProvider will set Authorization — don't override with the // session ingress token (SDK merges requestInit AFTER authProvider). // CCR proxy URLs (ccr_shttp_mcp) have no stored OAuth, so they still // get the ingress token. See PR #24454 discussion. const hasOAuthTokens = !!(await authProvider.tokens())
// Use the auth provider with StreamableHTTPClientTransport const proxyOptions = getProxyFetchOptions() logMCPDebug( name, `Proxy options: ${proxyOptions.dispatcher ? 'custom dispatcher' : 'default'}`, )
const transportOptions: StreamableHTTPClientTransportOptions = { authProvider, // Use fresh timeout per request to avoid stale AbortSignal bug. // Step-up detection wraps innermost so the 403 is seen before the // SDK's handler calls auth() → tokens(). fetch: wrapFetchWithTimeout( wrapFetchWithStepUpDetection(createFetchWithInit(), authProvider), ), requestInit: { ...proxyOptions, headers: { 'User-Agent': getMCPUserAgent(), ...(sessionIngressToken && !hasOAuthTokens && { Authorization: `Bearer ${sessionIngressToken}`, }), ...combinedHeaders, }, }, }
// Redact sensitive headers before logging const headersForLogging = transportOptions.requestInit?.headers ? mapValues( transportOptions.requestInit.headers as Record<string, string>, (value, key) => key.toLowerCase() === 'authorization' ? '[REDACTED]' : value, ) : undefined
logMCPDebug( name, `HTTP transport options: ${jsonStringify({ url: serverRef.url, headers: headersForLogging, hasAuthProvider: !!authProvider, timeoutMs: MCP_REQUEST_TIMEOUT_MS, })}`, )
transport = new StreamableHTTPClientTransport( new URL(serverRef.url), transportOptions, ) logMCPDebug(name, `HTTP transport created successfully`) } else if (serverRef.type === 'sdk') { throw new Error('SDK servers should be handled in print.ts') } else if (serverRef.type === 'claudeai-proxy') { logMCPDebug( name, `Initializing claude.ai proxy transport for server ${serverRef.id}`, )
const tokens = getClaudeAIOAuthTokens() if (!tokens) { throw new Error('No claude.ai OAuth token found') }
const oauthConfig = getOauthConfig() const proxyUrl = `${oauthConfig.MCP_PROXY_URL}${oauthConfig.MCP_PROXY_PATH.replace('{server_id}', serverRef.id)}`
logMCPDebug(name, `Using claude.ai proxy at ${proxyUrl}`)
// eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins const fetchWithAuth = createClaudeAiProxyFetch(globalThis.fetch)
const proxyOptions = getProxyFetchOptions() const transportOptions: StreamableHTTPClientTransportOptions = { // Wrap fetchWithAuth with fresh timeout per request fetch: wrapFetchWithTimeout(fetchWithAuth), requestInit: { ...proxyOptions, headers: { 'User-Agent': getMCPUserAgent(), 'X-Mcp-Client-Session-Id': getSessionId(), }, }, }
transport = new StreamableHTTPClientTransport( new URL(proxyUrl), transportOptions, ) logMCPDebug(name, `claude.ai proxy transport created successfully`) } else if ( (serverRef.type === 'stdio' || !serverRef.type) && isClaudeInChromeMCPServer(name) ) { // Run the Chrome MCP server in-process to avoid spawning a ~325 MB subprocess const { createChromeContext } = await import( '../../utils/claudeInChrome/mcpServer.js' ) const { createClaudeForChromeMcpServer } = await import( '@ant/claude-for-chrome-mcp' ) const { createLinkedTransportPair } = await import( './InProcessTransport.js' ) const context = createChromeContext(serverRef.env) inProcessServer = createClaudeForChromeMcpServer(context) const [clientTransport, serverTransport] = createLinkedTransportPair() await inProcessServer.connect(serverTransport) transport = clientTransport logMCPDebug(name, `In-process Chrome MCP server started`) } else if ( feature('CHICAGO_MCP') && (serverRef.type === 'stdio' || !serverRef.type) && isComputerUseMCPServer!(name) ) { // Run the Computer Use MCP server in-process — same rationale as // Chrome above. The package's CallTool handler is a stub; real // dispatch goes through wrapper.tsx's .call() override. const { createComputerUseMcpServerForCli } = await import( '../../utils/computerUse/mcpServer.js' ) const { createLinkedTransportPair } = await import( './InProcessTransport.js' ) inProcessServer = await createComputerUseMcpServerForCli() const [clientTransport, serverTransport] = createLinkedTransportPair() await inProcessServer.connect(serverTransport) transport = clientTransport logMCPDebug(name, `In-process Computer Use MCP server started`) } else if (serverRef.type === 'stdio' || !serverRef.type) { const finalCommand = process.env.CLAUDE_CODE_SHELL_PREFIX || serverRef.command const finalArgs = process.env.CLAUDE_CODE_SHELL_PREFIX ? [[serverRef.command, ...serverRef.args].join(' ')] : serverRef.args transport = new StdioClientTransport({ command: finalCommand, args: finalArgs, env: { ...subprocessEnv(), ...serverRef.env, } as Record<string, string>, stderr: 'pipe', // prevents error output from the MCP server from printing to the UI }) } else { throw new Error(`Unsupported server type: ${serverRef.type}`) }
// Set up stderr logging for stdio transport before connecting in case there are any stderr // outputs emitted during the connection start (this can be useful for debugging failed connections). // Store handler reference for cleanup to prevent memory leaks let stderrHandler: ((data: Buffer) => void) | undefined let stderrOutput = '' if (serverRef.type === 'stdio' || !serverRef.type) { const stdioTransport = transport as StdioClientTransport if (stdioTransport.stderr) { stderrHandler = (data: Buffer) => { // Cap stderr accumulation to prevent unbounded memory growth if (stderrOutput.length < 64 * 1024 * 1024) { try { stderrOutput += data.toString() } catch { // Ignore errors from exceeding max string length } } } stdioTransport.stderr.on('data', stderrHandler) } }
const client = new Client( { name: 'claude-code', title: 'Claude Code', version: MACRO.VERSION ?? 'unknown', description: "Anthropic's agentic coding tool", websiteUrl: PRODUCT_URL, }, { capabilities: { roots: {}, // Empty object declares the capability. Sending {form:{},url:{}} // breaks Java MCP SDK servers (Spring AI) whose Elicitation class // has zero fields and fails on unknown properties. elicitation: {}, }, }, )
// Add debug logging for client events if available if (serverRef.type === 'http') { logMCPDebug(name, `Client created, setting up request handler`) }
client.setRequestHandler(ListRootsRequestSchema, async () => { logMCPDebug(name, `Received ListRoots request from server`) return { roots: [ { uri: `file://${getOriginalCwd()}`, }, ], } })
// Add a timeout to connection attempts to prevent tests from hanging indefinitely logMCPDebug( name, `Starting connection with timeout of ${getConnectionTimeoutMs()}ms`, )
// For HTTP transport, try a basic connectivity test first if (serverRef.type === 'http') { logMCPDebug(name, `Testing basic HTTP connectivity to ${serverRef.url}`) try { const testUrl = new URL(serverRef.url) logMCPDebug( name, `Parsed URL: host=${testUrl.hostname}, port=${testUrl.port || 'default'}, protocol=${testUrl.protocol}`, )
// Log DNS resolution attempt if ( testUrl.hostname === '127.0.0.1' || testUrl.hostname === 'localhost' ) { logMCPDebug(name, `Using loopback address: ${testUrl.hostname}`) } } catch (urlError) { logMCPDebug(name, `Failed to parse URL: ${urlError}`) } }
const connectPromise = client.connect(transport) const timeoutPromise = new Promise<never>((_, reject) => { const timeoutId = setTimeout(() => { const elapsed = Date.now() - connectStartTime logMCPDebug( name, `Connection timeout triggered after ${elapsed}ms (limit: ${getConnectionTimeoutMs()}ms)`, ) if (inProcessServer) { inProcessServer.close().catch(() => {}) } transport.close().catch(() => {}) reject( new TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS( `MCP server "${name}" connection timed out after ${getConnectionTimeoutMs()}ms`, 'MCP connection timeout', ), ) }, getConnectionTimeoutMs())
// Clean up timeout if connect resolves or rejects connectPromise.then( () => { clearTimeout(timeoutId) }, _error => { clearTimeout(timeoutId) }, ) })
try { await Promise.race([connectPromise, timeoutPromise]) if (stderrOutput) { logMCPError(name, `Server stderr: ${stderrOutput}`) stderrOutput = '' // Release accumulated string to prevent memory growth } const elapsed = Date.now() - connectStartTime logMCPDebug( name, `Successfully connected (transport: ${serverRef.type || 'stdio'}) in ${elapsed}ms`, ) } catch (error) { const elapsed = Date.now() - connectStartTime // SSE-specific error logging if (serverRef.type === 'sse' && error instanceof Error) { logMCPDebug( name, `SSE Connection failed after ${elapsed}ms: ${jsonStringify({ url: serverRef.url, error: error.message, errorType: error.constructor.name, stack: error.stack, })}`, ) logMCPError(name, error)
if (error instanceof UnauthorizedError) { return handleRemoteAuthFailure(name, serverRef, 'sse') } } else if (serverRef.type === 'http' && error instanceof Error) { const errorObj = error as Error & { cause?: unknown code?: string errno?: string | number syscall?: string } logMCPDebug( name, `HTTP Connection failed after ${elapsed}ms: ${error.message} (code: ${errorObj.code || 'none'}, errno: ${errorObj.errno || 'none'})`, ) logMCPError(name, error)
if (error instanceof UnauthorizedError) { return handleRemoteAuthFailure(name, serverRef, 'http') } } else if ( serverRef.type === 'claudeai-proxy' && error instanceof Error ) { logMCPDebug( name, `claude.ai proxy connection failed after ${elapsed}ms: ${error.message}`, ) logMCPError(name, error)
// StreamableHTTPError has a `code` property with the HTTP status const errorCode = (error as Error & { code?: number }).code if (errorCode === 401) { return handleRemoteAuthFailure(name, serverRef, 'claudeai-proxy') } } else if ( serverRef.type === 'sse-ide' || serverRef.type === 'ws-ide' ) { logEvent('tengu_mcp_ide_server_connection_failed', { connectionDurationMs: elapsed, }) } if (inProcessServer) { inProcessServer.close().catch(() => {}) } transport.close().catch(() => {}) if (stderrOutput) { logMCPError(name, `Server stderr: ${stderrOutput}`) } throw error }
const capabilities = client.getServerCapabilities() const serverVersion = client.getServerVersion() const rawInstructions = client.getInstructions() let instructions = rawInstructions if ( rawInstructions && rawInstructions.length > MAX_MCP_DESCRIPTION_LENGTH ) { instructions = rawInstructions.slice(0, MAX_MCP_DESCRIPTION_LENGTH) + '… [truncated]' logMCPDebug( name, `Server instructions truncated from ${rawInstructions.length} to ${MAX_MCP_DESCRIPTION_LENGTH} chars`, ) }
// Log successful connection details logMCPDebug( name, `Connection established with capabilities: ${jsonStringify({ hasTools: !!capabilities?.tools, hasPrompts: !!capabilities?.prompts, hasResources: !!capabilities?.resources, hasResourceSubscribe: !!capabilities?.resources?.subscribe, serverVersion: serverVersion || 'unknown', })}`, ) logForDebugging( `[MCP] Server "${name}" connected with subscribe=${!!capabilities?.resources?.subscribe}`, )
// Register default elicitation handler that returns cancel during the // window before registerElicitationHandler overwrites it in // onConnectionAttempt (useManageMCPConnections). client.setRequestHandler(ElicitRequestSchema, async request => { logMCPDebug( name, `Elicitation request received during initialization: ${jsonStringify(request)}`, ) return { action: 'cancel' as const } })
if (serverRef.type === 'sse-ide' || serverRef.type === 'ws-ide') { const ideConnectionDurationMs = Date.now() - connectStartTime logEvent('tengu_mcp_ide_server_connection_succeeded', { connectionDurationMs: ideConnectionDurationMs, serverVersion: serverVersion as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) try { void maybeNotifyIDEConnected(client) } catch (error) { logMCPError( name, `Failed to send ide_connected notification: ${error}`, ) } }
// Enhanced connection drop detection and logging for all transport types const connectionStartTime = Date.now() let hasErrorOccurred = false
// Store original handlers const originalOnerror = client.onerror const originalOnclose = client.onclose
// The SDK's transport calls onerror on connection failures but doesn't call onclose, // which CC uses to trigger reconnection. We bridge this gap by tracking consecutive // terminal errors and manually closing after MAX_ERRORS_BEFORE_RECONNECT failures. let consecutiveConnectionErrors = 0 const MAX_ERRORS_BEFORE_RECONNECT = 3
// Guard against re-entry: close() aborts in-flight streams which may fire // onerror again before the close chain completes. let hasTriggeredClose = false
// client.close() → transport.close() → transport.onclose → SDK's _onclose(): // rejects all pending request handlers (so hung callTool() promises fail with // McpError -32000 "Connection closed") and then invokes our client.onclose // handler below (which clears the memo cache so the next call reconnects). // Calling client.onclose?.() directly would only clear the cache — pending // tool calls would stay hung. const closeTransportAndRejectPending = (reason: string) => { if (hasTriggeredClose) return hasTriggeredClose = true logMCPDebug(name, `Closing transport (${reason})`) void client.close().catch(e => { logMCPDebug(name, `Error during close: ${errorMessage(e)}`) }) }
const isTerminalConnectionError = (msg: string): boolean => { return ( msg.includes('ECONNRESET') || msg.includes('ETIMEDOUT') || msg.includes('EPIPE') || msg.includes('EHOSTUNREACH') || msg.includes('ECONNREFUSED') || msg.includes('Body Timeout Error') || msg.includes('terminated') || // SDK SSE reconnection intermediate errors — may be wrapped around the // actual network error, so the substrings above won't match msg.includes('SSE stream disconnected') || msg.includes('Failed to reconnect SSE stream') ) }
// Enhanced error handler with detailed logging client.onerror = (error: Error) => { const uptime = Date.now() - connectionStartTime hasErrorOccurred = true const transportType = serverRef.type || 'stdio'
// Log the connection drop with context logMCPDebug( name, `${transportType.toUpperCase()} connection dropped after ${Math.floor(uptime / 1000)}s uptime`, )
// Log specific error details for debugging if (error.message) { if (error.message.includes('ECONNRESET')) { logMCPDebug( name, `Connection reset - server may have crashed or restarted`, ) } else if (error.message.includes('ETIMEDOUT')) { logMCPDebug( name, `Connection timeout - network issue or server unresponsive`, ) } else if (error.message.includes('ECONNREFUSED')) { logMCPDebug(name, `Connection refused - server may be down`) } else if (error.message.includes('EPIPE')) { logMCPDebug( name, `Broken pipe - server closed connection unexpectedly`, ) } else if (error.message.includes('EHOSTUNREACH')) { logMCPDebug(name, `Host unreachable - network connectivity issue`) } else if (error.message.includes('ESRCH')) { logMCPDebug( name, `Process not found - stdio server process terminated`, ) } else if (error.message.includes('spawn')) { logMCPDebug( name, `Failed to spawn process - check command and permissions`, ) } else { logMCPDebug(name, `Connection error: ${error.message}`) } }
// For HTTP transports, detect session expiry (404 + JSON-RPC -32001) // and close the transport so pending tool calls reject and the next // call reconnects with a fresh session ID. if ( (transportType === 'http' || transportType === 'claudeai-proxy') && isMcpSessionExpiredError(error) ) { logMCPDebug( name, `MCP session expired (server returned 404 with session-not-found), triggering reconnection`, ) closeTransportAndRejectPending('session expired') if (originalOnerror) { originalOnerror(error) } return }
// For remote transports (SSE/HTTP), track terminal connection errors // and trigger reconnection via close if we see repeated failures. if ( transportType === 'sse' || transportType === 'http' || transportType === 'claudeai-proxy' ) { // The SDK's StreamableHTTP transport fires this after exhausting its // own SSE reconnect attempts (default maxRetries: 2) — but it never // calls onclose, so pending callTool() promises hang indefinitely. // This is the definitive "transport gave up" signal. if (error.message.includes('Maximum reconnection attempts')) { closeTransportAndRejectPending('SSE reconnection exhausted') if (originalOnerror) { originalOnerror(error) } return }
if (isTerminalConnectionError(error.message)) { consecutiveConnectionErrors++ logMCPDebug( name, `Terminal connection error ${consecutiveConnectionErrors}/${MAX_ERRORS_BEFORE_RECONNECT}`, )
if (consecutiveConnectionErrors >= MAX_ERRORS_BEFORE_RECONNECT) { consecutiveConnectionErrors = 0 closeTransportAndRejectPending('max consecutive terminal errors') } } else { // Non-terminal error (e.g., transient issue), reset counter consecutiveConnectionErrors = 0 } }
// Call original handler if (originalOnerror) { originalOnerror(error) } }
// Enhanced close handler with connection drop context client.onclose = () => { const uptime = Date.now() - connectionStartTime const transportType = serverRef.type ?? 'unknown'
logMCPDebug( name, `${transportType.toUpperCase()} connection closed after ${Math.floor(uptime / 1000)}s (${hasErrorOccurred ? 'with errors' : 'cleanly'})`, )
// Clear the memoization cache so next operation reconnects const key = getServerCacheKey(name, serverRef)
// Also clear fetch caches (keyed by server name). Reconnection // creates a new connection object; without clearing, the next // fetch would return stale tools/resources from the old connection. fetchToolsForClient.cache.delete(name) fetchResourcesForClient.cache.delete(name) fetchCommandsForClient.cache.delete(name) if (feature('MCP_SKILLS')) { fetchMcpSkillsForClient!.cache.delete(name) }
connectToServer.cache.delete(key) logMCPDebug(name, `Cleared connection cache for reconnection`)
if (originalOnclose) { originalOnclose() } }
const cleanup = async () => { // In-process servers (e.g. Chrome MCP) don't have child processes or stderr if (inProcessServer) { try { await inProcessServer.close() } catch (error) { logMCPDebug(name, `Error closing in-process server: ${error}`) } try { await client.close() } catch (error) { logMCPDebug(name, `Error closing client: ${error}`) } return }
// Remove stderr event listener to prevent memory leaks if (stderrHandler && (serverRef.type === 'stdio' || !serverRef.type)) { const stdioTransport = transport as StdioClientTransport stdioTransport.stderr?.off('data', stderrHandler) }
// For stdio transports, explicitly terminate the child process with proper signals // NOTE: StdioClientTransport.close() only sends an abort signal, but many MCP servers // (especially Docker containers) need explicit SIGINT/SIGTERM signals to trigger graceful shutdown if (serverRef.type === 'stdio') { try { const stdioTransport = transport as StdioClientTransport const childPid = stdioTransport.pid
if (childPid) { logMCPDebug(name, 'Sending SIGINT to MCP server process')
// First try SIGINT (like Ctrl+C) try { process.kill(childPid, 'SIGINT') } catch (error) { logMCPDebug(name, `Error sending SIGINT: ${error}`) return }
// Wait for graceful shutdown with rapid escalation (total 500ms to keep CLI responsive) await new Promise<void>(async resolve => { let resolved = false
// Set up a timer to check if process still exists const checkInterval = setInterval(() => { try { // process.kill(pid, 0) checks if process exists without killing it process.kill(childPid, 0) } catch { // Process no longer exists if (!resolved) { resolved = true clearInterval(checkInterval) clearTimeout(failsafeTimeout) logMCPDebug(name, 'MCP server process exited cleanly') resolve() } } }, 50)
// Absolute failsafe: clear interval after 600ms no matter what const failsafeTimeout = setTimeout(() => { if (!resolved) { resolved = true clearInterval(checkInterval) logMCPDebug( name, 'Cleanup timeout reached, stopping process monitoring', ) resolve() } }, 600)
try { // Wait 100ms for SIGINT to work (usually much faster) await sleep(100)
if (!resolved) { // Check if process still exists try { process.kill(childPid, 0) // Process still exists, SIGINT failed, try SIGTERM logMCPDebug( name, 'SIGINT failed, sending SIGTERM to MCP server process', ) try { process.kill(childPid, 'SIGTERM') } catch (termError) { logMCPDebug(name, `Error sending SIGTERM: ${termError}`) resolved = true clearInterval(checkInterval) clearTimeout(failsafeTimeout) resolve() return } } catch { // Process already exited resolved = true clearInterval(checkInterval) clearTimeout(failsafeTimeout) resolve() return }
// Wait 400ms for SIGTERM to work (slower than SIGINT, often used for cleanup) await sleep(400)
if (!resolved) { // Check if process still exists try { process.kill(childPid, 0) // Process still exists, SIGTERM failed, force kill with SIGKILL logMCPDebug( name, 'SIGTERM failed, sending SIGKILL to MCP server process', ) try { process.kill(childPid, 'SIGKILL') } catch (killError) { logMCPDebug( name, `Error sending SIGKILL: ${killError}`, ) } } catch { // Process already exited resolved = true clearInterval(checkInterval) clearTimeout(failsafeTimeout) resolve() } } }
// Final timeout - always resolve after 500ms max (total cleanup time) if (!resolved) { resolved = true clearInterval(checkInterval) clearTimeout(failsafeTimeout) resolve() } } catch { // Handle any errors in the escalation sequence if (!resolved) { resolved = true clearInterval(checkInterval) clearTimeout(failsafeTimeout) resolve() } } }) } } catch (processError) { logMCPDebug(name, `Error terminating process: ${processError}`) } }
// Close the client connection (which also closes the transport) try { await client.close() } catch (error) { logMCPDebug(name, `Error closing client: ${error}`) } }
// Register cleanup for all transport types - even network transports might need cleanup // This ensures all MCP servers get properly terminated, not just stdio ones const cleanupUnregister = registerCleanup(cleanup)
// Create the wrapped cleanup that includes unregistering const wrappedCleanup = async () => { cleanupUnregister?.() await cleanup() }
const connectionDurationMs = Date.now() - connectStartTime logEvent('tengu_mcp_server_connection_succeeded', { connectionDurationMs, transportType: (serverRef.type ?? 'stdio') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, totalServers: serverStats?.totalServers, stdioCount: serverStats?.stdioCount, sseCount: serverStats?.sseCount, httpCount: serverStats?.httpCount, sseIdeCount: serverStats?.sseIdeCount, wsIdeCount: serverStats?.wsIdeCount, ...mcpBaseUrlAnalytics(serverRef), }) return { name, client, type: 'connected' as const, capabilities: capabilities ?? {}, serverInfo: serverVersion, instructions, config: serverRef, cleanup: wrappedCleanup, } } catch (error) { const connectionDurationMs = Date.now() - connectStartTime logEvent('tengu_mcp_server_connection_failed', { connectionDurationMs, totalServers: serverStats?.totalServers || 1, stdioCount: serverStats?.stdioCount || (serverRef.type === 'stdio' ? 1 : 0), sseCount: serverStats?.sseCount || (serverRef.type === 'sse' ? 1 : 0), httpCount: serverStats?.httpCount || (serverRef.type === 'http' ? 1 : 0), sseIdeCount: serverStats?.sseIdeCount || (serverRef.type === 'sse-ide' ? 1 : 0), wsIdeCount: serverStats?.wsIdeCount || (serverRef.type === 'ws-ide' ? 1 : 0), transportType: (serverRef.type ?? 'stdio') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, ...mcpBaseUrlAnalytics(serverRef), }) logMCPDebug( name, `Connection failed after ${connectionDurationMs}ms: ${errorMessage(error)}`, ) logMCPError(name, `Connection failed: ${errorMessage(error)}`)
if (inProcessServer) { inProcessServer.close().catch(() => {}) } return { name, type: 'failed' as const, config: serverRef, error: errorMessage(error), } } }, getServerCacheKey,)
/** * Clears the memoize cache for a specific server * @param name Server name * @param serverRef Server configuration */export async function clearServerCache( name: string, serverRef: ScopedMcpServerConfig,): Promise<void> { const key = getServerCacheKey(name, serverRef)
try { const wrappedClient = await connectToServer(name, serverRef)
if (wrappedClient.type === 'connected') { await wrappedClient.cleanup() } } catch { // Ignore errors - server might have failed to connect }
// Clear from cache (both connection and fetch caches so reconnect // fetches fresh tools/resources/commands instead of stale ones) connectToServer.cache.delete(key) fetchToolsForClient.cache.delete(name) fetchResourcesForClient.cache.delete(name) fetchCommandsForClient.cache.delete(name) if (feature('MCP_SKILLS')) { fetchMcpSkillsForClient!.cache.delete(name) }}
/** * Ensures a valid connected client for an MCP server. * For most server types, uses the memoization cache if available, or reconnects * if the cache was cleared (e.g., after onclose). This ensures tool/resource * calls always use a valid connection. * * SDK MCP servers run in-process and are handled separately via setupSdkMcpClients, * so they are returned as-is without going through connectToServer. * * @param client The connected MCP server client * @returns Connected MCP server client (same or reconnected) * @throws Error if server cannot be connected */export async function ensureConnectedClient( client: ConnectedMCPServer,): Promise<ConnectedMCPServer> { // SDK MCP servers run in-process and are handled separately via setupSdkMcpClients if (client.config.type === 'sdk') { return client }
const connectedClient = await connectToServer(client.name, client.config) if (connectedClient.type !== 'connected') { throw new TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS( `MCP server "${client.name}" is not connected`, 'MCP server not connected', ) } return connectedClient}
/** * Compares two MCP server configurations to determine if they are equivalent. * Used to detect when a server needs to be reconnected due to config changes. */export function areMcpConfigsEqual( a: ScopedMcpServerConfig, b: ScopedMcpServerConfig,): boolean { // Quick type check first if (a.type !== b.type) return false
// Compare by serializing - this handles all config variations // We exclude 'scope' from comparison since it's metadata, not connection config const { scope: _scopeA, ...configA } = a const { scope: _scopeB, ...configB } = b return jsonStringify(configA) === jsonStringify(configB)}
// Max cache size for fetch* caches. Keyed by server name (stable across// reconnects), bounded to prevent unbounded growth with many MCP servers.const MCP_FETCH_CACHE_SIZE = 20
/** * Encode MCP tool input for the auto-mode security classifier. * Exported so the auto-mode eval scripts can mirror production encoding * for `mcp__*` tool stubs without duplicating this logic. */export function mcpToolInputToAutoClassifierInput( input: Record<string, unknown>, toolName: string,): string { const keys = Object.keys(input) return keys.length > 0 ? keys.map(k => `${k}=${String(input[k])}`).join(' ') : toolName}
export const fetchToolsForClient = memoizeWithLRU( async (client: MCPServerConnection): Promise<Tool[]> => { if (client.type !== 'connected') return []
try { if (!client.capabilities?.tools) { return [] }
const result = (await client.client.request( { method: 'tools/list' }, ListToolsResultSchema, )) as ListToolsResult
// Sanitize tool data from MCP server const toolsToProcess = recursivelySanitizeUnicode(result.tools)
// Check if we should skip the mcp__ prefix for SDK MCP servers const skipPrefix = client.config.type === 'sdk' && isEnvTruthy(process.env.CLAUDE_AGENT_SDK_MCP_NO_PREFIX)
// Convert MCP tools to our Tool format return toolsToProcess .map((tool): Tool => { const fullyQualifiedName = buildMcpToolName(client.name, tool.name) return { ...MCPTool, // In skip-prefix mode, use the original name for model invocation so MCP tools // can override builtins by name. mcpInfo is used for permission checking. name: skipPrefix ? tool.name : fullyQualifiedName, mcpInfo: { serverName: client.name, toolName: tool.name }, isMcp: true, // Collapse whitespace: _meta is open to external MCP servers, and // a newline here would inject orphan lines into the deferred-tool // list (formatDeferredToolLine joins on '\n'). searchHint: typeof tool._meta?.['anthropic/searchHint'] === 'string' ? tool._meta['anthropic/searchHint'] .replace(/\s+/g, ' ') .trim() || undefined : undefined, alwaysLoad: tool._meta?.['anthropic/alwaysLoad'] === true, async description() { return tool.description ?? '' }, async prompt() { const desc = tool.description ?? '' return desc.length > MAX_MCP_DESCRIPTION_LENGTH ? desc.slice(0, MAX_MCP_DESCRIPTION_LENGTH) + '… [truncated]' : desc }, isConcurrencySafe() { return tool.annotations?.readOnlyHint ?? false }, isReadOnly() { return tool.annotations?.readOnlyHint ?? false }, toAutoClassifierInput(input) { return mcpToolInputToAutoClassifierInput(input, tool.name) }, isDestructive() { return tool.annotations?.destructiveHint ?? false }, isOpenWorld() { return tool.annotations?.openWorldHint ?? false }, isSearchOrReadCommand() { return classifyMcpToolForCollapse(client.name, tool.name) }, inputJSONSchema: tool.inputSchema as Tool['inputJSONSchema'], async checkPermissions() { return { behavior: 'passthrough' as const, message: 'MCPTool requires permission.', suggestions: [ { type: 'addRules' as const, rules: [ { toolName: fullyQualifiedName, ruleContent: undefined, }, ], behavior: 'allow' as const, destination: 'localSettings' as const, }, ], } }, async call( args: Record<string, unknown>, context, _canUseTool, parentMessage, onProgress?: ToolCallProgress<MCPProgress>, ) { const toolUseId = extractToolUseId(parentMessage) const meta = toolUseId ? { 'claudecode/toolUseId': toolUseId } : {}
// Emit progress when tool starts if (onProgress && toolUseId) { onProgress({ toolUseID: toolUseId, data: { type: 'mcp_progress', status: 'started', serverName: client.name, toolName: tool.name, }, }) }
const startTime = Date.now() const MAX_SESSION_RETRIES = 1 for (let attempt = 0; ; attempt++) { try { const connectedClient = await ensureConnectedClient(client) const mcpResult = await callMCPToolWithUrlElicitationRetry({ client: connectedClient, clientConnection: client, tool: tool.name, args, meta, signal: context.abortController.signal, setAppState: context.setAppState, onProgress: onProgress && toolUseId ? progressData => { onProgress({ toolUseID: toolUseId, data: progressData, }) } : undefined, handleElicitation: context.handleElicitation, })
// Emit progress when tool completes successfully if (onProgress && toolUseId) { onProgress({ toolUseID: toolUseId, data: { type: 'mcp_progress', status: 'completed', serverName: client.name, toolName: tool.name, elapsedTimeMs: Date.now() - startTime, }, }) }
return { data: mcpResult.content, ...((mcpResult._meta || mcpResult.structuredContent) && { mcpMeta: { ...(mcpResult._meta && { _meta: mcpResult._meta, }), ...(mcpResult.structuredContent && { structuredContent: mcpResult.structuredContent, }), }, }), } } catch (error) { // Session expired — the connection cache has been // cleared, so retry with a fresh client. if ( error instanceof McpSessionExpiredError && attempt < MAX_SESSION_RETRIES ) { logMCPDebug( client.name, `Retrying tool '${tool.name}' after session recovery`, ) continue }
// Emit progress when tool fails if (onProgress && toolUseId) { onProgress({ toolUseID: toolUseId, data: { type: 'mcp_progress', status: 'failed', serverName: client.name, toolName: tool.name, elapsedTimeMs: Date.now() - startTime, }, }) } // Wrap MCP SDK errors so telemetry gets useful context // instead of just "Error" or "McpError" (the constructor // name). MCP SDK errors are protocol-level messages and // don't contain user file paths or code. if ( error instanceof Error && !( error instanceof TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS ) ) { const name = error.constructor.name if (name === 'Error') { throw new TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS( error.message, error.message.slice(0, 200), ) } // McpError has a numeric `code` with the JSON-RPC error // code (e.g. -32000 ConnectionClosed, -32001 RequestTimeout) if ( name === 'McpError' && 'code' in error && typeof error.code === 'number' ) { throw new TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS( error.message, `McpError ${error.code}`, ) } } throw error } } }, userFacingName() { // Prefer title annotation if available, otherwise use tool name const displayName = tool.annotations?.title || tool.name return `${client.name} - ${displayName} (MCP)` }, ...(isClaudeInChromeMCPServer(client.name) && (client.config.type === 'stdio' || !client.config.type) ? claudeInChromeToolRendering().getClaudeInChromeMCPToolOverrides( tool.name, ) : {}), ...(feature('CHICAGO_MCP') && (client.config.type === 'stdio' || !client.config.type) && isComputerUseMCPServer!(client.name) ? computerUseWrapper!().getComputerUseMCPToolOverrides(tool.name) : {}), } }) .filter(isIncludedMcpTool) } catch (error) { logMCPError(client.name, `Failed to fetch tools: ${errorMessage(error)}`) return [] } }, (client: MCPServerConnection) => client.name, MCP_FETCH_CACHE_SIZE,)
export const fetchResourcesForClient = memoizeWithLRU( async (client: MCPServerConnection): Promise<ServerResource[]> => { if (client.type !== 'connected') return []
try { if (!client.capabilities?.resources) { return [] }
const result = await client.client.request( { method: 'resources/list' }, ListResourcesResultSchema, )
if (!result.resources) return []
// Add server name to each resource return result.resources.map(resource => ({ ...resource, server: client.name, })) } catch (error) { logMCPError( client.name, `Failed to fetch resources: ${errorMessage(error)}`, ) return [] } }, (client: MCPServerConnection) => client.name, MCP_FETCH_CACHE_SIZE,)
export const fetchCommandsForClient = memoizeWithLRU( async (client: MCPServerConnection): Promise<Command[]> => { if (client.type !== 'connected') return []
try { if (!client.capabilities?.prompts) { return [] }
// Request prompts list from client const result = (await client.client.request( { method: 'prompts/list' }, ListPromptsResultSchema, )) as ListPromptsResult
if (!result.prompts) return []
// Sanitize prompt data from MCP server const promptsToProcess = recursivelySanitizeUnicode(result.prompts)
// Convert MCP prompts to our Command format return promptsToProcess.map(prompt => { const argNames = Object.values(prompt.arguments ?? {}).map(k => k.name) return { type: 'prompt' as const, name: 'mcp__' + normalizeNameForMCP(client.name) + '__' + prompt.name, description: prompt.description ?? '', hasUserSpecifiedDescription: !!prompt.description, contentLength: 0, // Dynamic MCP content isEnabled: () => true, isHidden: false, isMcp: true, progressMessage: 'running', userFacingName() { // Use prompt.name (programmatic identifier) not prompt.title (display name) // to avoid spaces breaking slash command parsing return `${client.name}:${prompt.name} (MCP)` }, argNames, source: 'mcp', async getPromptForCommand(args: string) { const argsArray = args.split(' ') try { const connectedClient = await ensureConnectedClient(client) const result = await connectedClient.client.getPrompt({ name: prompt.name, arguments: zipObject(argNames, argsArray), }) const transformed = await Promise.all( result.messages.map(message => transformResultContent(message.content, connectedClient.name), ), ) return transformed.flat() } catch (error) { logMCPError( client.name, `Error running command '${prompt.name}': ${errorMessage(error)}`, ) throw error } }, } }) } catch (error) { logMCPError( client.name, `Failed to fetch commands: ${errorMessage(error)}`, ) return [] } }, (client: MCPServerConnection) => client.name, MCP_FETCH_CACHE_SIZE,)
/** * Call an IDE tool directly as an RPC * @param toolName The name of the tool to call * @param args The arguments to pass to the tool * @param client The IDE client to use for the RPC call * @returns The result of the tool call */export async function callIdeRpc( toolName: string, args: Record<string, unknown>, client: ConnectedMCPServer,): Promise<string | ContentBlockParam[] | undefined> { const result = await callMCPTool({ client, tool: toolName, args, signal: createAbortController().signal, }) return result.content}
/** * Note: This should not be called by UI components directly, they should use the reconnectMcpServer * function from useManageMcpConnections. * @param name Server name * @param config Server configuration * @returns Object containing the client connection and its resources */export async function reconnectMcpServerImpl( name: string, config: ScopedMcpServerConfig,): Promise<{ client: MCPServerConnection tools: Tool[] commands: Command[] resources?: ServerResource[]}> { try { // Invalidate the keychain cache so we read fresh credentials from disk. // This is necessary when another process (e.g. the VS Code extension host) // has modified stored tokens (cleared auth, saved new OAuth tokens) and then // asks the CLI subprocess to reconnect. Without this, the subprocess would // use stale cached data and never notice the tokens were removed. clearKeychainCache()
await clearServerCache(name, config) const client = await connectToServer(name, config)
if (client.type !== 'connected') { return { client, tools: [], commands: [], } }
if (config.type === 'claudeai-proxy') { markClaudeAiMcpConnected(name) }
const supportsResources = !!client.capabilities?.resources
const [tools, mcpCommands, mcpSkills, resources] = await Promise.all([ fetchToolsForClient(client), fetchCommandsForClient(client), feature('MCP_SKILLS') && supportsResources ? fetchMcpSkillsForClient!(client) : Promise.resolve([]), supportsResources ? fetchResourcesForClient(client) : Promise.resolve([]), ]) const commands = [...mcpCommands, ...mcpSkills]
// Check if we need to add resource tools const resourceTools: Tool[] = [] if (supportsResources) { // Only add resource tools if no other server has them const hasResourceTools = [ListMcpResourcesTool, ReadMcpResourceTool].some( tool => tools.some(t => toolMatchesName(t, tool.name)), ) if (!hasResourceTools) { resourceTools.push(ListMcpResourcesTool, ReadMcpResourceTool) } }
return { client, tools: [...tools, ...resourceTools], commands, resources: resources.length > 0 ? resources : undefined, } } catch (error) { // Handle errors gracefully - connection might have closed during fetch logMCPError(name, `Error during reconnection: ${errorMessage(error)}`)
// Return with failed status return { client: { name, type: 'failed' as const, config }, tools: [], commands: [], } }}
// Replaced 2026-03: previous implementation ran fixed-size sequential batches// (await batch 1 fully, then start batch 2). That meant one slow server in// batch N held up ALL servers in batch N+1, even if the other 19 slots were// idle. pMap frees each slot as soon as its server completes, so a single// slow server only occupies one slot instead of blocking an entire batch// boundary. Same concurrency ceiling, same results, better scheduling.async function processBatched<T>( items: T[], concurrency: number, processor: (item: T) => Promise<void>,): Promise<void> { await pMap(items, processor, { concurrency })}
export async function getMcpToolsCommandsAndResources( onConnectionAttempt: (params: { client: MCPServerConnection tools: Tool[] commands: Command[] resources?: ServerResource[] }) => void, mcpConfigs?: Record<string, ScopedMcpServerConfig>,): Promise<void> { let resourceToolsAdded = false
const allConfigEntries = Object.entries( mcpConfigs ?? (await getAllMcpConfigs()).servers, )
// Partition into disabled and active entries — disabled servers should // never generate HTTP connections or flow through batch processing const configEntries: typeof allConfigEntries = [] for (const entry of allConfigEntries) { if (isMcpServerDisabled(entry[0])) { onConnectionAttempt({ client: { name: entry[0], type: 'disabled', config: entry[1] }, tools: [], commands: [], }) } else { configEntries.push(entry) } }
// Calculate transport counts for logging const totalServers = configEntries.length const stdioCount = count(configEntries, ([_, c]) => c.type === 'stdio') const sseCount = count(configEntries, ([_, c]) => c.type === 'sse') const httpCount = count(configEntries, ([_, c]) => c.type === 'http') const sseIdeCount = count(configEntries, ([_, c]) => c.type === 'sse-ide') const wsIdeCount = count(configEntries, ([_, c]) => c.type === 'ws-ide')
// Split servers by type: local (stdio/sdk) need lower concurrency due to // process spawning, remote servers can connect with higher concurrency const localServers = configEntries.filter(([_, config]) => isLocalMcpServer(config), ) const remoteServers = configEntries.filter( ([_, config]) => !isLocalMcpServer(config), )
const serverStats = { totalServers, stdioCount, sseCount, httpCount, sseIdeCount, wsIdeCount, }
const processServer = async ([name, config]: [ string, ScopedMcpServerConfig, ]): Promise<void> => { try { // Check if server is disabled - if so, just add it to state without connecting if (isMcpServerDisabled(name)) { onConnectionAttempt({ client: { name, type: 'disabled', config, }, tools: [], commands: [], }) return }
// Skip connection for servers that recently returned 401 (15min TTL), // or that we have probed before but hold no token for. The second // check closes the gap the TTL leaves open: without it, every 15min // we re-probe servers that cannot succeed until the user runs /mcp. // Each probe is a network round-trip for connect-401 plus OAuth // discovery, and print mode awaits the whole batch (main.tsx:3503). if ( (config.type === 'claudeai-proxy' || config.type === 'http' || config.type === 'sse') && ((await isMcpAuthCached(name)) || ((config.type === 'http' || config.type === 'sse') && hasMcpDiscoveryButNoToken(name, config))) ) { logMCPDebug(name, `Skipping connection (cached needs-auth)`) onConnectionAttempt({ client: { name, type: 'needs-auth' as const, config }, tools: [createMcpAuthTool(name, config)], commands: [], }) return }
const client = await connectToServer(name, config, serverStats)
if (client.type !== 'connected') { onConnectionAttempt({ client, tools: client.type === 'needs-auth' ? [createMcpAuthTool(name, config)] : [], commands: [], }) return }
if (config.type === 'claudeai-proxy') { markClaudeAiMcpConnected(name) }
const supportsResources = !!client.capabilities?.resources
const [tools, mcpCommands, mcpSkills, resources] = await Promise.all([ fetchToolsForClient(client), fetchCommandsForClient(client), // Discover skills from skill:// resources feature('MCP_SKILLS') && supportsResources ? fetchMcpSkillsForClient!(client) : Promise.resolve([]), // Fetch resources if supported supportsResources ? fetchResourcesForClient(client) : Promise.resolve([]), ]) const commands = [...mcpCommands, ...mcpSkills]
// If this server resources and we haven't added resource tools yet, // include our resource tools with this client's tools const resourceTools: Tool[] = [] if (supportsResources && !resourceToolsAdded) { resourceToolsAdded = true resourceTools.push(ListMcpResourcesTool, ReadMcpResourceTool) }
onConnectionAttempt({ client, tools: [...tools, ...resourceTools], commands, resources: resources.length > 0 ? resources : undefined, }) } catch (error) { // Handle errors gracefully - connection might have closed during fetch logMCPError( name, `Error fetching tools/commands/resources: ${errorMessage(error)}`, )
// Still update with the client but no tools/commands onConnectionAttempt({ client: { name, type: 'failed' as const, config }, tools: [], commands: [], }) } }
// Process both groups concurrently, each with their own concurrency limits: // - Local servers (stdio/sdk): lower concurrency to avoid process spawning resource contention // - Remote servers: higher concurrency since they're just network connections await Promise.all([ processBatched( localServers, getMcpServerConnectionBatchSize(), processServer, ), processBatched( remoteServers, getRemoteMcpServerConnectionBatchSize(), processServer, ), ])}
// Not memoized: called only 2-3 times at startup/reconfig. The inner work// (connectToServer, fetch*ForClient) is already cached. Memoizing here by// mcpConfigs object ref leaked — main.tsx creates fresh config objects each call.export function prefetchAllMcpResources( mcpConfigs: Record<string, ScopedMcpServerConfig>,): Promise<{ clients: MCPServerConnection[] tools: Tool[] commands: Command[]}> { return new Promise(resolve => { let pendingCount = 0 let completedCount = 0
pendingCount = Object.keys(mcpConfigs).length
if (pendingCount === 0) { void resolve({ clients: [], tools: [], commands: [], }) return }
const clients: MCPServerConnection[] = [] const tools: Tool[] = [] const commands: Command[] = []
getMcpToolsCommandsAndResources(result => { clients.push(result.client) tools.push(...result.tools) commands.push(...result.commands)
completedCount++ if (completedCount >= pendingCount) { const commandsMetadataLength = commands.reduce((sum, command) => { const commandMetadataLength = command.name.length + (command.description ?? '').length + (command.argumentHint ?? '').length return sum + commandMetadataLength }, 0) logEvent('tengu_mcp_tools_commands_loaded', { tools_count: tools.length, commands_count: commands.length, commands_metadata_length: commandsMetadataLength, })
void resolve({ clients, tools, commands, }) } }, mcpConfigs).catch(error => { logMCPError( 'prefetchAllMcpResources', `Failed to get MCP resources: ${errorMessage(error)}`, ) // Still resolve with empty results void resolve({ clients: [], tools: [], commands: [], }) }) })}
/** * Transform result content from an MCP tool or MCP prompt into message blocks */export async function transformResultContent( resultContent: PromptMessage['content'], serverName: string,): Promise<Array<ContentBlockParam>> { switch (resultContent.type) { case 'text': return [ { type: 'text', text: resultContent.text, }, ] case 'audio': { const audioData = resultContent as { type: 'audio' data: string mimeType?: string } return await persistBlobToTextBlock( Buffer.from(audioData.data, 'base64'), audioData.mimeType, serverName, `[Audio from ${serverName}] `, ) } case 'image': { // Resize and compress image data, enforcing API dimension limits const imageBuffer = Buffer.from(String(resultContent.data), 'base64') const ext = resultContent.mimeType?.split('/')[1] || 'png' const resized = await maybeResizeAndDownsampleImageBuffer( imageBuffer, imageBuffer.length, ext, ) return [ { type: 'image', source: { data: resized.buffer.toString('base64'), media_type: `image/${resized.mediaType}` as Base64ImageSource['media_type'], type: 'base64', }, }, ] } case 'resource': { const resource = resultContent.resource const prefix = `[Resource from ${serverName} at ${resource.uri}] `
if ('text' in resource) { return [ { type: 'text', text: `${prefix}${resource.text}`, }, ] } else if ('blob' in resource) { const isImage = IMAGE_MIME_TYPES.has(resource.mimeType ?? '')
if (isImage) { // Resize and compress image blob, enforcing API dimension limits const imageBuffer = Buffer.from(resource.blob, 'base64') const ext = resource.mimeType?.split('/')[1] || 'png' const resized = await maybeResizeAndDownsampleImageBuffer( imageBuffer, imageBuffer.length, ext, ) const content: MessageParam['content'] = [] if (prefix) { content.push({ type: 'text', text: prefix, }) } content.push({ type: 'image', source: { data: resized.buffer.toString('base64'), media_type: `image/${resized.mediaType}` as Base64ImageSource['media_type'], type: 'base64', }, }) return content } else { return await persistBlobToTextBlock( Buffer.from(resource.blob, 'base64'), resource.mimeType, serverName, prefix, ) } } return [] } case 'resource_link': { const resourceLink = resultContent as ResourceLink let text = `[Resource link: ${resourceLink.name}] ${resourceLink.uri}` if (resourceLink.description) { text += ` (${resourceLink.description})` } return [ { type: 'text', text, }, ] } default: return [] }}
/** * Decode base64 binary content, write it to disk with the proper extension, * and return a small text block with the file path. Replaces the old behavior * of dumping raw base64 into the context. */async function persistBlobToTextBlock( bytes: Buffer, mimeType: string | undefined, serverName: string, sourceDescription: string,): Promise<Array<ContentBlockParam>> { const persistId = `mcp-${normalizeNameForMCP(serverName)}-blob-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const result = await persistBinaryContent(bytes, mimeType, persistId)
if ('error' in result) { return [ { type: 'text', text: `${sourceDescription}Binary content (${mimeType || 'unknown type'}, ${bytes.length} bytes) could not be saved to disk: ${result.error}`, }, ] }
return [ { type: 'text', text: getBinaryBlobSavedMessage( result.filepath, mimeType, result.size, sourceDescription, ), }, ]}
/** * Processes MCP tool result into a normalized format. */export type MCPResultType = 'toolResult' | 'structuredContent' | 'contentArray'
export type TransformedMCPResult = { content: MCPToolResult type: MCPResultType schema?: string}
/** * Generates a compact, jq-friendly type signature for a value. * e.g. "{title: string, items: [{id: number, name: string}]}" */export function inferCompactSchema(value: unknown, depth = 2): string { if (value === null) return 'null' if (Array.isArray(value)) { if (value.length === 0) return '[]' return `[${inferCompactSchema(value[0], depth - 1)}]` } if (typeof value === 'object') { if (depth <= 0) return '{...}' const entries = Object.entries(value).slice(0, 10) const props = entries.map( ([k, v]) => `${k}: ${inferCompactSchema(v, depth - 1)}`, ) const suffix = Object.keys(value).length > 10 ? ', ...' : '' return `{${props.join(', ')}${suffix}}` } return typeof value}
export async function transformMCPResult( result: unknown, tool: string, // Tool name for validation (e.g., "search") name: string, // Server name for transformation (e.g., "slack")): Promise<TransformedMCPResult> { if (result && typeof result === 'object') { if ('toolResult' in result) { return { content: String(result.toolResult), type: 'toolResult', } }
if ( 'structuredContent' in result && result.structuredContent !== undefined ) { return { content: jsonStringify(result.structuredContent), type: 'structuredContent', schema: inferCompactSchema(result.structuredContent), } }
if ('content' in result && Array.isArray(result.content)) { const transformedContent = ( await Promise.all( result.content.map(item => transformResultContent(item, name)), ) ).flat() return { content: transformedContent, type: 'contentArray', schema: inferCompactSchema(transformedContent), } } }
const errorMessage = `MCP server "${name}" tool "${tool}": unexpected response format` logMCPError(name, errorMessage) throw new TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS( errorMessage, 'MCP tool unexpected response format', )}
/** * Check if MCP content contains any image blocks. * Used to decide whether to persist to file (images should use truncation instead * to preserve image compression and viewability). */function contentContainsImages(content: MCPToolResult): boolean { if (!content || typeof content === 'string') { return false } return content.some(block => block.type === 'image')}
export async function processMCPResult( result: unknown, tool: string, // Tool name for validation (e.g., "search") name: string, // Server name for IDE check and transformation (e.g., "slack")): Promise<MCPToolResult> { const { content, type, schema } = await transformMCPResult(result, tool, name)
// IDE tools are not going to the model directly, so we don't need to // handle large output. if (name === 'ide') { return content }
// Check if content needs truncation (i.e., is too large) if (!(await mcpContentNeedsTruncation(content))) { return content }
const sizeEstimateTokens = getContentSizeEstimate(content)
// If large output files feature is disabled, fall back to old truncation behavior if (isEnvDefinedFalsy(process.env.ENABLE_MCP_LARGE_OUTPUT_FILES)) { logEvent('tengu_mcp_large_result_handled', { outcome: 'truncated', reason: 'env_disabled', sizeEstimateTokens, } as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) return await truncateMcpContentIfNeeded(content) }
// Save large output to file and return instructions for reading it // Content is guaranteed to exist at this point (we checked mcpContentNeedsTruncation) if (!content) { return content }
// If content contains images, fall back to truncation - persisting images as JSON // defeats the image compression logic and makes them non-viewable if (contentContainsImages(content)) { logEvent('tengu_mcp_large_result_handled', { outcome: 'truncated', reason: 'contains_images', sizeEstimateTokens, } as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) return await truncateMcpContentIfNeeded(content) }
// Generate a unique ID for the persisted file (server__tool-timestamp) const timestamp = Date.now() const persistId = `mcp-${normalizeNameForMCP(name)}-${normalizeNameForMCP(tool)}-${timestamp}` // Convert to string for persistence (persistToolResult expects string or specific block types) const contentStr = typeof content === 'string' ? content : jsonStringify(content, null, 2) const persistResult = await persistToolResult(contentStr, persistId)
if (isPersistError(persistResult)) { // If file save failed, fall back to returning truncated content info const contentLength = contentStr.length logEvent('tengu_mcp_large_result_handled', { outcome: 'truncated', reason: 'persist_failed', sizeEstimateTokens, } as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) return `Error: result (${contentLength.toLocaleString()} characters) exceeds maximum allowed tokens. Failed to save output to file: ${persistResult.error}. If this MCP server provides pagination or filtering tools, use them to retrieve specific portions of the data.` }
logEvent('tengu_mcp_large_result_handled', { outcome: 'persisted', reason: 'file_saved', sizeEstimateTokens, persistedSizeChars: persistResult.originalSize, } as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS)
const formatDescription = getFormatDescription(type, schema) return getLargeOutputInstructions( persistResult.filepath, persistResult.originalSize, formatDescription, )}
/** * Call an MCP tool, handling UrlElicitationRequiredError (-32042) by * displaying the URL elicitation to the user, waiting for the completion * notification, and retrying the tool call. */type MCPToolCallResult = { content: MCPToolResult _meta?: Record<string, unknown> structuredContent?: Record<string, unknown>}
/** @internal Exported for testing. */export async function callMCPToolWithUrlElicitationRetry({ client: connectedClient, clientConnection, tool, args, meta, signal, setAppState, onProgress, callToolFn = callMCPTool, handleElicitation,}: { client: ConnectedMCPServer clientConnection: MCPServerConnection tool: string args: Record<string, unknown> meta?: Record<string, unknown> signal: AbortSignal setAppState: (f: (prev: AppState) => AppState) => void onProgress?: (data: MCPProgress) => void /** Injectable for testing. Defaults to callMCPTool. */ callToolFn?: (opts: { client: ConnectedMCPServer tool: string args: Record<string, unknown> meta?: Record<string, unknown> signal: AbortSignal onProgress?: (data: MCPProgress) => void }) => Promise<MCPToolCallResult> /** Handler for URL elicitations when no hook handles them. * In print/SDK mode, delegates to structuredIO. In REPL, falls back to queue. */ handleElicitation?: ( serverName: string, params: ElicitRequestURLParams, signal: AbortSignal, ) => Promise<ElicitResult>}): Promise<MCPToolCallResult> { const MAX_URL_ELICITATION_RETRIES = 3 for (let attempt = 0; ; attempt++) { try { return await callToolFn({ client: connectedClient, tool, args, meta, signal, onProgress, }) } catch (error) { // The MCP SDK's Protocol creates plain McpError (not UrlElicitationRequiredError) // for error responses, so we check the error code instead of instanceof. if ( !(error instanceof McpError) || error.code !== ErrorCode.UrlElicitationRequired ) { throw error }
// Limit the number of URL elicitation retries if (attempt >= MAX_URL_ELICITATION_RETRIES) { throw error }
const errorData = error.data const rawElicitations = errorData != null && typeof errorData === 'object' && 'elicitations' in errorData && Array.isArray(errorData.elicitations) ? (errorData.elicitations as unknown[]) : []
// Validate each element has the required fields for ElicitRequestURLParams const elicitations = rawElicitations.filter( (e): e is ElicitRequestURLParams => { if (e == null || typeof e !== 'object') return false const obj = e as Record<string, unknown> return ( obj.mode === 'url' && typeof obj.url === 'string' && typeof obj.elicitationId === 'string' && typeof obj.message === 'string' ) }, )
const serverName = clientConnection.type === 'connected' ? clientConnection.name : 'unknown'
if (elicitations.length === 0) { logMCPDebug( serverName, `Tool '${tool}' returned -32042 but no valid elicitations in error data`, ) throw error }
logMCPDebug( serverName, `Tool '${tool}' requires URL elicitation (error -32042, attempt ${attempt + 1}), processing ${elicitations.length} elicitation(s)`, )
// Process each URL elicitation from the error. // The completion notification handler (in registerElicitationHandler) sets // `completed: true` on the matching queue event; the dialog reacts to this flag. for (const elicitation of elicitations) { const { elicitationId } = elicitation
// Run elicitation hooks — they can resolve URL elicitations programmatically const hookResponse = await runElicitationHooks( serverName, elicitation, signal, ) if (hookResponse) { logMCPDebug( serverName, `URL elicitation ${elicitationId} resolved by hook: ${jsonStringify(hookResponse)}`, ) if (hookResponse.action !== 'accept') { return { content: `URL elicitation was ${hookResponse.action === 'decline' ? 'declined' : hookResponse.action + 'ed'} by a hook. The tool "${tool}" could not complete because it requires the user to open a URL.`, } } // Hook accepted — skip the UI and proceed to retry continue }
// Resolve the URL elicitation via callback (print/SDK mode) or queue (REPL mode). let userResult: ElicitResult if (handleElicitation) { // Print/SDK mode: delegate to structuredIO which sends a control request userResult = await handleElicitation(serverName, elicitation, signal) } else { // REPL mode: queue for ElicitationDialog with two-phase consent/waiting flow const waitingState: ElicitationWaitingState = { actionLabel: 'Retry now', showCancel: true, } userResult = await new Promise<ElicitResult>(resolve => { const onAbort = () => { void resolve({ action: 'cancel' }) } if (signal.aborted) { onAbort() return } signal.addEventListener('abort', onAbort, { once: true })
setAppState(prev => ({ ...prev, elicitation: { queue: [ ...prev.elicitation.queue, { serverName, requestId: `error-elicit-${elicitationId}`, params: elicitation, signal, waitingState, respond: result => { // Phase 1 consent: accept is a no-op (doesn't resolve retry Promise) if (result.action === 'accept') { return } // Decline or cancel: resolve the retry Promise signal.removeEventListener('abort', onAbort) void resolve(result) }, onWaitingDismiss: action => { signal.removeEventListener('abort', onAbort) if (action === 'retry') { void resolve({ action: 'accept' }) } else { void resolve({ action: 'cancel' }) } }, }, ], }, })) }) }
// Run ElicitationResult hooks — they can modify or block the response const finalResult = await runElicitationResultHooks( serverName, userResult, signal, 'url', elicitationId, )
if (finalResult.action !== 'accept') { logMCPDebug( serverName, `User ${finalResult.action === 'decline' ? 'declined' : finalResult.action + 'ed'} URL elicitation ${elicitationId}`, ) return { content: `URL elicitation was ${finalResult.action === 'decline' ? 'declined' : finalResult.action + 'ed'} by the user. The tool "${tool}" could not complete because it requires the user to open a URL.`, } }
logMCPDebug( serverName, `Elicitation ${elicitationId} completed, retrying tool call`, ) }
// Loop back to retry the tool call } }}
async function callMCPTool({ client: { client, name, config }, tool, args, meta, signal, onProgress,}: { client: ConnectedMCPServer tool: string args: Record<string, unknown> meta?: Record<string, unknown> signal: AbortSignal onProgress?: (data: MCPProgress) => void}): Promise<{ content: MCPToolResult _meta?: Record<string, unknown> structuredContent?: Record<string, unknown>}> { const toolStartTime = Date.now() let progressInterval: NodeJS.Timeout | undefined
try { logMCPDebug(name, `Calling MCP tool: ${tool}`)
// Set up progress logging for long-running tools (every 30 seconds) progressInterval = setInterval( (startTime, name, tool) => { const elapsed = Date.now() - startTime const elapsedSeconds = Math.floor(elapsed / 1000) const duration = `${elapsedSeconds}s` logMCPDebug(name, `Tool '${tool}' still running (${duration} elapsed)`) }, 30000, // Log every 30 seconds toolStartTime, name, tool, )
// Use Promise.race with our own timeout to handle cases where SDK's // internal timeout doesn't work (e.g., SSE stream breaks mid-request) const timeoutMs = getMcpToolTimeoutMs() let timeoutId: NodeJS.Timeout | undefined
const timeoutPromise = new Promise<never>((_, reject) => { timeoutId = setTimeout( (reject, name, tool, timeoutMs) => { reject( new TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS( `MCP server "${name}" tool "${tool}" timed out after ${Math.floor(timeoutMs / 1000)}s`, 'MCP tool timeout', ), ) }, timeoutMs, reject, name, tool, timeoutMs, ) })
const result = await Promise.race([ client.callTool( { name: tool, arguments: args, _meta: meta, }, CallToolResultSchema, { signal, timeout: timeoutMs, onprogress: onProgress ? sdkProgress => { onProgress({ type: 'mcp_progress', status: 'progress', serverName: name, toolName: tool, progress: sdkProgress.progress, total: sdkProgress.total, progressMessage: sdkProgress.message, }) } : undefined, }, ), timeoutPromise, ]).finally(() => { if (timeoutId) { clearTimeout(timeoutId) } })
if ('isError' in result && result.isError) { let errorDetails = 'Unknown error' if ( 'content' in result && Array.isArray(result.content) && result.content.length > 0 ) { const firstContent = result.content[0] if ( firstContent && typeof firstContent === 'object' && 'text' in firstContent ) { errorDetails = firstContent.text } } else if ('error' in result) { // Fallback for legacy error format errorDetails = String(result.error) } logMCPError(name, errorDetails) throw new McpToolCallError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS( errorDetails, 'MCP tool returned error', '_meta' in result && result._meta ? { _meta: result._meta } : undefined, ) } const elapsed = Date.now() - toolStartTime const duration = elapsed < 1000 ? `${elapsed}ms` : elapsed < 60000 ? `${Math.floor(elapsed / 1000)}s` : `${Math.floor(elapsed / 60000)}m ${Math.floor((elapsed % 60000) / 1000)}s`
logMCPDebug(name, `Tool '${tool}' completed successfully in ${duration}`)
// Log code indexing tool usage const codeIndexingTool = detectCodeIndexingFromMcpServerName(name) if (codeIndexingTool) { logEvent('tengu_code_indexing_tool_used', { tool: codeIndexingTool as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, source: 'mcp' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, success: true, }) }
const content = await processMCPResult(result, tool, name) return { content, _meta: result._meta as Record<string, unknown> | undefined, structuredContent: result.structuredContent as | Record<string, unknown> | undefined, } } catch (e) { // Clear intervals on error if (progressInterval !== undefined) { clearInterval(progressInterval) }
const elapsed = Date.now() - toolStartTime
if (e instanceof Error && e.name !== 'AbortError') { logMCPDebug( name, `Tool '${tool}' failed after ${Math.floor(elapsed / 1000)}s: ${e.message}`, ) }
// Check for 401 errors indicating expired/invalid OAuth tokens // The MCP SDK's StreamableHTTPError has a `code` property with the HTTP status if (e instanceof Error) { const errorCode = 'code' in e ? (e.code as number | undefined) : undefined if (errorCode === 401 || e instanceof UnauthorizedError) { logMCPDebug( name, `Tool call returned 401 Unauthorized - token may have expired`, ) logEvent('tengu_mcp_tool_call_auth_error', {}) throw new McpAuthError( name, `MCP server "${name}" requires re-authorization (token expired)`, ) }
// Check for session expiry — two error shapes can surface here: // 1. Direct 404 + JSON-RPC -32001 from the server (StreamableHTTPError) // 2. -32000 "Connection closed" (McpError) — the SDK closes the transport // after the onerror handler fires, so the pending callTool() rejects // with this derived error instead of the original 404. // In both cases, clear the connection cache so the next tool call // creates a fresh session. const isSessionExpired = isMcpSessionExpiredError(e) const isConnectionClosedOnHttp = 'code' in e && (e as Error & { code?: number }).code === -32000 && e.message.includes('Connection closed') && (config.type === 'http' || config.type === 'claudeai-proxy') if (isSessionExpired || isConnectionClosedOnHttp) { logMCPDebug( name, `MCP session expired during tool call (${isSessionExpired ? '404/-32001' : 'connection closed'}), clearing connection cache for re-initialization`, ) logEvent('tengu_mcp_session_expired', {}) await clearServerCache(name, config) throw new McpSessionExpiredError(name) } }
// When the users hits esc, avoid logspew if (!(e instanceof Error) || e.name !== 'AbortError') { throw e } return { content: undefined } } finally { // Always clear intervals if (progressInterval !== undefined) { clearInterval(progressInterval) } }}
function extractToolUseId(message: AssistantMessage): string | undefined { if (message.message.content[0]?.type !== 'tool_use') { return undefined } return message.message.content[0].id}
/** * Sets up SDK MCP clients by creating transports and connecting them. * This is used for SDK MCP servers that run in the same process as the SDK. * * @param sdkMcpConfigs - The SDK MCP server configurations * @param sendMcpMessage - Callback to send MCP messages through the control channel * @returns Connected clients, their tools, and transport map for message routing */export async function setupSdkMcpClients( sdkMcpConfigs: Record<string, McpSdkServerConfig>, sendMcpMessage: ( serverName: string, message: JSONRPCMessage, ) => Promise<JSONRPCMessage>,): Promise<{ clients: MCPServerConnection[] tools: Tool[]}> { const clients: MCPServerConnection[] = [] const tools: Tool[] = []
// Connect to all servers in parallel const results = await Promise.allSettled( Object.entries(sdkMcpConfigs).map(async ([name, config]) => { const transport = new SdkControlClientTransport(name, sendMcpMessage)
const client = new Client( { name: 'claude-code', title: 'Claude Code', version: MACRO.VERSION ?? 'unknown', description: "Anthropic's agentic coding tool", websiteUrl: PRODUCT_URL, }, { capabilities: {}, }, )
try { // Connect the client await client.connect(transport)
// Get capabilities from the server const capabilities = client.getServerCapabilities()
// Create the connected client object const connectedClient: MCPServerConnection = { type: 'connected', name, capabilities: capabilities || {}, client, config: { ...config, scope: 'dynamic' as const }, cleanup: async () => { await client.close() }, }
// Fetch tools if the server has them const serverTools: Tool[] = [] if (capabilities?.tools) { const sdkTools = await fetchToolsForClient(connectedClient) serverTools.push(...sdkTools) }
return { client: connectedClient, tools: serverTools, } } catch (error) { // If connection fails, return failed server logMCPError(name, `Failed to connect SDK MCP server: ${error}`) return { client: { type: 'failed' as const, name, config: { ...config, scope: 'user' as const }, }, tools: [], } } }), )
// Process results and collect clients and tools for (const result of results) { if (result.status === 'fulfilled') { clients.push(result.value.client) tools.push(...result.value.tools) } // If rejected (unexpected), the error was already logged inside the promise }
return { clients, tools }}