* fix: OAuth tokens secure storage for Windows & Linux * fix(mcp): MCP Tool Re-exposure & Strict Input Validation Fixes the MCP re-exposure bug by correctly handling tool deduplication, input validation with Ajv, and structured output (including images). Also disables experimental API betas by default to prevent 500 errors on external accounts. * fix(mcp): skip official registry prefetch in non-first-party mode Prevents unnecessary calls to Anthropic's MCP registry when using other API providers. * fix(cli): disable experimental API betas by default This prevents 500 errors from Anthropic's API when tool-calling with non-Anthropic accounts or models that don't support certain beta features. * fix: issues raised in the PR review for #675
268 lines
8.8 KiB
TypeScript
268 lines
8.8 KiB
TypeScript
// OpenClaude: disable experimental API betas by default.
|
|
// Tool search (defer_loading), global cache scope, and context management
|
|
// require internal API support not available to external accounts → 500.
|
|
// Users can opt-in with CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=false.
|
|
// eslint-disable-next-line custom-rules/no-top-level-side-effects
|
|
process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS ??= 'true'
|
|
|
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
import { ZodError } from 'zod'
|
|
import {
|
|
CallToolRequestSchema,
|
|
type CallToolResult,
|
|
ListToolsRequestSchema,
|
|
type ListToolsResult,
|
|
type Tool,
|
|
} from '@modelcontextprotocol/sdk/types.js'
|
|
import { getDefaultAppState } from 'src/state/AppStateStore.js'
|
|
import review from '../commands/review.js'
|
|
import type { Command } from '../commands.js'
|
|
import { getMcpToolsCommandsAndResources } from '../services/mcp/client.js'
|
|
import type { MCPServerConnection } from '../services/mcp/types.js'
|
|
import {
|
|
findToolByName,
|
|
getEmptyToolPermissionContext,
|
|
type Tool as InternalTool,
|
|
type ToolUseContext,
|
|
} from '../Tool.js'
|
|
import { getTools } from '../tools.js'
|
|
import { createAbortController } from '../utils/abortController.js'
|
|
import { createFileStateCacheWithSizeLimit } from '../utils/fileStateCache.js'
|
|
import { logError } from '../utils/log.js'
|
|
import { createAssistantMessage } from '../utils/messages.js'
|
|
import { getMainLoopModel } from '../utils/model/model.js'
|
|
import { hasPermissionsToUseTool } from '../utils/permissions/permissions.js'
|
|
import { setCwd } from '../utils/Shell.js'
|
|
import { jsonStringify } from '../utils/slowOperations.js'
|
|
import { getErrorParts } from '../utils/toolErrors.js'
|
|
import { zodToJsonSchema } from '../utils/zodToJsonSchema.js'
|
|
|
|
type ToolInput = Tool['inputSchema']
|
|
type ToolOutput = Tool['outputSchema']
|
|
|
|
const MCP_COMMANDS: Command[] = [review]
|
|
|
|
export function getCombinedTools(
|
|
builtins: InternalTool[],
|
|
mcpTools: InternalTool[],
|
|
): InternalTool[] {
|
|
const mcpToolNames = new Set(mcpTools.map(t => t.name))
|
|
const deduplicatedBuiltins = builtins.filter(t => !mcpToolNames.has(t.name))
|
|
|
|
return [...mcpTools, ...deduplicatedBuiltins]
|
|
}
|
|
|
|
export async function loadReexposedMcpTools(): Promise<{
|
|
mcpClients: MCPServerConnection[]
|
|
mcpTools: InternalTool[]
|
|
}> {
|
|
const mcpClients: MCPServerConnection[] = []
|
|
const mcpTools: InternalTool[] = []
|
|
|
|
// Load configured MCP clients and their tools
|
|
await getMcpToolsCommandsAndResources(({ client, tools: clientTools }) => {
|
|
mcpClients.push(client)
|
|
mcpTools.push(...clientTools)
|
|
})
|
|
|
|
return { mcpClients, mcpTools }
|
|
}
|
|
|
|
export async function startMCPServer(
|
|
cwd: string,
|
|
debug: boolean,
|
|
verbose: boolean,
|
|
): Promise<void> {
|
|
// Use size-limited LRU cache for readFileState to prevent unbounded memory growth
|
|
// 100 files and 25MB limit should be sufficient for MCP server operations
|
|
const READ_FILE_STATE_CACHE_SIZE = 100
|
|
const readFileStateCache = createFileStateCacheWithSizeLimit(
|
|
READ_FILE_STATE_CACHE_SIZE,
|
|
)
|
|
setCwd(cwd)
|
|
const server = new Server(
|
|
{
|
|
name: 'claude/tengu',
|
|
version: MACRO.VERSION,
|
|
},
|
|
{
|
|
capabilities: {
|
|
tools: {},
|
|
},
|
|
},
|
|
)
|
|
|
|
const { mcpClients, mcpTools } = await loadReexposedMcpTools()
|
|
|
|
server.setRequestHandler(
|
|
ListToolsRequestSchema,
|
|
async (): Promise<ListToolsResult> => {
|
|
const toolPermissionContext = getEmptyToolPermissionContext()
|
|
const tools = getCombinedTools(getTools(toolPermissionContext), mcpTools)
|
|
return {
|
|
tools: await Promise.all(
|
|
tools.map(async tool => {
|
|
let outputSchema: ToolOutput | undefined
|
|
if (tool.outputSchema) {
|
|
const convertedSchema = zodToJsonSchema(tool.outputSchema)
|
|
// MCP SDK requires outputSchema to have type: "object" at root level
|
|
// Skip schemas with anyOf/oneOf at root (from z.union, z.discriminatedUnion, etc.)
|
|
// See: https://github.com/anthropics/claude-code/issues/8014
|
|
if (
|
|
typeof convertedSchema === 'object' &&
|
|
convertedSchema !== null &&
|
|
'type' in convertedSchema &&
|
|
convertedSchema.type === 'object'
|
|
) {
|
|
outputSchema = convertedSchema as ToolOutput
|
|
}
|
|
}
|
|
return {
|
|
...tool,
|
|
description: await tool.prompt({
|
|
getToolPermissionContext: async () => toolPermissionContext,
|
|
tools,
|
|
agents: [],
|
|
}),
|
|
inputSchema: (tool.inputJSONSchema ?? zodToJsonSchema(tool.inputSchema)) as ToolInput,
|
|
outputSchema,
|
|
}
|
|
}),
|
|
),
|
|
}
|
|
},
|
|
)
|
|
|
|
server.setRequestHandler(
|
|
CallToolRequestSchema,
|
|
async ({ params: { name, arguments: args } }): Promise<CallToolResult> => {
|
|
const toolPermissionContext = getEmptyToolPermissionContext()
|
|
const tools = getCombinedTools(getTools(toolPermissionContext), mcpTools)
|
|
const tool = findToolByName(tools, name)
|
|
if (!tool) {
|
|
throw new Error(`Tool ${name} not found`)
|
|
}
|
|
|
|
// Assume MCP servers do not read messages separately from the tool
|
|
// call arguments.
|
|
const toolUseContext: ToolUseContext = {
|
|
abortController: createAbortController(),
|
|
options: {
|
|
commands: MCP_COMMANDS,
|
|
tools,
|
|
mainLoopModel: getMainLoopModel(),
|
|
thinkingConfig: { type: 'disabled' },
|
|
mcpClients,
|
|
mcpResources: {},
|
|
isNonInteractiveSession: true,
|
|
debug,
|
|
verbose,
|
|
agentDefinitions: { activeAgents: [], allAgents: [] },
|
|
},
|
|
getAppState: () => getDefaultAppState(),
|
|
setAppState: () => {},
|
|
messages: [],
|
|
readFileState: readFileStateCache,
|
|
setInProgressToolUseIDs: () => {},
|
|
setResponseLength: () => {},
|
|
updateFileHistoryState: () => {},
|
|
updateAttributionState: () => {},
|
|
}
|
|
|
|
try {
|
|
if (!tool.isEnabled()) {
|
|
throw new Error(`Tool ${name} is not enabled`)
|
|
}
|
|
|
|
// Validate input types with zod
|
|
const parsedArgs = tool.inputSchema.parse(args ?? {})
|
|
|
|
const validationResult = await tool.validateInput?.(
|
|
(parsedArgs as never) ?? {},
|
|
toolUseContext,
|
|
)
|
|
if (validationResult && !validationResult.result) {
|
|
throw new Error(
|
|
`Tool ${name} input is invalid: ${validationResult.message}`,
|
|
)
|
|
}
|
|
const finalResult = await tool.call(
|
|
(parsedArgs ?? {}) as never,
|
|
toolUseContext,
|
|
hasPermissionsToUseTool,
|
|
createAssistantMessage({
|
|
content: [],
|
|
}),
|
|
)
|
|
|
|
let content: CallToolResult['content']
|
|
const data = finalResult.data as string | { type: string; text?: string; source?: { type: string; media_type: string; data: string } }[] | unknown
|
|
|
|
if (typeof data === 'string') {
|
|
content = [{ type: 'text', text: data }]
|
|
} else if (Array.isArray(data)) {
|
|
content = data.map((block: any) => {
|
|
if (block.type === 'text') {
|
|
return { type: 'text', text: block.text || '' }
|
|
} else if (block.type === 'image' && block.source) {
|
|
return {
|
|
type: 'image',
|
|
data: block.source.data,
|
|
mimeType: block.source.media_type,
|
|
}
|
|
} else {
|
|
// eslint-disable-next-line custom-rules/no-top-level-side-effects, no-console
|
|
console.warn(`Unmapped content block type from tool ${name}: ${block.type || 'unknown'}`)
|
|
return { type: 'text', text: jsonStringify(block) }
|
|
}
|
|
}) as CallToolResult['content']
|
|
} else {
|
|
content = [{ type: 'text', text: jsonStringify(data) }]
|
|
}
|
|
|
|
return {
|
|
content,
|
|
isError: !!(finalResult as any).isError,
|
|
}
|
|
} catch (error) {
|
|
logError(error)
|
|
|
|
if (error instanceof ZodError) {
|
|
return {
|
|
isError: true,
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: `Tool ${name} input is invalid:\n${error.errors.map(e => `- ${e.path.join('.')}: ${e.message}`).join('\n')}`,
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
const parts =
|
|
error instanceof Error ? getErrorParts(error) : [String(error)]
|
|
const errorText = parts.filter(Boolean).join('\n').trim() || 'Error'
|
|
|
|
return {
|
|
isError: true,
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: errorText,
|
|
},
|
|
],
|
|
}
|
|
}
|
|
},
|
|
)
|
|
|
|
async function runServer() {
|
|
const transport = new StdioServerTransport()
|
|
await server.connect(transport)
|
|
}
|
|
|
|
return await runServer()
|
|
}
|
|
|