Reduce remaining file, LSP, and XAA debug detail

This commit is contained in:
2026-04-04 10:31:29 +08:00
parent 010ded8476
commit 4d506aabf7
6 changed files with 211 additions and 90 deletions

View File

@@ -14,9 +14,10 @@ import * as path from 'path'
import { count } from '../../utils/array.js' import { count } from '../../utils/array.js'
import { getCwd } from '../../utils/cwd.js' import { getCwd } from '../../utils/cwd.js'
import { logForDebugging } from '../../utils/debug.js' import { logForDebugging } from '../../utils/debug.js'
import { errorMessage } from '../../utils/errors.js' import { errorMessage, getErrnoCode } from '../../utils/errors.js'
import { logError } from '../../utils/log.js' import { logError } from '../../utils/log.js'
import { sleep } from '../../utils/sleep.js' import { sleep } from '../../utils/sleep.js'
import { jsonStringify } from '../../utils/slowOperations.js'
import { import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent, logEvent,
@@ -45,6 +46,37 @@ function logDebug(message: string): void {
logForDebugging(`[files-api] ${message}`) logForDebugging(`[files-api] ${message}`)
} }
function summarizeFilesApiError(error: unknown): string {
const summary: Record<string, boolean | number | string> = {}
if (error instanceof Error) {
summary.errorType = error.constructor.name
summary.errorName = error.name
summary.hasMessage = error.message.length > 0
} else {
summary.errorType = typeof error
summary.hasValue = error !== undefined && error !== null
}
const errno = getErrnoCode(error)
if (errno) {
summary.errno = errno
}
if (axios.isAxiosError(error)) {
summary.errorType = 'AxiosError'
if (error.code) {
summary.axiosCode = error.code
}
if (typeof error.response?.status === 'number') {
summary.httpStatus = error.response.status
}
summary.hasResponseData = error.response?.data !== undefined
}
return jsonStringify(summary)
}
/** /**
* File specification parsed from CLI args * File specification parsed from CLI args
* Format: --file=<file_id>:<relative_path> * Format: --file=<file_id>:<relative_path>
@@ -108,9 +140,7 @@ async function retryWithBackoff<T>(
} }
lastError = result.error || `${operation} failed` lastError = result.error || `${operation} failed`
logDebug( logDebug(`${operation} attempt ${attempt}/${MAX_RETRIES} failed`)
`${operation} attempt ${attempt}/${MAX_RETRIES} failed: ${lastError}`,
)
if (attempt < MAX_RETRIES) { if (attempt < MAX_RETRIES) {
const delayMs = BASE_DELAY_MS * Math.pow(2, attempt - 1) const delayMs = BASE_DELAY_MS * Math.pow(2, attempt - 1)
@@ -142,7 +172,7 @@ export async function downloadFile(
'anthropic-beta': FILES_API_BETA_HEADER, 'anthropic-beta': FILES_API_BETA_HEADER,
} }
logDebug(`Downloading file ${fileId} from ${url}`) logDebug(`Downloading file ${fileId} from configured Files API endpoint`)
return retryWithBackoff(`Download file ${fileId}`, async () => { return retryWithBackoff(`Download file ${fileId}`, async () => {
try { try {
@@ -191,9 +221,7 @@ export function buildDownloadPath(
): string | null { ): string | null {
const normalized = path.normalize(relativePath) const normalized = path.normalize(relativePath)
if (normalized.startsWith('..')) { if (normalized.startsWith('..')) {
logDebugError( logDebugError('Invalid file path rejected: path traversal is not allowed')
`Invalid file path: ${relativePath}. Path must not traverse above workspace`,
)
return null return null
} }
@@ -243,7 +271,7 @@ export async function downloadAndSaveFile(
// Write the file // Write the file
await fs.writeFile(fullPath, content) await fs.writeFile(fullPath, content)
logDebug(`Saved file ${fileId} to ${fullPath} (${content.length} bytes)`) logDebug(`Saved file ${fileId} (${content.length} bytes)`)
return { return {
fileId, fileId,
@@ -252,10 +280,16 @@ export async function downloadAndSaveFile(
bytesWritten: content.length, bytesWritten: content.length,
} }
} catch (error) { } catch (error) {
logDebugError(`Failed to download file ${fileId}: ${errorMessage(error)}`) logDebugError(
if (error instanceof Error) { `Failed to download file ${fileId}: ${summarizeFilesApiError(error)}`,
logError(error) )
} logError(
new Error(
`Files API download failed for ${fileId}: ${summarizeFilesApiError(
error,
)}`,
),
)
return { return {
fileId, fileId,
@@ -390,7 +424,7 @@ export async function uploadFile(
'anthropic-beta': FILES_API_BETA_HEADER, 'anthropic-beta': FILES_API_BETA_HEADER,
} }
logDebug(`Uploading file ${filePath} as ${relativePath}`) logDebug('Uploading file to configured Files API endpoint')
// Read file content first (outside retry loop since it's not a network operation) // Read file content first (outside retry loop since it's not a network operation)
let content: Buffer let content: Buffer
@@ -455,7 +489,7 @@ export async function uploadFile(
const body = Buffer.concat(bodyParts) const body = Buffer.concat(bodyParts)
try { try {
return await retryWithBackoff(`Upload file ${relativePath}`, async () => { return await retryWithBackoff('Upload session file', async () => {
try { try {
const response = await axios.post(url, body, { const response = await axios.post(url, body, {
headers: { headers: {
@@ -476,7 +510,7 @@ export async function uploadFile(
error: 'Upload succeeded but no file ID returned', error: 'Upload succeeded but no file ID returned',
} }
} }
logDebug(`Uploaded file ${filePath} -> ${fileId} (${fileSize} bytes)`) logDebug(`Uploaded file (${fileSize} bytes)`)
return { return {
done: true, done: true,
value: { value: {
@@ -735,9 +769,7 @@ export function parseFileSpecs(fileSpecs: string[]): File[] {
const relativePath = spec.substring(colonIndex + 1) const relativePath = spec.substring(colonIndex + 1)
if (!fileId || !relativePath) { if (!fileId || !relativePath) {
logDebugError( logDebugError('Invalid file spec: missing file_id or relative path')
`Invalid file spec: ${spec}. Both file_id and path are required`,
)
continue continue
} }

View File

@@ -8,6 +8,34 @@ import type { DiagnosticFile } from '../diagnosticTracking.js'
import { registerPendingLSPDiagnostic } from './LSPDiagnosticRegistry.js' import { registerPendingLSPDiagnostic } from './LSPDiagnosticRegistry.js'
import type { LSPServerManager } from './LSPServerManager.js' import type { LSPServerManager } from './LSPServerManager.js'
function summarizeLspErrorForDebug(error: unknown): string {
const err = toError(error)
return jsonStringify({
errorType: err.constructor.name,
errorName: err.name,
hasMessage: err.message.length > 0,
})
}
function summarizeDiagnosticParamsForDebug(params: unknown): string {
if (!params || typeof params !== 'object') {
return jsonStringify({
paramsType: typeof params,
hasValue: params !== undefined && params !== null,
})
}
const paramRecord = params as Record<string, unknown>
const diagnostics = paramRecord.diagnostics
return jsonStringify({
keys: Object.keys(paramRecord)
.sort()
.slice(0, 10),
hasUri: typeof paramRecord.uri === 'string',
diagnosticsCount: Array.isArray(diagnostics) ? diagnostics.length : 0,
})
}
/** /**
* Map LSP severity to Claude diagnostic severity * Map LSP severity to Claude diagnostic severity
* *
@@ -54,7 +82,9 @@ export function formatDiagnosticsForAttachment(
const err = toError(error) const err = toError(error)
logError(err) logError(err)
logForDebugging( logForDebugging(
`Failed to convert URI to file path: ${params.uri}. Error: ${err.message}. Using original URI as fallback.`, `Failed to convert diagnostic URI to file path; using original URI fallback (${summarizeLspErrorForDebug(
err,
)})`,
) )
// Gracefully fallback to original URI - LSP servers may send malformed URIs // Gracefully fallback to original URI - LSP servers may send malformed URIs
uri = params.uri uri = params.uri
@@ -177,14 +207,16 @@ export function registerLSPNotificationHandlers(
) )
logError(err) logError(err)
logForDebugging( logForDebugging(
`Invalid diagnostic params from ${serverName}: ${jsonStringify(params)}`, `Invalid diagnostic params from ${serverName}: ${summarizeDiagnosticParamsForDebug(
params,
)}`,
) )
return return
} }
const diagnosticParams = params as PublishDiagnosticsParams const diagnosticParams = params as PublishDiagnosticsParams
logForDebugging( logForDebugging(
`Received diagnostics from ${serverName}: ${diagnosticParams.diagnostics.length} diagnostic(s) for ${diagnosticParams.uri}`, `Received diagnostics from ${serverName}: ${diagnosticParams.diagnostics.length} diagnostic(s)`,
) )
// Convert LSP diagnostics to Claude format (can throw on invalid URIs) // Convert LSP diagnostics to Claude format (can throw on invalid URIs)
@@ -199,7 +231,7 @@ export function registerLSPNotificationHandlers(
firstFile.diagnostics.length === 0 firstFile.diagnostics.length === 0
) { ) {
logForDebugging( logForDebugging(
`Skipping empty diagnostics from ${serverName} for ${diagnosticParams.uri}`, `Skipping empty diagnostics from ${serverName}`,
) )
return return
} }
@@ -223,9 +255,8 @@ export function registerLSPNotificationHandlers(
logError(err) logError(err)
logForDebugging( logForDebugging(
`Error registering LSP diagnostics from ${serverName}: ` + `Error registering LSP diagnostics from ${serverName}: ` +
`URI: ${diagnosticParams.uri}, ` +
`Diagnostic count: ${firstFile.diagnostics.length}, ` + `Diagnostic count: ${firstFile.diagnostics.length}, ` +
`Error: ${err.message}`, `Error: ${summarizeLspErrorForDebug(err)}`,
) )
// Track consecutive failures and warn after 3+ // Track consecutive failures and warn after 3+
@@ -234,7 +265,7 @@ export function registerLSPNotificationHandlers(
lastError: '', lastError: '',
} }
failures.count++ failures.count++
failures.lastError = err.message failures.lastError = summarizeLspErrorForDebug(err)
diagnosticFailures.set(serverName, failures) diagnosticFailures.set(serverName, failures)
if (failures.count >= 3) { if (failures.count >= 3) {
@@ -251,7 +282,9 @@ export function registerLSPNotificationHandlers(
const err = toError(error) const err = toError(error)
logError(err) logError(err)
logForDebugging( logForDebugging(
`Unexpected error processing diagnostics from ${serverName}: ${err.message}`, `Unexpected error processing diagnostics from ${serverName}: ${summarizeLspErrorForDebug(
err,
)}`,
) )
// Track consecutive failures and warn after 3+ // Track consecutive failures and warn after 3+
@@ -260,7 +293,7 @@ export function registerLSPNotificationHandlers(
lastError: '', lastError: '',
} }
failures.count++ failures.count++
failures.lastError = err.message failures.lastError = summarizeLspErrorForDebug(err)
diagnosticFailures.set(serverName, failures) diagnosticFailures.set(serverName, failures)
if (failures.count >= 3) { if (failures.count >= 3) {
@@ -284,13 +317,13 @@ export function registerLSPNotificationHandlers(
registrationErrors.push({ registrationErrors.push({
serverName, serverName,
error: err.message, error: summarizeLspErrorForDebug(err),
}) })
logError(err) logError(err)
logForDebugging( logForDebugging(
`Failed to register diagnostics handler for ${serverName}: ` + `Failed to register diagnostics handler for ${serverName}: ` +
`Error: ${err.message}`, `Error: ${summarizeLspErrorForDebug(err)}`,
) )
} }
} }

View File

@@ -96,6 +96,24 @@ function redactTokens(raw: unknown): string {
return s.replace(SENSITIVE_TOKEN_RE, (_, k) => `"${k}":"[REDACTED]"`) return s.replace(SENSITIVE_TOKEN_RE, (_, k) => `"${k}":"[REDACTED]"`)
} }
function summarizeXaaPayload(raw: unknown): string {
if (typeof raw === 'string') {
return `text(${raw.length} chars)`
}
if (Array.isArray(raw)) {
return `array(${raw.length})`
}
if (raw && typeof raw === 'object') {
return jsonStringify({
payloadType: 'object',
keys: Object.keys(raw as Record<string, unknown>)
.sort()
.slice(0, 10),
})
}
return typeof raw
}
// ─── Zod Schemas ──────────────────────────────────────────────────────────── // ─── Zod Schemas ────────────────────────────────────────────────────────────
const TokenExchangeResponseSchema = lazySchema(() => const TokenExchangeResponseSchema = lazySchema(() =>
@@ -145,7 +163,7 @@ export async function discoverProtectedResource(
) )
} catch (e) { } catch (e) {
throw new Error( throw new Error(
`XAA: PRM discovery failed: ${e instanceof Error ? e.message : String(e)}`, `XAA: PRM discovery failed (${e instanceof Error ? e.name : typeof e})`,
) )
} }
if (!prm.resource || !prm.authorization_servers?.[0]) { if (!prm.resource || !prm.authorization_servers?.[0]) {
@@ -154,9 +172,7 @@ export async function discoverProtectedResource(
) )
} }
if (normalizeUrl(prm.resource) !== normalizeUrl(serverUrl)) { if (normalizeUrl(prm.resource) !== normalizeUrl(serverUrl)) {
throw new Error( throw new Error('XAA: PRM discovery failed: PRM resource mismatch')
`XAA: PRM discovery failed: PRM resource mismatch: expected ${serverUrl}, got ${prm.resource}`,
)
} }
return { return {
resource: prm.resource, resource: prm.resource,
@@ -183,22 +199,16 @@ export async function discoverAuthorizationServer(
fetchFn: opts?.fetchFn ?? defaultFetch, fetchFn: opts?.fetchFn ?? defaultFetch,
}) })
if (!meta?.issuer || !meta.token_endpoint) { if (!meta?.issuer || !meta.token_endpoint) {
throw new Error( throw new Error('XAA: AS metadata discovery failed: no valid metadata')
`XAA: AS metadata discovery failed: no valid metadata at ${asUrl}`,
)
} }
if (normalizeUrl(meta.issuer) !== normalizeUrl(asUrl)) { if (normalizeUrl(meta.issuer) !== normalizeUrl(asUrl)) {
throw new Error( throw new Error('XAA: AS metadata discovery failed: issuer mismatch')
`XAA: AS metadata discovery failed: issuer mismatch: expected ${asUrl}, got ${meta.issuer}`,
)
} }
// RFC 8414 §3.3 / RFC 9728 §3 require HTTPS. A PRM-advertised http:// AS // RFC 8414 §3.3 / RFC 9728 §3 require HTTPS. A PRM-advertised http:// AS
// that self-consistently reports an http:// issuer would pass the mismatch // that self-consistently reports an http:// issuer would pass the mismatch
// check above, then we'd POST id_token + client_secret over plaintext. // check above, then we'd POST id_token + client_secret over plaintext.
if (new URL(meta.token_endpoint).protocol !== 'https:') { if (new URL(meta.token_endpoint).protocol !== 'https:') {
throw new Error( throw new Error('XAA: refusing non-HTTPS token endpoint')
`XAA: refusing non-HTTPS token endpoint: ${meta.token_endpoint}`,
)
} }
return { return {
issuer: meta.issuer, issuer: meta.issuer,
@@ -263,7 +273,7 @@ export async function requestJwtAuthorizationGrant(opts: {
body: params, body: params,
}) })
if (!res.ok) { if (!res.ok) {
const body = redactTokens(await res.text()).slice(0, 200) const body = summarizeXaaPayload(redactTokens(await res.text()))
// 4xx → id_token rejected (invalid_grant etc.), clear cache. // 4xx → id_token rejected (invalid_grant etc.), clear cache.
// 5xx → IdP outage, id_token may still be valid, preserve it. // 5xx → IdP outage, id_token may still be valid, preserve it.
const shouldClear = res.status < 500 const shouldClear = res.status < 500
@@ -278,21 +288,25 @@ export async function requestJwtAuthorizationGrant(opts: {
} catch { } catch {
// Transient network condition (captive portal, proxy) — don't clear id_token. // Transient network condition (captive portal, proxy) — don't clear id_token.
throw new XaaTokenExchangeError( throw new XaaTokenExchangeError(
`XAA: token exchange returned non-JSON (captive portal?) at ${opts.tokenEndpoint}`, 'XAA: token exchange returned non-JSON response (captive portal?)',
false, false,
) )
} }
const exchangeParsed = TokenExchangeResponseSchema().safeParse(rawExchange) const exchangeParsed = TokenExchangeResponseSchema().safeParse(rawExchange)
if (!exchangeParsed.success) { if (!exchangeParsed.success) {
throw new XaaTokenExchangeError( throw new XaaTokenExchangeError(
`XAA: token exchange response did not match expected shape: ${redactTokens(rawExchange)}`, `XAA: token exchange response did not match expected shape: ${summarizeXaaPayload(
redactTokens(rawExchange),
)}`,
true, true,
) )
} }
const result = exchangeParsed.data const result = exchangeParsed.data
if (!result.access_token) { if (!result.access_token) {
throw new XaaTokenExchangeError( throw new XaaTokenExchangeError(
`XAA: token exchange response missing access_token: ${redactTokens(result)}`, `XAA: token exchange response missing access_token: ${summarizeXaaPayload(
redactTokens(result),
)}`,
true, true,
) )
} }
@@ -373,7 +387,7 @@ export async function exchangeJwtAuthGrant(opts: {
body: params, body: params,
}) })
if (!res.ok) { if (!res.ok) {
const body = redactTokens(await res.text()).slice(0, 200) const body = summarizeXaaPayload(redactTokens(await res.text()))
throw new Error(`XAA: jwt-bearer grant failed: HTTP ${res.status}: ${body}`) throw new Error(`XAA: jwt-bearer grant failed: HTTP ${res.status}: ${body}`)
} }
let rawTokens: unknown let rawTokens: unknown
@@ -381,13 +395,15 @@ export async function exchangeJwtAuthGrant(opts: {
rawTokens = await res.json() rawTokens = await res.json()
} catch { } catch {
throw new Error( throw new Error(
`XAA: jwt-bearer grant returned non-JSON (captive portal?) at ${opts.tokenEndpoint}`, 'XAA: jwt-bearer grant returned non-JSON response (captive portal?)',
) )
} }
const tokensParsed = JwtBearerResponseSchema().safeParse(rawTokens) const tokensParsed = JwtBearerResponseSchema().safeParse(rawTokens)
if (!tokensParsed.success) { if (!tokensParsed.success) {
throw new Error( throw new Error(
`XAA: jwt-bearer response did not match expected shape: ${redactTokens(rawTokens)}`, `XAA: jwt-bearer response did not match expected shape: ${summarizeXaaPayload(
redactTokens(rawTokens),
)}`,
) )
} }
return tokensParsed.data return tokensParsed.data
@@ -431,11 +447,14 @@ export async function performCrossAppAccess(
): Promise<XaaResult> { ): Promise<XaaResult> {
const fetchFn = makeXaaFetch(abortSignal) const fetchFn = makeXaaFetch(abortSignal)
logMCPDebug(serverName, `XAA: discovering PRM for ${serverUrl}`) logMCPDebug(serverName, 'XAA: discovering protected resource metadata')
const prm = await discoverProtectedResource(serverUrl, { fetchFn }) const prm = await discoverProtectedResource(serverUrl, { fetchFn })
logMCPDebug( logMCPDebug(
serverName, serverName,
`XAA: discovered resource=${prm.resource} ASes=[${prm.authorization_servers.join(', ')}]`, `XAA: discovered protected resource metadata ${jsonStringify({
hasResource: Boolean(prm.resource),
authorizationServerCount: prm.authorization_servers.length,
})}`,
) )
// Try each advertised AS in order. grant_types_supported is OPTIONAL per // Try each advertised AS in order. grant_types_supported is OPTIONAL per
@@ -449,16 +468,16 @@ export async function performCrossAppAccess(
candidate = await discoverAuthorizationServer(asUrl, { fetchFn }) candidate = await discoverAuthorizationServer(asUrl, { fetchFn })
} catch (e) { } catch (e) {
if (abortSignal?.aborted) throw e if (abortSignal?.aborted) throw e
asErrors.push(`${asUrl}: ${e instanceof Error ? e.message : String(e)}`) asErrors.push(
`authorization server discovery failed (${e instanceof Error ? e.name : typeof e})`,
)
continue continue
} }
if ( if (
candidate.grant_types_supported && candidate.grant_types_supported &&
!candidate.grant_types_supported.includes(JWT_BEARER_GRANT) !candidate.grant_types_supported.includes(JWT_BEARER_GRANT)
) { ) {
asErrors.push( asErrors.push('authorization server does not advertise jwt-bearer grant')
`${asUrl}: does not advertise jwt-bearer grant (supported: ${candidate.grant_types_supported.join(', ')})`,
)
continue continue
} }
asMeta = candidate asMeta = candidate
@@ -466,7 +485,7 @@ export async function performCrossAppAccess(
} }
if (!asMeta) { if (!asMeta) {
throw new Error( throw new Error(
`XAA: no authorization server supports jwt-bearer. Tried: ${asErrors.join('; ')}`, `XAA: no authorization server supports jwt-bearer (${asErrors.length} candidates tried)`,
) )
} }
// Pick auth method from what the AS advertises. We handle // Pick auth method from what the AS advertises. We handle
@@ -481,7 +500,7 @@ export async function performCrossAppAccess(
: 'client_secret_basic' : 'client_secret_basic'
logMCPDebug( logMCPDebug(
serverName, serverName,
`XAA: AS issuer=${asMeta.issuer} token_endpoint=${asMeta.token_endpoint} auth_method=${authMethod}`, `XAA: selected authorization server (auth_method=${authMethod})`,
) )
logMCPDebug(serverName, `XAA: exchanging id_token for ID-JAG at IdP`) logMCPDebug(serverName, `XAA: exchanging id_token for ID-JAG at IdP`)

View File

@@ -210,9 +210,7 @@ export async function discoverOidc(
signal: AbortSignal.timeout(IDP_REQUEST_TIMEOUT_MS), signal: AbortSignal.timeout(IDP_REQUEST_TIMEOUT_MS),
}) })
if (!res.ok) { if (!res.ok) {
throw new Error( throw new Error(`XAA IdP: OIDC discovery failed (HTTP ${res.status})`)
`XAA IdP: OIDC discovery failed: HTTP ${res.status} at ${url}`,
)
} }
// Captive portals and proxy auth pages return 200 with HTML. res.json() // Captive portals and proxy auth pages return 200 with HTML. res.json()
// throws a raw SyntaxError before safeParse can give a useful message. // throws a raw SyntaxError before safeParse can give a useful message.
@@ -221,17 +219,15 @@ export async function discoverOidc(
body = await res.json() body = await res.json()
} catch { } catch {
throw new Error( throw new Error(
`XAA IdP: OIDC discovery returned non-JSON at ${url} (captive portal or proxy?)`, 'XAA IdP: OIDC discovery returned non-JSON response (captive portal or proxy?)',
) )
} }
const parsed = OpenIdProviderDiscoveryMetadataSchema.safeParse(body) const parsed = OpenIdProviderDiscoveryMetadataSchema.safeParse(body)
if (!parsed.success) { if (!parsed.success) {
throw new Error(`XAA IdP: invalid OIDC metadata: ${parsed.error.message}`) throw new Error('XAA IdP: invalid OIDC metadata')
} }
if (new URL(parsed.data.token_endpoint).protocol !== 'https:') { if (new URL(parsed.data.token_endpoint).protocol !== 'https:') {
throw new Error( throw new Error('XAA IdP: refusing non-HTTPS token endpoint')
`XAA IdP: refusing non-HTTPS token endpoint: ${parsed.data.token_endpoint}`,
)
} }
return parsed.data return parsed.data
} }
@@ -373,7 +369,7 @@ function waitForCallback(
), ),
) )
} else { } else {
rejectOnce(new Error(`XAA IdP: callback server failed: ${err.message}`)) rejectOnce(new Error('XAA IdP: callback server failed'))
} }
}) })
@@ -405,11 +401,11 @@ export async function acquireIdpIdToken(
const cached = getCachedIdpIdToken(idpIssuer) const cached = getCachedIdpIdToken(idpIssuer)
if (cached) { if (cached) {
logMCPDebug('xaa', `Using cached id_token for ${idpIssuer}`) logMCPDebug('xaa', 'Using cached id_token for configured IdP')
return cached return cached
} }
logMCPDebug('xaa', `No cached id_token for ${idpIssuer}; starting OIDC login`) logMCPDebug('xaa', 'No cached id_token for configured IdP; starting OIDC login')
const metadata = await discoverOidc(idpIssuer) const metadata = await discoverOidc(idpIssuer)
const port = opts.callbackPort ?? (await findAvailablePort()) const port = opts.callbackPort ?? (await findAvailablePort())
@@ -478,10 +474,7 @@ export async function acquireIdpIdToken(
: Date.now() + (tokens.expires_in ?? 3600) * 1000 : Date.now() + (tokens.expires_in ?? 3600) * 1000
saveIdpIdToken(idpIssuer, tokens.id_token, expiresAt) saveIdpIdToken(idpIssuer, tokens.id_token, expiresAt)
logMCPDebug( logMCPDebug('xaa', 'Cached id_token for configured IdP')
'xaa',
`Cached id_token for ${idpIssuer} (expires ${new Date(expiresAt).toISOString()})`,
)
return tokens.id_token return tokens.id_token
} }

View File

@@ -57,6 +57,47 @@ function debug(msg: string): void {
logForDebugging(`[brief:upload] ${msg}`) logForDebugging(`[brief:upload] ${msg}`)
} }
function summarizeUploadError(error: unknown): string {
const summary: Record<string, boolean | number | string> = {}
if (error instanceof Error) {
summary.errorType = error.constructor.name
summary.errorName = error.name
summary.hasMessage = error.message.length > 0
} else {
summary.errorType = typeof error
summary.hasValue = error !== undefined && error !== null
}
if (axios.isAxiosError(error)) {
summary.errorType = 'AxiosError'
if (error.code) {
summary.axiosCode = error.code
}
if (typeof error.response?.status === 'number') {
summary.httpStatus = error.response.status
}
summary.hasResponseData = error.response?.data !== undefined
}
return jsonStringify(summary)
}
function summarizeUploadResponse(data: unknown): string {
if (data === undefined) return 'undefined'
if (data === null) return 'null'
if (Array.isArray(data)) return `array(${data.length})`
if (typeof data === 'object') {
return jsonStringify({
responseType: 'object',
keys: Object.keys(data as Record<string, unknown>)
.sort()
.slice(0, 10),
})
}
return typeof data
}
/** /**
* Base URL for uploads. Must match the host the token is valid for. * Base URL for uploads. Must match the host the token is valid for.
* *
@@ -100,7 +141,9 @@ export async function uploadBriefAttachment(
if (!ctx.replBridgeEnabled) return undefined if (!ctx.replBridgeEnabled) return undefined
if (size > MAX_UPLOAD_BYTES) { if (size > MAX_UPLOAD_BYTES) {
debug(`skip ${fullPath}: ${size} bytes exceeds ${MAX_UPLOAD_BYTES} limit`) debug(
`skip attachment upload: ${size} bytes exceeds ${MAX_UPLOAD_BYTES} limit`,
)
return undefined return undefined
} }
@@ -114,7 +157,7 @@ export async function uploadBriefAttachment(
try { try {
content = await readFile(fullPath) content = await readFile(fullPath)
} catch (e) { } catch (e) {
debug(`read failed for ${fullPath}: ${e}`) debug(`read failed before upload: ${summarizeUploadError(e)}`)
return undefined return undefined
} }
@@ -150,23 +193,23 @@ export async function uploadBriefAttachment(
if (response.status !== 201) { if (response.status !== 201) {
debug( debug(
`upload failed for ${fullPath}: status=${response.status} body=${jsonStringify(response.data).slice(0, 200)}`, `upload failed: status=${response.status} response=${summarizeUploadResponse(
response.data,
)}`,
) )
return undefined return undefined
} }
const parsed = uploadResponseSchema().safeParse(response.data) const parsed = uploadResponseSchema().safeParse(response.data)
if (!parsed.success) { if (!parsed.success) {
debug( debug('unexpected upload response shape')
`unexpected response shape for ${fullPath}: ${parsed.error.message}`,
)
return undefined return undefined
} }
debug(`uploaded ${fullPath}${parsed.data.file_uuid} (${size} bytes)`) debug(`uploaded attachment (${size} bytes)`)
return parsed.data.file_uuid return parsed.data.file_uuid
} catch (e) { } catch (e) {
debug(`upload threw for ${fullPath}: ${e}`) debug(`upload threw: ${summarizeUploadError(e)}`)
return undefined return undefined
} }
} }

View File

@@ -15,7 +15,6 @@ import type {
ScopedMcpServerConfig, ScopedMcpServerConfig,
} from '../../services/mcp/types.js' } from '../../services/mcp/types.js'
import type { Tool } from '../../Tool.js' import type { Tool } from '../../Tool.js'
import { errorMessage } from '../../utils/errors.js'
import { lazySchema } from '../../utils/lazySchema.js' import { lazySchema } from '../../utils/lazySchema.js'
import { logMCPDebug, logMCPError } from '../../utils/log.js' import { logMCPDebug, logMCPError } from '../../utils/log.js'
import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js' import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js'
@@ -29,9 +28,11 @@ export type McpAuthOutput = {
authUrl?: string authUrl?: string
} }
function getConfigUrl(config: ScopedMcpServerConfig): string | undefined { function summarizeMcpAuthToolError(error: unknown): string {
if ('url' in config) return config.url if (error instanceof Error) {
return undefined return `${error.name} (hasMessage=${error.message.length > 0})`
}
return `non-Error (${typeof error})`
} }
/** /**
@@ -50,12 +51,10 @@ export function createMcpAuthTool(
serverName: string, serverName: string,
config: ScopedMcpServerConfig, config: ScopedMcpServerConfig,
): Tool<InputSchema, McpAuthOutput> { ): Tool<InputSchema, McpAuthOutput> {
const url = getConfigUrl(config)
const transport = config.type ?? 'stdio' const transport = config.type ?? 'stdio'
const location = url ? `${transport} at ${url}` : transport
const description = const description =
`The \`${serverName}\` MCP server (${location}) is installed but requires authentication. ` + `The \`${serverName}\` MCP server (${transport}) is installed but requires authentication. ` +
`Call this tool to start the OAuth flow — you'll receive an authorization URL to share with the user. ` + `Call this tool to start the OAuth flow — you'll receive an authorization URL to share with the user. ` +
`Once the user completes authorization in their browser, the server's real tools will become available automatically.` `Once the user completes authorization in their browser, the server's real tools will become available automatically.`
@@ -167,7 +166,9 @@ export function createMcpAuthTool(
.catch(err => { .catch(err => {
logMCPError( logMCPError(
serverName, serverName,
`OAuth flow failed after tool-triggered start: ${errorMessage(err)}`, `OAuth flow failed after tool-triggered start: ${summarizeMcpAuthToolError(
err,
)}`,
) )
}) })
@@ -199,7 +200,7 @@ export function createMcpAuthTool(
return { return {
data: { data: {
status: 'error' as const, status: 'error' as const,
message: `Failed to start OAuth flow for ${serverName}: ${errorMessage(err)}. Ask the user to run /mcp and authenticate manually.`, message: `Failed to start OAuth flow for ${serverName}. Ask the user to run /mcp and authenticate manually.`,
}, },
} }
} }