/ entrypoints / mcp.ts
mcp.ts
  1  import { Server } from '@modelcontextprotocol/sdk/server/index.js'
  2  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
  3  import {
  4    CallToolRequestSchema,
  5    type CallToolResult,
  6    ListToolsRequestSchema,
  7    type ListToolsResult,
  8    type Tool,
  9  } from '@modelcontextprotocol/sdk/types.js'
 10  import { getDefaultAppState } from 'src/state/AppStateStore.js'
 11  import review from '../commands/review.js'
 12  import type { Command } from '../commands.js'
 13  import {
 14    findToolByName,
 15    getEmptyToolPermissionContext,
 16    type ToolUseContext,
 17  } from '../Tool.js'
 18  import { getTools } from '../tools.js'
 19  import { createAbortController } from '../utils/abortController.js'
 20  import { createFileStateCacheWithSizeLimit } from '../utils/fileStateCache.js'
 21  import { logError } from '../utils/log.js'
 22  import { createAssistantMessage } from '../utils/messages.js'
 23  import { getMainLoopModel } from '../utils/model/model.js'
 24  import { hasPermissionsToUseTool } from '../utils/permissions/permissions.js'
 25  import { setCwd } from '../utils/Shell.js'
 26  import { jsonStringify } from '../utils/slowOperations.js'
 27  import { getErrorParts } from '../utils/toolErrors.js'
 28  import { zodToJsonSchema } from '../utils/zodToJsonSchema.js'
 29  
 30  type ToolInput = Tool['inputSchema']
 31  type ToolOutput = Tool['outputSchema']
 32  
 33  const MCP_COMMANDS: Command[] = [review]
 34  
 35  export async function startMCPServer(
 36    cwd: string,
 37    debug: boolean,
 38    verbose: boolean,
 39  ): Promise<void> {
 40    // Use size-limited LRU cache for readFileState to prevent unbounded memory growth
 41    // 100 files and 25MB limit should be sufficient for MCP server operations
 42    const READ_FILE_STATE_CACHE_SIZE = 100
 43    const readFileStateCache = createFileStateCacheWithSizeLimit(
 44      READ_FILE_STATE_CACHE_SIZE,
 45    )
 46    setCwd(cwd)
 47    const server = new Server(
 48      {
 49        name: 'claude/tengu',
 50        version: MACRO.VERSION,
 51      },
 52      {
 53        capabilities: {
 54          tools: {},
 55        },
 56      },
 57    )
 58  
 59    server.setRequestHandler(
 60      ListToolsRequestSchema,
 61      async (): Promise<ListToolsResult> => {
 62        // TODO: Also re-expose any MCP tools
 63        const toolPermissionContext = getEmptyToolPermissionContext()
 64        const tools = getTools(toolPermissionContext)
 65        return {
 66          tools: await Promise.all(
 67            tools.map(async tool => {
 68              let outputSchema: ToolOutput | undefined
 69              if (tool.outputSchema) {
 70                const convertedSchema = zodToJsonSchema(tool.outputSchema)
 71                // MCP SDK requires outputSchema to have type: "object" at root level
 72                // Skip schemas with anyOf/oneOf at root (from z.union, z.discriminatedUnion, etc.)
 73                // See: https://github.com/anthropics/claude-code/issues/8014
 74                if (
 75                  typeof convertedSchema === 'object' &&
 76                  convertedSchema !== null &&
 77                  'type' in convertedSchema &&
 78                  convertedSchema.type === 'object'
 79                ) {
 80                  outputSchema = convertedSchema as ToolOutput
 81                }
 82              }
 83              return {
 84                ...tool,
 85                description: await tool.prompt({
 86                  getToolPermissionContext: async () => toolPermissionContext,
 87                  tools,
 88                  agents: [],
 89                }),
 90                inputSchema: zodToJsonSchema(tool.inputSchema) as ToolInput,
 91                outputSchema,
 92              }
 93            }),
 94          ),
 95        }
 96      },
 97    )
 98  
 99    server.setRequestHandler(
100      CallToolRequestSchema,
101      async ({ params: { name, arguments: args } }): Promise<CallToolResult> => {
102        const toolPermissionContext = getEmptyToolPermissionContext()
103        // TODO: Also re-expose any MCP tools
104        const tools = getTools(toolPermissionContext)
105        const tool = findToolByName(tools, name)
106        if (!tool) {
107          throw new Error(`Tool ${name} not found`)
108        }
109  
110        // Assume MCP servers do not read messages separately from the tool
111        // call arguments.
112        const toolUseContext: ToolUseContext = {
113          abortController: createAbortController(),
114          options: {
115            commands: MCP_COMMANDS,
116            tools,
117            mainLoopModel: getMainLoopModel(),
118            thinkingConfig: { type: 'disabled' },
119            mcpClients: [],
120            mcpResources: {},
121            isNonInteractiveSession: true,
122            debug,
123            verbose,
124            agentDefinitions: { activeAgents: [], allAgents: [] },
125          },
126          getAppState: () => getDefaultAppState(),
127          setAppState: () => {},
128          messages: [],
129          readFileState: readFileStateCache,
130          setInProgressToolUseIDs: () => {},
131          setResponseLength: () => {},
132          updateFileHistoryState: () => {},
133          updateAttributionState: () => {},
134        }
135  
136        // TODO: validate input types with zod
137        try {
138          if (!tool.isEnabled()) {
139            throw new Error(`Tool ${name} is not enabled`)
140          }
141          const validationResult = await tool.validateInput?.(
142            (args as never) ?? {},
143            toolUseContext,
144          )
145          if (validationResult && !validationResult.result) {
146            throw new Error(
147              `Tool ${name} input is invalid: ${validationResult.message}`,
148            )
149          }
150          const finalResult = await tool.call(
151            (args ?? {}) as never,
152            toolUseContext,
153            hasPermissionsToUseTool,
154            createAssistantMessage({
155              content: [],
156            }),
157          )
158  
159          return {
160            content: [
161              {
162                type: 'text' as const,
163                text:
164                  typeof finalResult === 'string'
165                    ? finalResult
166                    : jsonStringify(finalResult.data),
167              },
168            ],
169          }
170        } catch (error) {
171          logError(error)
172  
173          const parts =
174            error instanceof Error ? getErrorParts(error) : [String(error)]
175          const errorText = parts.filter(Boolean).join('\n').trim() || 'Error'
176  
177          return {
178            isError: true,
179            content: [
180              {
181                type: 'text',
182                text: errorText,
183              },
184            ],
185          }
186        }
187      },
188    )
189  
190    async function runServer() {
191      const transport = new StdioServerTransport()
192      await server.connect(transport)
193    }
194  
195    return await runServer()
196  }