Merge upstream/main into fix/anthropic-schema-format

This commit is contained in:
skfallin
2026-04-02 15:42:28 +02:00
71 changed files with 6989 additions and 931 deletions

View File

@@ -154,7 +154,10 @@ export async function getAnthropicClient({
fetch: resolvedFetch,
}),
}
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI)) {
if (
isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI) ||
isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
) {
const { createOpenAIShimClient } = await import('./openaiShim.js')
return createOpenAIShimClient({
defaultHeaders,

View File

@@ -144,6 +144,42 @@ describe('Codex request translation', () => {
])
})
test('removes unsupported uri format from strict Responses schemas', () => {
const tools = convertToolsToResponsesTools([
{
name: 'WebFetch',
description: 'Fetch a URL',
input_schema: {
type: 'object',
properties: {
url: { type: 'string', format: 'uri' },
prompt: { type: 'string' },
},
required: ['url', 'prompt'],
additionalProperties: false,
},
},
])
expect(tools).toEqual([
{
type: 'function',
name: 'WebFetch',
description: 'Fetch a URL',
parameters: {
type: 'object',
properties: {
url: { type: 'string' },
prompt: { type: 'string' },
},
required: ['url', 'prompt'],
additionalProperties: false,
},
strict: true,
},
])
})
test('converts assistant tool use and user tool result into Responses items', () => {
const items = convertAnthropicMessagesToResponsesInput([
{

View File

@@ -1,3 +1,4 @@
import { APIError } from '@anthropic-ai/sdk'
import type {
ResolvedCodexCredentials,
ResolvedProviderRequest,
@@ -234,7 +235,10 @@ export function convertAnthropicMessagesToResponsesInput(
items.push({
type: 'function_call_output',
call_id: callId,
output: convertToolResultToText(toolResult.content),
output: (() => {
const out = convertToolResultToText(toolResult.content)
return toolResult.is_error ? `Error: ${out}` : out
})(),
})
}
@@ -311,6 +315,11 @@ function enforceStrictSchema(schema: unknown): Record<string, unknown> {
// Codex API strict schemas reject these JSON schema keywords
delete record.$schema
delete record.propertyNames
// Codex Responses rejects JSON Schema's standard `uri` string format.
// Keep URL validation in the tool layer and send a plain string here.
if (record.format === 'uri') {
delete record.format
}
if (record.type === 'object') {
// OpenAI structured outputs completely forbid dynamic additionalProperties.
@@ -453,6 +462,7 @@ function convertToolChoice(toolChoice: unknown): unknown {
if (!choice?.type) return undefined
if (choice.type === 'auto') return 'auto'
if (choice.type === 'any') return 'required'
if (choice.type === 'none') return 'none'
if (choice.type === 'tool' && choice.name) {
return {
type: 'function',
@@ -553,7 +563,13 @@ export async function performCodexRequest(options: {
if (!response.ok) {
const errorBody = await response.text().catch(() => 'unknown error')
throw new Error(`Codex API error ${response.status}: ${errorBody}`)
let errorResponse: object | undefined
try { errorResponse = JSON.parse(errorBody) } catch { /* raw text */ }
throw APIError.generate(
response.status, errorResponse,
`Codex API error ${response.status}: ${errorBody}`,
response.headers as unknown as Record<string, string>,
)
}
return response
@@ -633,11 +649,9 @@ export async function collectCodexCompletedResponse(
for await (const event of readSseEvents(response)) {
if (event.event === 'response.failed') {
throw new Error(
event.data?.response?.error?.message ??
event.data?.error?.message ??
'Codex response failed',
)
const msg = event.data?.response?.error?.message ??
event.data?.error?.message ?? 'Codex response failed'
throw APIError.generate(500, undefined, msg, {} as Record<string, string>)
}
if (
@@ -650,7 +664,10 @@ export async function collectCodexCompletedResponse(
}
if (!completedResponse) {
throw new Error('Codex response ended without a completed payload')
throw APIError.generate(
500, undefined, 'Codex response ended without a completed payload',
{} as Record<string, string>,
)
}
return completedResponse
@@ -806,11 +823,9 @@ export async function* codexStreamToAnthropic(
}
if (event.event === 'response.failed') {
throw new Error(
payload?.response?.error?.message ??
payload?.error?.message ??
'Codex response failed',
)
const msg = payload?.response?.error?.message ??
payload?.error?.message ?? 'Codex response failed'
throw APIError.generate(500, undefined, msg, {} as Record<string, string>)
}
}

View File

@@ -14,8 +14,16 @@
* OPENAI_BASE_URL=http://... — base URL (default: https://api.openai.com/v1)
* OPENAI_MODEL=gpt-4o — default model override
* CODEX_API_KEY / ~/.codex/auth.json — Codex auth for codexplan/codexspark
*
* GitHub Models (models.github.ai), OpenAI-compatible:
* CLAUDE_CODE_USE_GITHUB=1 — enable GitHub inference (no need for USE_OPENAI)
* GITHUB_TOKEN or GH_TOKEN — PAT with models access (mapped to Bearer auth)
* OPENAI_MODEL — optional; use github:copilot or openai/gpt-4.1 style IDs
*/
import { APIError } from '@anthropic-ai/sdk'
import { isEnvTruthy } from '../../utils/envUtils.js'
import { hydrateGithubModelsTokenFromSecureStorage } from '../../utils/githubModelsCredentials.js'
import {
codexStreamToAnthropic,
collectCodexCompletedResponse,
@@ -26,10 +34,31 @@ import {
type ShimCreateParams,
} from './codexShim.js'
import {
isLocalProviderUrl,
resolveCodexApiCredentials,
resolveProviderRequest,
} from './providerConfig.js'
import { stripIncompatibleSchemaKeywords } from '../../utils/schemaSanitizer.js'
import { redactSecretValueForDisplay } from '../../utils/providerProfile.js'
const GITHUB_MODELS_DEFAULT_BASE = 'https://models.github.ai/inference'
const GITHUB_API_VERSION = '2022-11-28'
const GITHUB_429_MAX_RETRIES = 3
const GITHUB_429_BASE_DELAY_SEC = 1
const GITHUB_429_MAX_DELAY_SEC = 32
function isGithubModelsMode(): boolean {
return isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
}
function formatRetryAfterHint(response: Response): string {
const ra = response.headers.get('retry-after')
return ra ? ` (Retry-After: ${ra})` : ''
}
function sleepMs(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
// ---------------------------------------------------------------------------
// Types — minimal subset of Anthropic SDK types we need to produce
@@ -188,7 +217,10 @@ function convertMessages(
const assistantMsg: OpenAIMessage = {
role: 'assistant',
content: convertContentBlocks(textContent) as string,
content: (() => {
const c = convertContentBlocks(textContent)
return typeof c === 'string' ? c : Array.isArray(c) ? c.map((p: { text?: string }) => p.text ?? '').join('') : ''
})(),
}
if (toolUses.length > 0) {
@@ -217,7 +249,10 @@ function convertMessages(
} else {
result.push({
role: 'assistant',
content: convertContentBlocks(content) as string,
content: (() => {
const c = convertContentBlocks(content)
return typeof c === 'string' ? c : Array.isArray(c) ? c.map((p: { text?: string }) => p.text ?? '').join('') : ''
})(),
})
}
}
@@ -296,9 +331,7 @@ function normalizeSchemaForOpenAI(
function convertTools(
tools: Array<{ name: string; description?: string; input_schema?: Record<string, unknown> }>,
): OpenAITool[] {
const isGemini =
process.env.CLAUDE_CODE_USE_GEMINI === '1' ||
process.env.CLAUDE_CODE_USE_GEMINI === 'true'
const isGemini = isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI)
return tools
.filter(t => t.name !== 'ToolSearchTool') // Not relevant for OpenAI
@@ -595,7 +628,8 @@ async function* openaiStreamToAnthropic(
if (
!hasEmittedFinalUsage &&
chunkUsage &&
(chunk.choices?.length ?? 0) === 0
(chunk.choices?.length ?? 0) === 0 &&
lastStopReason !== null
) {
yield {
type: 'message_delta',
@@ -633,9 +667,11 @@ class OpenAIShimStream {
class OpenAIShimMessages {
private defaultHeaders: Record<string, string>
private reasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh'
constructor(defaultHeaders: Record<string, string>) {
constructor(defaultHeaders: Record<string, string>, reasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh') {
this.defaultHeaders = defaultHeaders
this.reasoningEffort = reasoningEffort
}
create(
@@ -644,9 +680,12 @@ class OpenAIShimMessages {
) {
const self = this
let httpResponse: Response | undefined
const promise = (async () => {
const request = resolveProviderRequest({ model: params.model })
const request = resolveProviderRequest({ model: params.model, reasoningEffortOverride: self.reasoningEffort })
const response = await self._doRequest(request, params, options)
httpResponse = response
if (params.stream) {
return new OpenAIShimStream(
@@ -673,8 +712,9 @@ class OpenAIShimMessages {
const data = await promise
return {
data,
response: new Response(),
request_id: makeMessageId(),
response: httpResponse ?? new Response(),
request_id:
httpResponse?.headers.get('x-request-id') ?? makeMessageId(),
}
}
@@ -692,8 +732,11 @@ class OpenAIShimMessages {
const authHint = credentials.authPath
? ` or place a Codex auth.json at ${credentials.authPath}`
: ''
const safeModel =
redactSecretValueForDisplay(request.requestedModel, process.env) ??
'the requested model'
throw new Error(
`Codex auth is required for ${request.requestedModel}. Set CODEX_API_KEY${authHint}.`,
`Codex auth is required for ${safeModel}. Set CODEX_API_KEY${authHint}.`,
)
}
if (!credentials.accountId) {
@@ -752,10 +795,16 @@ class OpenAIShimMessages {
body.max_completion_tokens = maxCompletionTokensValue
}
if (params.stream) {
if (params.stream && !isLocalProviderUrl(request.baseUrl)) {
body.stream_options = { include_usage: true }
}
const isGithub = isGithubModelsMode()
if (isGithub && body.max_completion_tokens !== undefined) {
body.max_tokens = body.max_completion_tokens
delete body.max_completion_tokens
}
if (params.temperature !== undefined) body.temperature = params.temperature
if (params.top_p !== undefined) body.top_p = params.top_p
@@ -805,6 +854,11 @@ class OpenAIShimMessages {
}
}
if (isGithub) {
headers.Accept = 'application/vnd.github.v3+json'
headers['X-GitHub-Api-Version'] = GITHUB_API_VERSION
}
// Build the chat completions URL
// Azure Cognitive Services / Azure OpenAI require a deployment-specific path
// and an api-version query parameter.
@@ -827,19 +881,50 @@ class OpenAIShimMessages {
chatCompletionsUrl = `${request.baseUrl}/chat/completions`
}
const response = await fetch(chatCompletionsUrl, {
method: 'POST',
const fetchInit = {
method: 'POST' as const,
headers,
body: JSON.stringify(body),
signal: options?.signal,
})
if (!response.ok) {
const errorBody = await response.text().catch(() => 'unknown error')
throw new Error(`OpenAI API error ${response.status}: ${errorBody}`)
}
return response
const maxAttempts = isGithub ? GITHUB_429_MAX_RETRIES : 1
let response: Response | undefined
for (let attempt = 0; attempt < maxAttempts; attempt++) {
response = await fetch(chatCompletionsUrl, fetchInit)
if (response.ok) {
return response
}
if (
isGithub &&
response.status === 429 &&
attempt < maxAttempts - 1
) {
await response.text().catch(() => {})
const delaySec = Math.min(
GITHUB_429_BASE_DELAY_SEC * 2 ** attempt,
GITHUB_429_MAX_DELAY_SEC,
)
await sleepMs(delaySec * 1000)
continue
}
const errorBody = await response.text().catch(() => 'unknown error')
const rateHint =
isGithub && response.status === 429 ? formatRetryAfterHint(response) : ''
let errorResponse: object | undefined
try { errorResponse = JSON.parse(errorBody) } catch { /* raw text */ }
throw APIError.generate(
response.status,
errorResponse,
`OpenAI API error ${response.status}: ${errorBody}${rateHint}`,
response.headers as unknown as Record<string, string>,
)
}
throw APIError.generate(
500, undefined, 'OpenAI shim: request loop exited unexpectedly',
{} as Record<string, string>,
)
}
private _convertNonStreamingResponse(
@@ -849,7 +934,10 @@ class OpenAIShimMessages {
choices?: Array<{
message?: {
role?: string
content?: string | null
content?:
| string
| null
| Array<{ type?: string; text?: string }>
tool_calls?: Array<{
id: string
function: { name: string; arguments: string }
@@ -868,8 +956,25 @@ class OpenAIShimMessages {
const choice = data.choices?.[0]
const content: Array<Record<string, unknown>> = []
if (choice?.message?.content) {
content.push({ type: 'text', text: choice.message.content })
const rawContent = choice?.message?.content
if (typeof rawContent === 'string' && rawContent) {
content.push({ type: 'text', text: rawContent })
} else if (Array.isArray(rawContent) && rawContent.length > 0) {
const parts: string[] = []
for (const part of rawContent) {
if (
part &&
typeof part === 'object' &&
part.type === 'text' &&
typeof part.text === 'string'
) {
parts.push(part.text)
}
}
const joined = parts.join('\n')
if (joined) {
content.push({ type: 'text', text: joined })
}
}
if (choice?.message?.tool_calls) {
@@ -917,9 +1022,11 @@ class OpenAIShimMessages {
class OpenAIShimBeta {
messages: OpenAIShimMessages
reasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh'
constructor(defaultHeaders: Record<string, string>) {
this.messages = new OpenAIShimMessages(defaultHeaders)
constructor(defaultHeaders: Record<string, string>, reasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh') {
this.messages = new OpenAIShimMessages(defaultHeaders, reasoningEffort)
this.reasoningEffort = reasoningEffort
}
}
@@ -927,13 +1034,13 @@ export function createOpenAIShimClient(options: {
defaultHeaders?: Record<string, string>
maxRetries?: number
timeout?: number
reasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh'
}): unknown {
hydrateGithubModelsTokenFromSecureStorage()
// When Gemini provider is active, map Gemini env vars to OpenAI-compatible ones
// so the existing providerConfig.ts infrastructure picks them up correctly.
if (
process.env.CLAUDE_CODE_USE_GEMINI === '1' ||
process.env.CLAUDE_CODE_USE_GEMINI === 'true'
) {
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI)) {
process.env.OPENAI_BASE_URL ??=
process.env.GEMINI_BASE_URL ??
'https://generativelanguage.googleapis.com/v1beta/openai'
@@ -942,11 +1049,15 @@ export function createOpenAIShimClient(options: {
if (process.env.GEMINI_MODEL && !process.env.OPENAI_MODEL) {
process.env.OPENAI_MODEL = process.env.GEMINI_MODEL
}
} else if (isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)) {
process.env.OPENAI_BASE_URL ??= GITHUB_MODELS_DEFAULT_BASE
process.env.OPENAI_API_KEY ??=
process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? ''
}
const beta = new OpenAIShimBeta({
...(options.defaultHeaders ?? {}),
})
}, options.reasoningEffort)
return {
beta,

View File

@@ -0,0 +1,41 @@
import { afterEach, expect, test } from 'bun:test'
import {
DEFAULT_GITHUB_MODELS_API_MODEL,
normalizeGithubModelsApiModel,
resolveProviderRequest,
} from './providerConfig.js'
const originalUseGithub = process.env.CLAUDE_CODE_USE_GITHUB
afterEach(() => {
if (originalUseGithub === undefined) {
delete process.env.CLAUDE_CODE_USE_GITHUB
} else {
process.env.CLAUDE_CODE_USE_GITHUB = originalUseGithub
}
})
test.each([
['copilot', DEFAULT_GITHUB_MODELS_API_MODEL],
['github:copilot', DEFAULT_GITHUB_MODELS_API_MODEL],
['', DEFAULT_GITHUB_MODELS_API_MODEL],
['github:gpt-4o', 'gpt-4o'],
['gpt-4o', 'gpt-4o'],
['github:copilot?reasoning=high', DEFAULT_GITHUB_MODELS_API_MODEL],
] as const)('normalizeGithubModelsApiModel(%s) -> %s', (input, expected) => {
expect(normalizeGithubModelsApiModel(input)).toBe(expected)
})
test('resolveProviderRequest applies GitHub normalization when CLAUDE_CODE_USE_GITHUB=1', () => {
process.env.CLAUDE_CODE_USE_GITHUB = '1'
const r = resolveProviderRequest({ model: 'github:gpt-4o' })
expect(r.resolvedModel).toBe('gpt-4o')
expect(r.transport).toBe('chat_completions')
})
test('resolveProviderRequest leaves model unchanged without GitHub flag', () => {
delete process.env.CLAUDE_CODE_USE_GITHUB
const r = resolveProviderRequest({ model: 'github:gpt-4o' })
expect(r.resolvedModel).toBe('github:gpt-4o')
})

View File

@@ -2,8 +2,12 @@ import { existsSync, readFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { isEnvTruthy } from '../../utils/envUtils.js'
export const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1'
export const DEFAULT_CODEX_BASE_URL = 'https://chatgpt.com/backend-api/codex'
/** Default GitHub Models API model when user selects copilot / github:copilot */
export const DEFAULT_GITHUB_MODELS_API_MODEL = 'openai/gpt-4.1'
const CODEX_ALIAS_MODELS: Record<
string,
@@ -16,13 +20,43 @@ const CODEX_ALIAS_MODELS: Record<
model: 'gpt-5.4',
reasoningEffort: 'high',
},
'gpt-5.4': {
model: 'gpt-5.4',
reasoningEffort: 'high',
},
'gpt-5.3-codex': {
model: 'gpt-5.3-codex',
reasoningEffort: 'high',
},
'gpt-5.3-codex-spark': {
model: 'gpt-5.3-codex-spark',
},
codexspark: {
model: 'gpt-5.3-codex-spark',
},
'gpt-5.2-codex': {
model: 'gpt-5.2-codex',
reasoningEffort: 'high',
},
'gpt-5.1-codex-max': {
model: 'gpt-5.1-codex-max',
reasoningEffort: 'high',
},
'gpt-5.1-codex-mini': {
model: 'gpt-5.1-codex-mini',
},
'gpt-5.4-mini': {
model: 'gpt-5.4-mini',
reasoningEffort: 'medium',
},
'gpt-5.2': {
model: 'gpt-5.2',
reasoningEffort: 'medium',
},
} as const
type CodexAlias = keyof typeof CODEX_ALIAS_MODELS
type ReasoningEffort = 'low' | 'medium' | 'high'
type ReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh'
export type ProviderTransport = 'chat_completions' | 'codex_responses'
@@ -98,7 +132,7 @@ function decodeJwtPayload(token: string): Record<string, unknown> | undefined {
function parseReasoningEffort(value: string | undefined): ReasoningEffort | undefined {
if (!value) return undefined
const normalized = value.trim().toLowerCase()
if (normalized === 'low' || normalized === 'medium' || normalized === 'high') {
if (normalized === 'low' || normalized === 'medium' || normalized === 'high' || normalized === 'xhigh') {
return normalized
}
return undefined
@@ -171,16 +205,32 @@ export function isCodexBaseUrl(baseUrl: string | undefined): boolean {
}
}
/**
* Normalize user model string for GitHub Models inference (models.github.ai).
* Mirrors runtime devsper `github._normalize_model_id`.
*/
export function normalizeGithubModelsApiModel(requestedModel: string): string {
const noQuery = requestedModel.split('?', 1)[0] ?? requestedModel
const segment =
noQuery.includes(':') ? noQuery.split(':', 2)[1]!.trim() : noQuery.trim()
if (!segment || segment.toLowerCase() === 'copilot') {
return DEFAULT_GITHUB_MODELS_API_MODEL
}
return segment
}
export function resolveProviderRequest(options?: {
model?: string
baseUrl?: string
fallbackModel?: string
reasoningEffortOverride?: ReasoningEffort
}): ResolvedProviderRequest {
const isGithubMode = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
const requestedModel =
options?.model?.trim() ||
process.env.OPENAI_MODEL?.trim() ||
options?.fallbackModel?.trim() ||
'gpt-4o'
(isGithubMode ? 'github:copilot' : 'gpt-4o')
const descriptor = parseModelDescriptor(requestedModel)
const rawBaseUrl =
options?.baseUrl ??
@@ -192,17 +242,28 @@ export function resolveProviderRequest(options?: {
? 'codex_responses'
: 'chat_completions'
const resolvedModel =
transport === 'chat_completions' &&
isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
? normalizeGithubModelsApiModel(requestedModel)
: descriptor.baseModel
const reasoning = options?.reasoningEffortOverride
? { effort: options.reasoningEffortOverride }
: descriptor.reasoning
return {
transport,
requestedModel,
resolvedModel: descriptor.baseModel,
resolvedModel,
baseUrl:
(rawBaseUrl ??
(transport === 'codex_responses'
? DEFAULT_CODEX_BASE_URL
: DEFAULT_OPENAI_BASE_URL)
).replace(/\/+$/, ''),
reasoning: descriptor.reasoning,
reasoning,
}
}
@@ -311,3 +372,11 @@ export function resolveCodexApiCredentials(
source: 'auth.json',
}
}
export function getReasoningEffortForModel(model: string): ReasoningEffort | undefined {
const normalized = model.trim().toLowerCase()
const base = normalized.split('?', 1)[0] ?? normalized
const alias = base as CodexAlias
const aliasConfig = CODEX_ALIAS_MODELS[alias]
return aliasConfig?.reasoningEffort
}

View File

@@ -0,0 +1,94 @@
import { afterEach, describe, expect, mock, test } from 'bun:test'
import {
GitHubDeviceFlowError,
pollAccessToken,
requestDeviceCode,
} from './deviceFlow.js'
describe('requestDeviceCode', () => {
const originalFetch = globalThis.fetch
afterEach(() => {
globalThis.fetch = originalFetch
})
test('parses successful device code response', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
new Response(
JSON.stringify({
device_code: 'abc',
user_code: 'ABCD-1234',
verification_uri: 'https://github.com/login/device',
expires_in: 600,
interval: 5,
}),
{ status: 200 },
),
),
)
const r = await requestDeviceCode({
clientId: 'test-client',
fetchImpl: globalThis.fetch,
})
expect(r.device_code).toBe('abc')
expect(r.user_code).toBe('ABCD-1234')
expect(r.verification_uri).toBe('https://github.com/login/device')
expect(r.expires_in).toBe(600)
expect(r.interval).toBe(5)
})
test('throws on HTTP error', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(new Response('bad', { status: 500 })),
)
await expect(
requestDeviceCode({ clientId: 'x', fetchImpl: globalThis.fetch }),
).rejects.toThrow(GitHubDeviceFlowError)
})
})
describe('pollAccessToken', () => {
const originalFetch = globalThis.fetch
afterEach(() => {
globalThis.fetch = originalFetch
})
test('returns token when GitHub responds with access_token immediately', async () => {
let calls = 0
globalThis.fetch = mock(() => {
calls++
return Promise.resolve(
new Response(JSON.stringify({ access_token: 'tok-xyz' }), {
status: 200,
}),
)
})
const token = await pollAccessToken('dev-code', {
clientId: 'cid',
fetchImpl: globalThis.fetch,
})
expect(token).toBe('tok-xyz')
expect(calls).toBe(1)
})
test('throws on access_denied', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
new Response(JSON.stringify({ error: 'access_denied' }), {
status: 200,
}),
),
)
await expect(
pollAccessToken('dc', {
clientId: 'c',
fetchImpl: globalThis.fetch,
}),
).rejects.toThrow(/denied/)
})
})

View File

@@ -0,0 +1,174 @@
/**
* GitHub OAuth device flow for CLI login (https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow).
*/
import { execFileNoThrow } from '../../utils/execFileNoThrow.js'
export const DEFAULT_GITHUB_DEVICE_FLOW_CLIENT_ID = 'Ov23liXjWSSui6QIahPl'
export const GITHUB_DEVICE_CODE_URL = 'https://github.com/login/device/code'
export const GITHUB_DEVICE_ACCESS_TOKEN_URL =
'https://github.com/login/oauth/access_token'
/** Match runtime devsper github_oauth DEFAULT_SCOPE */
export const DEFAULT_GITHUB_DEVICE_SCOPE = 'read:user,models:read'
export class GitHubDeviceFlowError extends Error {
constructor(message: string) {
super(message)
this.name = 'GitHubDeviceFlowError'
}
}
export type DeviceCodeResult = {
device_code: string
user_code: string
verification_uri: string
expires_in: number
interval: number
}
export function getGithubDeviceFlowClientId(): string {
return (
process.env.GITHUB_DEVICE_FLOW_CLIENT_ID?.trim() ||
DEFAULT_GITHUB_DEVICE_FLOW_CLIENT_ID
)
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
export async function requestDeviceCode(options?: {
clientId?: string
scope?: string
fetchImpl?: typeof fetch
}): Promise<DeviceCodeResult> {
const clientId = options?.clientId ?? getGithubDeviceFlowClientId()
if (!clientId) {
throw new GitHubDeviceFlowError(
'No OAuth client ID: set GITHUB_DEVICE_FLOW_CLIENT_ID or paste a PAT instead.',
)
}
const fetchFn = options?.fetchImpl ?? fetch
const res = await fetchFn(GITHUB_DEVICE_CODE_URL, {
method: 'POST',
headers: { Accept: 'application/json' },
body: new URLSearchParams({
client_id: clientId,
scope: options?.scope ?? DEFAULT_GITHUB_DEVICE_SCOPE,
}),
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new GitHubDeviceFlowError(
`Device code request failed: ${res.status} ${text}`,
)
}
const data = (await res.json()) as Record<string, unknown>
const device_code = data.device_code
const user_code = data.user_code
const verification_uri = data.verification_uri
if (
typeof device_code !== 'string' ||
typeof user_code !== 'string' ||
typeof verification_uri !== 'string'
) {
throw new GitHubDeviceFlowError('Malformed device code response from GitHub')
}
return {
device_code,
user_code,
verification_uri,
expires_in: typeof data.expires_in === 'number' ? data.expires_in : 900,
interval: typeof data.interval === 'number' ? data.interval : 5,
}
}
export type PollOptions = {
clientId?: string
initialInterval?: number
timeoutSeconds?: number
fetchImpl?: typeof fetch
}
export async function pollAccessToken(
deviceCode: string,
options?: PollOptions,
): Promise<string> {
const clientId = options?.clientId ?? getGithubDeviceFlowClientId()
if (!clientId) {
throw new GitHubDeviceFlowError('client_id required for polling')
}
let interval = Math.max(1, options?.initialInterval ?? 5)
const timeoutSeconds = options?.timeoutSeconds ?? 900
const fetchFn = options?.fetchImpl ?? fetch
const start = Date.now()
while ((Date.now() - start) / 1000 < timeoutSeconds) {
const res = await fetchFn(GITHUB_DEVICE_ACCESS_TOKEN_URL, {
method: 'POST',
headers: { Accept: 'application/json' },
body: new URLSearchParams({
client_id: clientId,
device_code: deviceCode,
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
}),
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new GitHubDeviceFlowError(
`Token request failed: ${res.status} ${text}`,
)
}
const data = (await res.json()) as Record<string, unknown>
const err = data.error as string | undefined
if (err == null) {
const token = data.access_token
if (typeof token === 'string' && token) {
return token
}
throw new GitHubDeviceFlowError('No access_token in response')
}
if (err === 'authorization_pending') {
await sleep(interval * 1000)
continue
}
if (err === 'slow_down') {
interval =
typeof data.interval === 'number' ? data.interval : interval + 5
await sleep(interval * 1000)
continue
}
if (err === 'expired_token') {
throw new GitHubDeviceFlowError(
'Device code expired. Start the login flow again.',
)
}
if (err === 'access_denied') {
throw new GitHubDeviceFlowError('Authorization was denied or cancelled.')
}
throw new GitHubDeviceFlowError(`GitHub OAuth error: ${err}`)
}
throw new GitHubDeviceFlowError('Timed out waiting for authorization.')
}
/**
* Best-effort open browser / OS handler for the verification URL.
*/
export async function openVerificationUri(uri: string): Promise<void> {
try {
if (process.platform === 'darwin') {
await execFileNoThrow('open', [uri], { useCwd: false, timeout: 5000 })
} else if (process.platform === 'win32') {
await execFileNoThrow('cmd', ['/c', 'start', '', uri], {
useCwd: false,
timeout: 5000,
})
} else {
await execFileNoThrow('xdg-open', [uri], { useCwd: false, timeout: 5000 })
}
} catch {
// User can open the URL manually
}
}

View File

@@ -0,0 +1,48 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { cleanupFailedConnection } from './client.js'
test('cleanupFailedConnection awaits transport close before resolving', async () => {
let closed = false
let resolveClose: (() => void) | undefined
const transport = {
close: async () =>
await new Promise<void>(resolve => {
resolveClose = () => {
closed = true
resolve()
}
}),
}
const cleanupPromise = cleanupFailedConnection(transport)
assert.equal(closed, false)
resolveClose?.()
await cleanupPromise
assert.equal(closed, true)
})
test('cleanupFailedConnection closes in-process server and transport', async () => {
let inProcessClosed = false
let transportClosed = false
const inProcessServer = {
close: async () => {
inProcessClosed = true
},
}
const transport = {
close: async () => {
transportClosed = true
},
}
await cleanupFailedConnection(transport, inProcessServer)
assert.equal(inProcessClosed, true)
assert.equal(transportClosed, true)
})

View File

@@ -560,6 +560,22 @@ function getRemoteMcpServerConnectionBatchSize(): number {
)
}
type InProcessMcpServer = {
connect(t: Transport): Promise<void>
close(): Promise<void>
}
export async function cleanupFailedConnection(
transport: Pick<Transport, 'close'>,
inProcessServer?: Pick<InProcessMcpServer, 'close'>,
): Promise<void> {
if (inProcessServer) {
await inProcessServer.close().catch(() => {})
}
await transport.close().catch(() => {})
}
function isLocalMcpServer(config: ScopedMcpServerConfig): boolean {
return !config.type || config.type === 'stdio' || config.type === 'sdk'
}
@@ -606,9 +622,7 @@ export const connectToServer = memoize(
},
): Promise<MCPServerConnection> => {
const connectStartTime = Date.now()
let inProcessServer:
| { connect(t: Transport): Promise<void>; close(): Promise<void> }
| undefined
let inProcessServer: InProcessMcpServer | undefined
try {
let transport
@@ -1145,9 +1159,10 @@ export const connectToServer = memoize(
})
}
if (inProcessServer) {
inProcessServer.close().catch(() => { })
await cleanupFailedConnection(transport, inProcessServer)
} else {
await cleanupFailedConnection(transport)
}
transport.close().catch(() => { })
if (stderrOutput) {
logMCPError(name, `Server stderr: ${stderrOutput}`)
}

View File

@@ -0,0 +1,540 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { ValidationError } from '../../utils/settings/validation.js'
import {
buildEmptyDoctorReport,
doctorAllServers,
doctorServer,
findingsFromValidationErrors,
type McpDoctorDependencies,
} from './doctor.js'
function stdioConfig(scope: 'local' | 'project' | 'user' | 'enterprise', command: string) {
return {
type: 'stdio' as const,
command,
args: [],
scope,
}
}
function makeDependencies(overrides: Partial<McpDoctorDependencies> = {}): McpDoctorDependencies {
return {
getAllMcpConfigs: async () => ({ servers: {}, errors: [] }),
getMcpConfigsByScope: () => ({ servers: {}, errors: [] }),
getProjectMcpServerStatus: () => 'approved',
isMcpServerDisabled: () => false,
describeMcpConfigFilePath: scope => `scope://${scope}`,
clearServerCache: async () => {},
connectToServer: async (name, config) => ({
name,
type: 'connected',
capabilities: {},
config,
cleanup: async () => {},
}),
...overrides,
}
}
test('buildEmptyDoctorReport returns zeroed summary', () => {
const report = buildEmptyDoctorReport({
configOnly: true,
scopeFilter: 'project',
targetName: 'filesystem',
})
assert.equal(report.targetName, 'filesystem')
assert.equal(report.scopeFilter, 'project')
assert.equal(report.configOnly, true)
assert.deepEqual(report.summary, {
totalReports: 0,
healthy: 0,
warnings: 0,
blocking: 0,
})
assert.deepEqual(report.findings, [])
assert.deepEqual(report.servers, [])
})
test('findingsFromValidationErrors maps missing env warnings into doctor findings', () => {
const validationErrors: ValidationError[] = [
{
file: '.mcp.json',
path: 'mcpServers.filesystem',
message: 'Missing environment variables: API_KEY, API_URL',
suggestion: 'Set the following environment variables: API_KEY, API_URL',
mcpErrorMetadata: {
scope: 'project',
serverName: 'filesystem',
severity: 'warning',
},
},
]
const findings = findingsFromValidationErrors(validationErrors)
assert.equal(findings.length, 1)
assert.deepEqual(findings[0], {
blocking: false,
code: 'config.missing_env_vars',
message: 'Missing environment variables: API_KEY, API_URL',
remediation: 'Set the following environment variables: API_KEY, API_URL',
scope: 'project',
serverName: 'filesystem',
severity: 'warn',
sourcePath: '.mcp.json',
})
})
test('findingsFromValidationErrors maps Windows npx warnings into doctor findings', () => {
const validationErrors: ValidationError[] = [
{
file: '.mcp.json',
path: 'mcpServers.node-tools',
message: "Windows requires 'cmd /c' wrapper to execute npx",
suggestion:
'Change command to "cmd" with args ["/c", "npx", ...]. See: https://code.claude.com/docs/en/mcp#configure-mcp-servers',
mcpErrorMetadata: {
scope: 'project',
serverName: 'node-tools',
severity: 'warning',
},
},
]
const findings = findingsFromValidationErrors(validationErrors)
assert.equal(findings.length, 1)
assert.equal(findings[0]?.code, 'config.windows_npx_wrapper_required')
assert.equal(findings[0]?.serverName, 'node-tools')
assert.equal(findings[0]?.severity, 'warn')
assert.equal(findings[0]?.blocking, false)
})
test('findingsFromValidationErrors maps fatal parse errors into blocking findings', () => {
const validationErrors: ValidationError[] = [
{
file: 'C:/repo/.mcp.json',
path: '',
message: 'MCP config is not a valid JSON',
suggestion: 'Fix the JSON syntax errors in the file',
mcpErrorMetadata: {
scope: 'project',
severity: 'fatal',
},
},
]
const findings = findingsFromValidationErrors(validationErrors)
assert.equal(findings.length, 1)
assert.equal(findings[0]?.code, 'config.invalid_json')
assert.equal(findings[0]?.severity, 'error')
assert.equal(findings[0]?.blocking, true)
})
test('doctorAllServers reports global validation findings once without duplicating them into every server', async () => {
const localConfig = stdioConfig('local', 'node-local')
const deps = makeDependencies({
getAllMcpConfigs: async () => ({
servers: { filesystem: localConfig },
errors: [],
}),
getMcpConfigsByScope: scope =>
scope === 'project'
? {
servers: {},
errors: [
{
file: '.mcp.json',
path: '',
message: 'MCP config is not a valid JSON',
suggestion: 'Fix the JSON syntax errors in the file',
mcpErrorMetadata: {
scope: 'project',
severity: 'fatal',
},
},
],
}
: scope === 'local'
? { servers: { filesystem: localConfig }, errors: [] }
: { servers: {}, errors: [] },
})
const report = await doctorAllServers({ configOnly: true }, deps)
assert.equal(report.summary.totalReports, 1)
assert.equal(report.summary.blocking, 1)
assert.equal(report.findings.length, 1)
assert.equal(report.findings[0]?.code, 'config.invalid_json')
assert.deepEqual(report.servers[0]?.findings, [])
})
test('doctorServer explains same-name shadowing across scopes', async () => {
const localConfig = stdioConfig('local', 'node-local')
const userConfig = stdioConfig('user', 'node-user')
const deps = makeDependencies({
getAllMcpConfigs: async () => ({
servers: {
filesystem: localConfig,
},
errors: [],
}),
getMcpConfigsByScope: scope => {
switch (scope) {
case 'local':
return { servers: { filesystem: localConfig }, errors: [] }
case 'user':
return { servers: { filesystem: userConfig }, errors: [] }
default:
return { servers: {}, errors: [] }
}
},
})
const report = await doctorServer('filesystem', { configOnly: true }, deps)
assert.equal(report.servers.length, 1)
assert.equal(report.servers[0]?.definitions.length, 2)
assert.equal(report.servers[0]?.definitions.find(def => def.sourceType === 'local')?.runtimeActive, true)
assert.equal(report.servers[0]?.definitions.find(def => def.sourceType === 'user')?.runtimeActive, false)
assert.deepEqual(
report.servers[0]?.findings.map(finding => finding.code).sort(),
['duplicate.same_name_multiple_scopes', 'scope.shadowed'],
)
})
test('doctorServer reports project servers pending approval', async () => {
const projectConfig = stdioConfig('project', 'node-project')
const deps = makeDependencies({
getMcpConfigsByScope: scope =>
scope === 'project'
? { servers: { sentry: projectConfig }, errors: [] }
: { servers: {}, errors: [] },
getProjectMcpServerStatus: name => (name === 'sentry' ? 'pending' : 'approved'),
})
const report = await doctorServer('sentry', { configOnly: true }, deps)
assert.equal(report.servers.length, 1)
assert.equal(report.servers[0]?.definitions[0]?.pendingApproval, true)
assert.equal(report.servers[0]?.definitions[0]?.runtimeActive, false)
assert.equal(report.servers[0]?.definitions[0]?.runtimeVisible, false)
assert.equal(
report.servers[0]?.findings.some(finding => finding.code === 'state.pending_project_approval'),
true,
)
})
test('doctorServer does not treat disabled servers as runtime-active or live-check targets', async () => {
let connectCalls = 0
const localConfig = stdioConfig('local', 'node-local')
const deps = makeDependencies({
getAllMcpConfigs: async () => ({
servers: { github: localConfig },
errors: [],
}),
getMcpConfigsByScope: scope =>
scope === 'local'
? { servers: { github: localConfig }, errors: [] }
: { servers: {}, errors: [] },
isMcpServerDisabled: name => name === 'github',
connectToServer: async (name, config) => {
connectCalls += 1
return {
name,
type: 'failed',
config,
error: 'should not connect',
}
},
})
const report = await doctorServer('github', { configOnly: false }, deps)
assert.equal(connectCalls, 0)
assert.equal(report.summary.blocking, 0)
assert.equal(report.summary.warnings, 1)
assert.equal(report.servers[0]?.definitions[0]?.disabled, true)
assert.equal(report.servers[0]?.definitions[0]?.runtimeActive, false)
assert.equal(report.servers[0]?.definitions[0]?.runtimeVisible, false)
assert.equal(report.servers[0]?.liveCheck.result, 'disabled')
assert.equal(
report.servers[0]?.findings.some(finding => finding.code === 'state.disabled' && finding.severity === 'warn'),
true,
)
})
test('doctorAllServers skips live checks in config-only mode', async () => {
let connectCalls = 0
const localConfig = stdioConfig('local', 'node-local')
const deps = makeDependencies({
getAllMcpConfigs: async () => ({
servers: { linear: localConfig },
errors: [],
}),
getMcpConfigsByScope: scope =>
scope === 'local'
? { servers: { linear: localConfig }, errors: [] }
: { servers: {}, errors: [] },
connectToServer: async (name, config) => {
connectCalls += 1
return {
name,
type: 'connected',
capabilities: {},
config,
cleanup: async () => {},
}
},
})
const report = await doctorAllServers({ configOnly: true }, deps)
assert.equal(connectCalls, 0)
assert.equal(report.servers[0]?.liveCheck.attempted, false)
assert.equal(report.servers[0]?.liveCheck.result, 'skipped')
})
test('doctorAllServers honors scopeFilter when collecting names', async () => {
const pluginConfig = {
type: 'http' as const,
url: 'https://example.test/mcp',
scope: 'dynamic' as const,
pluginSource: 'plugin:github@official',
}
const deps = makeDependencies({
getAllMcpConfigs: async () => ({
servers: { 'plugin:github:github': pluginConfig },
errors: [],
}),
})
const report = await doctorAllServers({ configOnly: true, scopeFilter: 'user' }, deps)
assert.equal(report.summary.totalReports, 0)
assert.deepEqual(report.servers, [])
})
test('doctorAllServers honors scopeFilter when collecting validation errors', async () => {
const userConfig = stdioConfig('user', 'node-user')
const deps = makeDependencies({
getAllMcpConfigs: async () => ({
servers: { filesystem: userConfig },
errors: [],
}),
getMcpConfigsByScope: scope => {
switch (scope) {
case 'project':
return {
servers: {},
errors: [
{
file: '.mcp.json',
path: '',
message: 'MCP config is not a valid JSON',
suggestion: 'Fix the JSON syntax errors in the file',
mcpErrorMetadata: {
scope: 'project',
severity: 'fatal',
},
},
],
}
case 'user':
return { servers: { filesystem: userConfig }, errors: [] }
default:
return { servers: {}, errors: [] }
}
},
})
const report = await doctorAllServers({ configOnly: true, scopeFilter: 'user' }, deps)
assert.equal(report.summary.totalReports, 1)
assert.equal(report.summary.blocking, 0)
assert.deepEqual(report.findings, [])
assert.deepEqual(report.servers[0]?.findings, [])
})
test('doctorAllServers includes observed runtime definitions for plugin-only servers', async () => {
const pluginConfig = {
type: 'http' as const,
url: 'https://example.test/mcp',
scope: 'dynamic' as const,
pluginSource: 'plugin:github@official',
}
const deps = makeDependencies({
getAllMcpConfigs: async () => ({
servers: { 'plugin:github:github': pluginConfig },
errors: [],
}),
})
const report = await doctorAllServers({ configOnly: true }, deps)
assert.equal(report.summary.totalReports, 1)
assert.equal(report.servers[0]?.definitions.length, 1)
assert.equal(report.servers[0]?.definitions[0]?.sourceType, 'plugin')
assert.equal(report.servers[0]?.definitions[0]?.runtimeActive, true)
})
test('doctorAllServers reports disabled plugin servers as disabled, not not-found', async () => {
const pluginConfig = {
type: 'http' as const,
url: 'https://example.test/mcp',
scope: 'dynamic' as const,
pluginSource: 'plugin:github@official',
}
const deps = makeDependencies({
getAllMcpConfigs: async () => ({
servers: { 'plugin:github:github': pluginConfig },
errors: [],
}),
isMcpServerDisabled: name => name === 'plugin:github:github',
})
const report = await doctorAllServers({ configOnly: true }, deps)
assert.equal(report.summary.totalReports, 1)
assert.equal(report.summary.warnings, 1)
assert.equal(report.summary.blocking, 0)
assert.equal(report.servers[0]?.definitions.length, 1)
assert.equal(report.servers[0]?.definitions[0]?.sourceType, 'plugin')
assert.equal(report.servers[0]?.definitions[0]?.disabled, true)
assert.equal(report.servers[0]?.definitions[0]?.runtimeActive, false)
assert.equal(
report.servers[0]?.findings.some(finding => finding.code === 'state.disabled' && !finding.blocking),
true,
)
assert.equal(
report.servers[0]?.findings.some(finding => finding.code === 'state.not_found'),
false,
)
})
test('doctorServer converts failed live checks into blocking findings', async () => {
const localConfig = stdioConfig('local', 'node-local')
const deps = makeDependencies({
getAllMcpConfigs: async () => ({
servers: { github: localConfig },
errors: [],
}),
getMcpConfigsByScope: scope =>
scope === 'local'
? { servers: { github: localConfig }, errors: [] }
: { servers: {}, errors: [] },
connectToServer: async (name, config) => ({
name,
type: 'failed',
config,
error: 'command not found: node-local',
}),
})
const report = await doctorServer('github', { configOnly: false }, deps)
assert.equal(report.summary.blocking, 1)
assert.equal(report.servers[0]?.liveCheck.result, 'failed')
assert.equal(
report.servers[0]?.findings.some(
finding => finding.code === 'stdio.command_not_found' && finding.blocking,
),
true,
)
})
test('doctorServer converts needs-auth live checks into warning findings', async () => {
const localConfig = stdioConfig('local', 'node-local')
const deps = makeDependencies({
getAllMcpConfigs: async () => ({
servers: { sentry: localConfig },
errors: [],
}),
getMcpConfigsByScope: scope =>
scope === 'local'
? { servers: { sentry: localConfig }, errors: [] }
: { servers: {}, errors: [] },
connectToServer: async (name, config) => ({
name,
type: 'needs-auth',
config,
}),
})
const report = await doctorServer('sentry', { configOnly: false }, deps)
assert.equal(report.summary.warnings, 1)
assert.equal(report.summary.blocking, 0)
assert.equal(
report.servers[0]?.findings.some(finding => finding.code === 'auth.needs_auth' && finding.severity === 'warn'),
true,
)
})
test('doctorServer includes observed runtime definition for plugin-only targets', async () => {
const pluginConfig = {
type: 'http' as const,
url: 'https://example.test/mcp',
scope: 'dynamic' as const,
pluginSource: 'plugin:github@official',
}
const deps = makeDependencies({
getAllMcpConfigs: async () => ({
servers: { 'plugin:github:github': pluginConfig },
errors: [],
}),
})
const report = await doctorServer('plugin:github:github', { configOnly: true }, deps)
assert.equal(report.summary.totalReports, 1)
assert.equal(report.servers[0]?.definitions.length, 1)
assert.equal(report.servers[0]?.definitions[0]?.sourceType, 'plugin')
assert.equal(report.servers[0]?.definitions[0]?.runtimeActive, true)
})
test('doctorServer with scopeFilter does not leak runtime definition from another scope when target is absent', async () => {
let connectCalls = 0
const localConfig = stdioConfig('local', 'node-local')
const deps = makeDependencies({
getAllMcpConfigs: async () => ({
servers: { github: localConfig },
errors: [],
}),
getMcpConfigsByScope: scope =>
scope === 'local'
? { servers: { github: localConfig }, errors: [] }
: { servers: {}, errors: [] },
connectToServer: async (name, config) => {
connectCalls += 1
return {
name,
type: 'connected',
capabilities: {},
config,
cleanup: async () => {},
}
},
})
const report = await doctorServer('github', { configOnly: false, scopeFilter: 'user' }, deps)
assert.equal(connectCalls, 0)
assert.equal(report.summary.totalReports, 1)
assert.equal(report.summary.blocking, 1)
assert.deepEqual(report.servers[0]?.definitions, [])
assert.equal(report.servers[0]?.liveCheck.result, 'skipped')
assert.equal(
report.servers[0]?.findings.some(finding => finding.code === 'state.not_found' && finding.blocking),
true,
)
})
test('doctorServer reports blocking not-found state when no definition exists', async () => {
const report = await doctorServer('missing-server', { configOnly: true }, makeDependencies())
assert.equal(report.summary.blocking, 1)
assert.equal(report.servers[0]?.findings.some(finding => finding.code === 'state.not_found' && finding.blocking), true)
})

695
src/services/mcp/doctor.ts Normal file
View File

@@ -0,0 +1,695 @@
import type { ValidationError } from '../../utils/settings/validation.js'
import { clearServerCache, connectToServer } from './client.js'
import {
getAllMcpConfigs,
getMcpConfigsByScope,
isMcpServerDisabled,
} from './config.js'
import type {
ConfigScope,
ScopedMcpServerConfig,
} from './types.js'
import { describeMcpConfigFilePath, getProjectMcpServerStatus } from './utils.js'
export type McpDoctorSeverity = 'info' | 'warn' | 'error'
export type McpDoctorScopeFilter = 'local' | 'project' | 'user' | 'enterprise'
export type McpDoctorFinding = {
blocking: boolean
code: string
message: string
remediation?: string
scope?: string
serverName?: string
severity: McpDoctorSeverity
sourcePath?: string
}
export type McpDoctorLiveCheck = {
attempted: boolean
durationMs?: number
error?: string
result?: 'connected' | 'needs-auth' | 'failed' | 'pending' | 'disabled' | 'skipped'
}
export type McpDoctorDefinition = {
name: string
sourceType:
| 'local'
| 'project'
| 'user'
| 'enterprise'
| 'managed'
| 'plugin'
| 'claudeai'
| 'dynamic'
| 'internal'
sourcePath?: string
transport?: string
runtimeVisible: boolean
runtimeActive: boolean
pendingApproval?: boolean
disabled?: boolean
}
export type McpDoctorServerReport = {
serverName: string
requestedByUser: boolean
definitions: McpDoctorDefinition[]
liveCheck: McpDoctorLiveCheck
findings: McpDoctorFinding[]
}
export type McpDoctorDependencies = {
getAllMcpConfigs: typeof getAllMcpConfigs
getMcpConfigsByScope: typeof getMcpConfigsByScope
getProjectMcpServerStatus: typeof getProjectMcpServerStatus
isMcpServerDisabled: typeof isMcpServerDisabled
describeMcpConfigFilePath: typeof describeMcpConfigFilePath
connectToServer: typeof connectToServer
clearServerCache: typeof clearServerCache
}
export type McpDoctorReport = {
generatedAt: string
targetName?: string
scopeFilter?: McpDoctorScopeFilter
configOnly: boolean
summary: {
totalReports: number
healthy: number
warnings: number
blocking: number
}
findings: McpDoctorFinding[]
servers: McpDoctorServerReport[]
}
const DEFAULT_DEPENDENCIES: McpDoctorDependencies = {
getAllMcpConfigs,
getMcpConfigsByScope,
getProjectMcpServerStatus,
isMcpServerDisabled,
describeMcpConfigFilePath,
connectToServer,
clearServerCache,
}
export function buildEmptyDoctorReport(options: {
configOnly: boolean
scopeFilter?: McpDoctorScopeFilter
targetName?: string
}): McpDoctorReport {
return {
generatedAt: new Date().toISOString(),
targetName: options.targetName,
scopeFilter: options.scopeFilter,
configOnly: options.configOnly,
summary: {
totalReports: 0,
healthy: 0,
warnings: 0,
blocking: 0,
},
findings: [],
servers: [],
}
}
function getFindingCode(error: ValidationError): string {
if (error.message === 'MCP config is not a valid JSON') {
return 'config.invalid_json'
}
if (error.message.startsWith('Missing environment variables:')) {
return 'config.missing_env_vars'
}
if (error.message.includes("Windows requires 'cmd /c' wrapper to execute npx")) {
return 'config.windows_npx_wrapper_required'
}
if (error.message === 'Does not adhere to MCP server configuration schema') {
return 'config.invalid_schema'
}
return 'config.validation_error'
}
function getSeverity(error: ValidationError): McpDoctorSeverity {
const severity = error.mcpErrorMetadata?.severity
if (severity === 'fatal') {
return 'error'
}
if (severity === 'warning') {
return 'warn'
}
return 'warn'
}
export function findingsFromValidationErrors(
validationErrors: ValidationError[],
): McpDoctorFinding[] {
return validationErrors.map(error => {
const severity = getSeverity(error)
return {
blocking: severity === 'error',
code: getFindingCode(error),
message: error.message,
remediation: error.suggestion,
scope: error.mcpErrorMetadata?.scope,
serverName: error.mcpErrorMetadata?.serverName,
severity,
sourcePath: error.file,
}
})
}
function splitValidationFindings(validationFindings: McpDoctorFinding[]): {
globalFindings: McpDoctorFinding[]
serverFindingsByName: Map<string, McpDoctorFinding[]>
} {
const globalFindings: McpDoctorFinding[] = []
const serverFindingsByName = new Map<string, McpDoctorFinding[]>()
for (const finding of validationFindings) {
if (!finding.serverName) {
globalFindings.push(finding)
continue
}
const findings = serverFindingsByName.get(finding.serverName) ?? []
findings.push(finding)
serverFindingsByName.set(finding.serverName, findings)
}
return {
globalFindings,
serverFindingsByName,
}
}
function getSourceType(config: ScopedMcpServerConfig): McpDoctorDefinition['sourceType'] {
if (config.scope === 'claudeai') {
return 'claudeai'
}
if (config.scope === 'dynamic') {
return config.pluginSource ? 'plugin' : 'dynamic'
}
if (config.scope === 'managed') {
return 'managed'
}
return config.scope
}
function getTransport(config: ScopedMcpServerConfig): string {
return config.type ?? 'stdio'
}
function getConfigSignature(config: ScopedMcpServerConfig): string {
switch (config.type) {
case 'sse':
case 'http':
case 'ws':
case 'claudeai-proxy':
return `${config.scope}:${config.type}:${config.url}`
case 'sdk':
return `${config.scope}:${config.type}:${config.name}`
default:
return `${config.scope}:${config.type ?? 'stdio'}:${config.command}:${JSON.stringify(config.args ?? [])}`
}
}
function isSameDefinition(
config: ScopedMcpServerConfig,
activeConfig: ScopedMcpServerConfig | undefined,
): boolean {
if (!activeConfig) {
return false
}
return getSourceType(config) === getSourceType(activeConfig) && getConfigSignature(config) === getConfigSignature(activeConfig)
}
function buildScopeDefinitions(
name: string,
scope: ConfigScope,
servers: Record<string, ScopedMcpServerConfig>,
activeConfig: ScopedMcpServerConfig | undefined,
deps: McpDoctorDependencies,
): McpDoctorDefinition[] {
const config = servers[name]
if (!config) {
return []
}
const pendingApproval =
scope === 'project' ? deps.getProjectMcpServerStatus(name) === 'pending' : false
const disabled = deps.isMcpServerDisabled(name)
const runtimeActive = !disabled && isSameDefinition(config, activeConfig)
return [
{
name,
sourceType: getSourceType(config),
sourcePath: deps.describeMcpConfigFilePath(scope),
transport: getTransport(config),
runtimeVisible: runtimeActive,
runtimeActive,
pendingApproval,
disabled,
},
]
}
function shouldIncludeScope(
scope: ConfigScope,
scopeFilter: McpDoctorScopeFilter | undefined,
): boolean {
if (!scopeFilter) {
return scope === 'enterprise' || scope === 'local' || scope === 'project' || scope === 'user'
}
return scope === scopeFilter
}
function getValidationErrorsForSelectedScopes(
scopeResults: {
enterprise: ReturnType<McpDoctorDependencies['getMcpConfigsByScope']>
local: ReturnType<McpDoctorDependencies['getMcpConfigsByScope']>
project: ReturnType<McpDoctorDependencies['getMcpConfigsByScope']>
user: ReturnType<McpDoctorDependencies['getMcpConfigsByScope']>
},
scopeFilter: McpDoctorScopeFilter | undefined,
): ValidationError[] {
return [
...(shouldIncludeScope('enterprise', scopeFilter) ? scopeResults.enterprise.errors : []),
...(shouldIncludeScope('local', scopeFilter) ? scopeResults.local.errors : []),
...(shouldIncludeScope('project', scopeFilter) ? scopeResults.project.errors : []),
...(shouldIncludeScope('user', scopeFilter) ? scopeResults.user.errors : []),
]
}
function buildObservedDefinition(
name: string,
activeConfig: ScopedMcpServerConfig,
options?: {
disabled?: boolean
runtimeActive?: boolean
runtimeVisible?: boolean
},
): McpDoctorDefinition {
return {
name,
sourceType: getSourceType(activeConfig),
sourcePath:
getSourceType(activeConfig) === 'plugin'
? `plugin:${activeConfig.pluginSource ?? 'unknown'}`
: getSourceType(activeConfig) === 'claudeai'
? 'claude.ai'
: activeConfig.scope,
transport: getTransport(activeConfig),
runtimeVisible: options?.runtimeVisible ?? true,
runtimeActive: options?.runtimeActive ?? true,
disabled: options?.disabled ?? false,
}
}
function hasDefinitionForRuntimeSource(
definitions: McpDoctorDefinition[],
runtimeConfig: ScopedMcpServerConfig,
deps: McpDoctorDependencies,
): boolean {
const runtimeSourceType = getSourceType(runtimeConfig)
const runtimeSourcePath =
runtimeSourceType === 'plugin'
? `plugin:${runtimeConfig.pluginSource ?? 'unknown'}`
: runtimeSourceType === 'claudeai'
? 'claude.ai'
: deps.describeMcpConfigFilePath(runtimeConfig.scope)
return definitions.some(
definition =>
definition.sourceType === runtimeSourceType &&
definition.sourcePath === runtimeSourcePath &&
definition.transport === getTransport(runtimeConfig),
)
}
function buildShadowingFindings(definitions: McpDoctorDefinition[]): McpDoctorFinding[] {
const userEditable = definitions.filter(definition =>
definition.sourceType === 'local' ||
definition.sourceType === 'project' ||
definition.sourceType === 'user' ||
definition.sourceType === 'enterprise',
)
if (userEditable.length <= 1) {
return []
}
const active = userEditable.find(definition => definition.runtimeActive) ?? userEditable[0]
return [
{
blocking: false,
code: 'duplicate.same_name_multiple_scopes',
message: `Server is defined in multiple config scopes; active source is ${active.sourceType}`,
remediation: 'Remove or rename one of the duplicate definitions to avoid confusion.',
serverName: active.name,
severity: 'warn',
},
{
blocking: false,
code: 'scope.shadowed',
message: `${active.name} has shadowed definitions in lower-precedence config scopes.`,
remediation: 'Inspect the other definitions and remove the ones you no longer want to keep.',
serverName: active.name,
severity: 'warn',
},
]
}
function buildStateFindings(definitions: McpDoctorDefinition[]): McpDoctorFinding[] {
const findings: McpDoctorFinding[] = []
for (const definition of definitions) {
if (definition.pendingApproval) {
findings.push({
blocking: false,
code: 'state.pending_project_approval',
message: `${definition.name} is declared in project config but pending project approval.`,
remediation: 'Approve the server in the project MCP approval flow before expecting it to become active.',
scope: 'project',
serverName: definition.name,
severity: 'warn',
sourcePath: definition.sourcePath,
})
}
if (definition.disabled) {
findings.push({
blocking: false,
code: 'state.disabled',
message: `${definition.name} is currently disabled.`,
remediation: 'Re-enable the server before expecting it to be available at runtime.',
serverName: definition.name,
severity: 'warn',
sourcePath: definition.sourcePath,
})
}
}
return findings
}
function summarizeReport(report: McpDoctorReport): McpDoctorReport {
const allFindings = [...report.findings, ...report.servers.flatMap(server => server.findings)]
const blocking = allFindings.filter(finding => finding.blocking).length
const warnings = allFindings.filter(finding => finding.severity === 'warn').length
const healthy = report.servers.filter(
server =>
server.liveCheck.result === 'connected' &&
server.findings.every(finding => !finding.blocking && finding.severity !== 'warn'),
).length
return {
...report,
summary: {
totalReports: report.servers.length,
healthy,
warnings,
blocking,
},
}
}
async function getLiveCheck(
name: string,
activeConfig: ScopedMcpServerConfig | undefined,
configOnly: boolean,
definitions: McpDoctorDefinition[],
deps: McpDoctorDependencies,
): Promise<McpDoctorLiveCheck> {
if (configOnly) {
return { attempted: false, result: 'skipped' }
}
if (!activeConfig) {
if (definitions.some(definition => definition.pendingApproval)) {
return { attempted: false, result: 'pending' }
}
if (definitions.some(definition => definition.disabled)) {
return { attempted: false, result: 'disabled' }
}
return { attempted: false, result: 'skipped' }
}
const startedAt = Date.now()
const connection = await deps.connectToServer(name, activeConfig)
const durationMs = Date.now() - startedAt
try {
switch (connection.type) {
case 'connected':
return { attempted: true, result: 'connected', durationMs }
case 'needs-auth':
return { attempted: true, result: 'needs-auth', durationMs }
case 'disabled':
return { attempted: true, result: 'disabled', durationMs }
case 'pending':
return { attempted: true, result: 'pending', durationMs }
case 'failed':
return {
attempted: true,
result: 'failed',
durationMs,
error: connection.error,
}
}
} finally {
await deps.clearServerCache(name, activeConfig).catch(() => {
// Best-effort cleanup for diagnostic connections.
})
}
}
function buildLiveFindings(
name: string,
definitions: McpDoctorDefinition[],
liveCheck: McpDoctorLiveCheck,
): McpDoctorFinding[] {
const activeDefinition = definitions.find(definition => definition.runtimeActive)
if (liveCheck.result === 'needs-auth') {
return [
{
blocking: false,
code: 'auth.needs_auth',
message: `${name} requires authentication before it can be used.`,
remediation: 'Authenticate the server and then rerun the doctor command.',
serverName: name,
severity: 'warn',
sourcePath: activeDefinition?.sourcePath,
},
]
}
if (liveCheck.result === 'failed') {
const commandNotFound =
activeDefinition?.transport === 'stdio' &&
typeof liveCheck.error === 'string' &&
liveCheck.error.toLowerCase().includes('not found')
return [
{
blocking: true,
code: commandNotFound ? 'stdio.command_not_found' : 'health.failed',
message: liveCheck.error
? `${name} failed its live health check: ${liveCheck.error}`
: `${name} failed its live health check.`,
remediation: commandNotFound
? 'Verify the configured executable exists on PATH or use a full executable path.'
: 'Inspect the server configuration and retry the connection once the underlying problem is fixed.',
serverName: name,
severity: 'error',
sourcePath: activeDefinition?.sourcePath,
},
]
}
return []
}
async function buildServerReport(
name: string,
options: {
configOnly: boolean
requestedByUser: boolean
scopeFilter?: McpDoctorScopeFilter
},
validationFindingsByName: Map<string, McpDoctorFinding[]>,
deps: McpDoctorDependencies,
): Promise<McpDoctorServerReport> {
const scopeResults = {
enterprise: deps.getMcpConfigsByScope('enterprise'),
local: deps.getMcpConfigsByScope('local'),
project: deps.getMcpConfigsByScope('project'),
user: deps.getMcpConfigsByScope('user'),
}
const { servers: activeServers } = await deps.getAllMcpConfigs()
const serverDisabled = deps.isMcpServerDisabled(name)
const runtimeConfig = activeServers[name] ?? undefined
const activeConfig = serverDisabled ? undefined : runtimeConfig
const definitions = [
...(shouldIncludeScope('enterprise', options.scopeFilter)
? buildScopeDefinitions(name, 'enterprise', scopeResults.enterprise.servers, activeConfig, deps)
: []),
...(shouldIncludeScope('local', options.scopeFilter)
? buildScopeDefinitions(name, 'local', scopeResults.local.servers, activeConfig, deps)
: []),
...(shouldIncludeScope('project', options.scopeFilter)
? buildScopeDefinitions(name, 'project', scopeResults.project.servers, activeConfig, deps)
: []),
...(shouldIncludeScope('user', options.scopeFilter)
? buildScopeDefinitions(name, 'user', scopeResults.user.servers, activeConfig, deps)
: []),
]
const shouldAddObservedDefinition =
!!runtimeConfig &&
!hasDefinitionForRuntimeSource(definitions, runtimeConfig, deps) &&
((definitions.length === 0 && !options.scopeFilter) ||
(definitions.length > 0 && definitions.every(definition => !definition.runtimeActive)))
if (runtimeConfig && shouldAddObservedDefinition) {
definitions.push(
buildObservedDefinition(name, runtimeConfig, {
disabled: serverDisabled,
runtimeActive: !serverDisabled,
runtimeVisible: !serverDisabled,
}),
)
}
const visibleRuntimeConfig =
definitions.some(definition => definition.runtimeActive) || shouldAddObservedDefinition
? activeConfig
: undefined
const findings: McpDoctorFinding[] = [
...(validationFindingsByName.get(name) ?? []),
...buildShadowingFindings(definitions),
...buildStateFindings(definitions),
]
if (definitions.length === 0 && !shouldAddObservedDefinition) {
findings.push({
blocking: true,
code: 'state.not_found',
message: `${name} was not found in the selected MCP configuration sources.`,
remediation: 'Check the server name and scope, or add the MCP server before retrying.',
serverName: name,
severity: 'error',
})
}
const liveCheck = await getLiveCheck(name, visibleRuntimeConfig, options.configOnly, definitions, deps)
findings.push(...buildLiveFindings(name, definitions, liveCheck))
return {
serverName: name,
requestedByUser: options.requestedByUser,
definitions,
liveCheck,
findings,
}
}
function getServerNames(
scopeServers: Array<Record<string, ScopedMcpServerConfig>>,
activeServers: Record<string, ScopedMcpServerConfig>,
includeActiveServers: boolean,
): string[] {
const names = new Set<string>(includeActiveServers ? Object.keys(activeServers) : [])
for (const servers of scopeServers) {
for (const name of Object.keys(servers)) {
names.add(name)
}
}
return [...names].sort()
}
export async function doctorAllServers(
options: { configOnly: boolean; scopeFilter?: McpDoctorScopeFilter } = {
configOnly: false,
},
deps: McpDoctorDependencies = DEFAULT_DEPENDENCIES,
): Promise<McpDoctorReport> {
const report = buildEmptyDoctorReport(options)
const scopeResults = {
enterprise: deps.getMcpConfigsByScope('enterprise'),
local: deps.getMcpConfigsByScope('local'),
project: deps.getMcpConfigsByScope('project'),
user: deps.getMcpConfigsByScope('user'),
}
const validationFindings = findingsFromValidationErrors(
getValidationErrorsForSelectedScopes(scopeResults, options.scopeFilter),
)
const { globalFindings, serverFindingsByName } = splitValidationFindings(validationFindings)
const { servers: activeServers } = await deps.getAllMcpConfigs()
const names = getServerNames(
[
...(shouldIncludeScope('enterprise', options.scopeFilter) ? [scopeResults.enterprise.servers] : []),
...(shouldIncludeScope('local', options.scopeFilter) ? [scopeResults.local.servers] : []),
...(shouldIncludeScope('project', options.scopeFilter) ? [scopeResults.project.servers] : []),
...(shouldIncludeScope('user', options.scopeFilter) ? [scopeResults.user.servers] : []),
],
activeServers,
!options.scopeFilter,
)
const servers = await Promise.all(
names.map(name =>
buildServerReport(
name,
{
configOnly: options.configOnly,
requestedByUser: false,
scopeFilter: options.scopeFilter,
},
serverFindingsByName,
deps,
),
),
)
report.servers = servers
report.findings = globalFindings
return summarizeReport(report)
}
export async function doctorServer(
name: string,
options: { configOnly: boolean; scopeFilter?: McpDoctorScopeFilter },
deps: McpDoctorDependencies = DEFAULT_DEPENDENCIES,
): Promise<McpDoctorReport> {
const report = buildEmptyDoctorReport({ ...options, targetName: name })
const scopeResults = {
enterprise: deps.getMcpConfigsByScope('enterprise'),
local: deps.getMcpConfigsByScope('local'),
project: deps.getMcpConfigsByScope('project'),
user: deps.getMcpConfigsByScope('user'),
}
const validationFindings = findingsFromValidationErrors(
getValidationErrorsForSelectedScopes(scopeResults, options.scopeFilter),
)
const { globalFindings, serverFindingsByName } = splitValidationFindings(validationFindings)
const server = await buildServerReport(
name,
{
configOnly: options.configOnly,
requestedByUser: true,
scopeFilter: options.scopeFilter,
},
serverFindingsByName,
deps,
)
report.servers = [server]
report.findings = globalFindings
return summarizeReport(report)
}