fix(stage-*): can’t get mcp servers when use remote api (#1181)

This commit is contained in:
Doji
2026-03-07 17:39:57 +08:00
committed by GitHub
parent 5e20939912
commit 5bed6bca80
9 changed files with 341 additions and 103 deletions
@@ -13,6 +13,7 @@ import type {
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { env } from 'node:process'
import { useLogg } from '@guiiai/logg'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
@@ -62,8 +63,11 @@ const defaultMcpConfig: ElectronMcpStdioConfigFile = {
mcpServers: {},
}
const toolNameSeparator = '::'
const mcpServerConnectTimeoutMsec = 10_000
const mcpRequestTimeoutMsec = 10_000
const mcpRequestMaxTotalTimeoutMsec = 15_000
const mcpToolRequestIdCacheTtlMsec = 30_000
const mcpToolRequestIdCacheMaxSize = 512
function stringifyError(error: unknown) {
if (error instanceof Error) {
@@ -89,20 +93,33 @@ function parseQualifiedToolName(name: string) {
}
}
function resolveFallbackToolName(toolName: string): string | undefined {
const normalizedTransportPrefix = toolName
.replace(/^\.(?:stdio|stdo)::/, '')
.replace(/^(?:stdio|stdo)::/, '')
if (normalizedTransportPrefix !== toolName) {
return normalizedTransportPrefix
async function withTimeout<T>(task: Promise<T>, timeoutMsec: number, timeoutMessage: string): Promise<T> {
let timeoutId: NodeJS.Timeout | undefined
try {
return await Promise.race([
task,
new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMsec)
}),
])
}
finally {
if (timeoutId) {
clearTimeout(timeoutId)
}
}
}
function createSpawnEnv(overrides?: Record<string, string>): Record<string, string> {
const baseEnv = Object.fromEntries(Object.entries(env).filter((entry): entry is [string, string] => typeof entry[1] === 'string'))
if (!overrides) {
return baseEnv
}
const lastSeparatorIndex = toolName.lastIndexOf(toolNameSeparator)
if (lastSeparatorIndex <= 0 || lastSeparatorIndex === toolName.length - toolNameSeparator.length) {
return undefined
return {
...baseEnv,
...overrides,
}
return toolName.slice(lastSeparatorIndex + toolNameSeparator.length)
}
async function closeSession(session: McpServerSession) {
@@ -118,8 +135,26 @@ export function createMcpStdioManager(): McpStdioManager {
const log = useLogg('main/mcp-stdio').useGlobalConfig()
const sessions = new Map<string, McpServerSession>()
const runtimeStatuses = new Map<string, ElectronMcpStdioServerRuntimeStatus>()
const inFlightToolCallsByRequestId = new Map<string, Promise<ElectronMcpCallToolResult>>()
const completedToolCallsByRequestId = new Map<string, { result: ElectronMcpCallToolResult, expiresAt: number }>()
let updatedAt = Date.now()
const pruneCompletedToolCalls = (now = Date.now()) => {
for (const [requestId, cached] of completedToolCallsByRequestId.entries()) {
if (cached.expiresAt <= now) {
completedToolCallsByRequestId.delete(requestId)
}
}
while (completedToolCallsByRequestId.size > mcpToolRequestIdCacheMaxSize) {
const oldestRequestId = completedToolCallsByRequestId.keys().next().value
if (!oldestRequestId) {
break
}
completedToolCallsByRequestId.delete(oldestRequestId)
}
}
const setRuntimeStatus = (status: ElectronMcpStdioServerRuntimeStatus) => {
runtimeStatuses.set(status.name, status)
updatedAt = Date.now()
@@ -171,13 +206,16 @@ export function createMcpStdioManager(): McpStdioManager {
})
sessions.delete(name)
}
inFlightToolCallsByRequestId.clear()
completedToolCallsByRequestId.clear()
}
const startServer = async (name: string, config: ElectronMcpStdioServerConfig) => {
const transport = new StdioClientTransport({
command: config.command,
args: config.args ?? [],
env: config.env,
env: createSpawnEnv(config.env),
cwd: config.cwd,
stderr: 'pipe',
})
@@ -187,7 +225,11 @@ export function createMcpStdioManager(): McpStdioManager {
})
try {
await client.connect(transport)
await withTimeout(
client.connect(transport),
mcpServerConnectTimeoutMsec,
`mcp server connect timeout (${mcpServerConnectTimeoutMsec}ms): ${name}`,
)
transport.stderr?.on('data', (data) => {
const text = data.toString('utf-8').trim()
if (text) {
@@ -204,6 +246,7 @@ export function createMcpStdioManager(): McpStdioManager {
})
}
catch (error) {
log.withFields({ serverName: name }).withError(error).warn('failed to connect mcp stdio server')
await transport.close().catch(() => {})
throw error
}
@@ -285,58 +328,73 @@ export function createMcpStdioManager(): McpStdioManager {
}
const callTool = async (payload: ElectronMcpCallToolPayload): Promise<ElectronMcpCallToolResult> => {
const { serverName, toolName } = parseQualifiedToolName(payload.name)
const session = sessions.get(serverName)
if (!session) {
throw new Error(`mcp server is not running: ${serverName}`)
const normalizedRequestId = payload.requestId?.trim()
if (normalizedRequestId) {
pruneCompletedToolCalls()
const cached = completedToolCallsByRequestId.get(normalizedRequestId)
if (cached && cached.expiresAt > Date.now()) {
return cached.result
}
const inFlight = inFlightToolCallsByRequestId.get(normalizedRequestId)
if (inFlight) {
return inFlight
}
}
let result
try {
result = await session.client.callTool({
const executeCall = async (): Promise<ElectronMcpCallToolResult> => {
const { serverName, toolName } = parseQualifiedToolName(payload.name)
const session = sessions.get(serverName)
if (!session) {
throw new Error(`mcp server is not running: ${serverName}`)
}
const result = await session.client.callTool({
name: toolName,
arguments: payload.arguments ?? {},
}, undefined, {
timeout: mcpRequestTimeoutMsec,
maxTotalTimeout: mcpRequestMaxTotalTimeoutMsec,
})
}
catch (error) {
const fallbackToolName = resolveFallbackToolName(toolName)
if (!fallbackToolName || fallbackToolName === toolName) {
throw error
const normalized: ElectronMcpCallToolResult = {}
if ('content' in result && Array.isArray(result.content)) {
normalized.content = result.content as Array<Record<string, unknown>>
}
if ('structuredContent' in result) {
normalized.structuredContent = result.structuredContent
}
if ('isError' in result && typeof result.isError === 'boolean') {
normalized.isError = result.isError
}
if ('toolResult' in result) {
normalized.toolResult = result.toolResult
}
log.withFields({
serverName,
requestedToolName: toolName,
fallbackToolName,
}).warn('retrying mcp tool call with normalized tool name')
return normalized
}
result = await session.client.callTool({
name: fallbackToolName,
arguments: payload.arguments ?? {},
}, undefined, {
timeout: mcpRequestTimeoutMsec,
maxTotalTimeout: mcpRequestMaxTotalTimeoutMsec,
const execution = executeCall()
if (!normalizedRequestId) {
return execution
}
inFlightToolCallsByRequestId.set(normalizedRequestId, execution)
try {
const result = await execution
const now = Date.now()
completedToolCallsByRequestId.set(normalizedRequestId, {
result,
expiresAt: now + mcpToolRequestIdCacheTtlMsec,
})
pruneCompletedToolCalls(now)
return result
}
const normalized: ElectronMcpCallToolResult = {}
if ('content' in result && Array.isArray(result.content)) {
normalized.content = result.content as Array<Record<string, unknown>>
finally {
inFlightToolCallsByRequestId.delete(normalizedRequestId)
}
if ('structuredContent' in result && result.structuredContent && typeof result.structuredContent === 'object' && !Array.isArray(result.structuredContent)) {
normalized.structuredContent = result.structuredContent as Record<string, unknown>
}
if ('isError' in result && typeof result.isError === 'boolean') {
normalized.isError = result.isError
}
if ('toolResult' in result) {
normalized.toolResult = result.toolResult
}
return normalized
}
const getRuntimeStatus = (): ElectronMcpStdioRuntimeStatus => {
@@ -378,18 +436,20 @@ export async function setupMcpStdioManager() {
return manager
}
export function createMcpServersService(params: { context: ReturnType<typeof createContext>['context'], manager: McpStdioManager }) {
defineInvokeHandler(params.context, electronMcpOpenConfigFile, async () => {
return params.manager.openConfigFile()
})
export function createMcpServersService(params: { context: ReturnType<typeof createContext>['context'], manager: McpStdioManager, allowManageConfig?: boolean }) {
if (params.allowManageConfig) {
defineInvokeHandler(params.context, electronMcpOpenConfigFile, async () => {
return params.manager.openConfigFile()
})
defineInvokeHandler(params.context, electronMcpApplyAndRestart, async () => {
return params.manager.applyAndRestart()
})
defineInvokeHandler(params.context, electronMcpApplyAndRestart, async () => {
return params.manager.applyAndRestart()
})
defineInvokeHandler(params.context, electronMcpGetRuntimeStatus, async () => {
return params.manager.getRuntimeStatus()
})
defineInvokeHandler(params.context, electronMcpGetRuntimeStatus, async () => {
return params.manager.getRuntimeStatus()
})
}
defineInvokeHandler(params.context, electronMcpListTools, async () => {
return params.manager.listTools()
@@ -10,9 +10,10 @@ import { createContext } from '@moeru/eventa/adapters/electron/main'
import { ipcMain } from 'electron'
import { electronOpenMainDevtools } from '../../../../shared/eventa'
import { createServerChannelService } from '../../../services/airi/channel-server'
import { createMcpServersService } from '../../../services/airi/mcp-servers'
import { createWidgetsService } from '../../../services/airi/widgets'
import { setupBaseWindowElectronInvokes } from '../../shared/window'
import { createScreenService, createWindowService } from '../../../services/electron'
export async function setupChatWindowElectronInvokes(params: {
window: BrowserWindow
@@ -26,12 +27,15 @@ export async function setupChatWindowElectronInvokes(params: {
// manage events within eventa's context system.
ipcMain.setMaxListeners(0)
const { context } = createContext(ipcMain, params.window)
await setupBaseWindowElectronInvokes({ context, window: params.window, i18n: params.i18n, serverChannel: params.serverChannel })
const { context } = createContext(ipcMain, params.window, {
onlySameWindow: true,
})
createScreenService({ context, window: params.window })
createWindowService({ context, window: params.window })
createWidgetsService({ context, widgetsManager: params.widgetsManager, window: params.window })
createMcpServersService({ context, manager: params.mcpStdioManager })
createServerChannelService({ serverChannel: params.serverChannel })
createMcpServersService({ context, manager: params.mcpStdioManager, allowManageConfig: false })
defineInvokeHandler(context, electronOpenMainDevtools, () => params.window.webContents.openDevTools({ mode: 'detach' }))
}
@@ -34,12 +34,14 @@ export async function setupMainWindowElectronInvokes(params: {
// manage events within eventa's context system.
ipcMain.setMaxListeners(0)
const { context } = createContext(ipcMain, params.window)
const { context } = createContext(ipcMain, params.window, {
onlySameWindow: true,
})
await setupBaseWindowElectronInvokes({ context, window: params.window, serverChannel: params.serverChannel, i18n: params.i18n })
createWidgetsService({ context, widgetsManager: params.widgetsManager, window: params.window })
createAutoUpdaterService({ context, window: params.window, service: params.autoUpdater })
createMcpServersService({ context, manager: params.mcpStdioManager })
createMcpServersService({ context, manager: params.mcpStdioManager, allowManageConfig: false })
defineInvokeHandler(context, electronOpenMainDevtools, () => params.window.webContents.openDevTools({ mode: 'detach' }))
defineInvokeHandler(context, electronOpenSettings, async () => toggleWindowShow(await params.settingsWindow()))
@@ -12,10 +12,10 @@ import { createContext } from '@moeru/eventa/adapters/electron/main'
import { ipcMain } from 'electron'
import { electronOpenDevtoolsWindow, electronOpenSettingsDevtools } from '../../../../shared/eventa'
import { createServerChannelService } from '../../../services/airi/channel-server'
import { createMcpServersService } from '../../../services/airi/mcp-servers'
import { createWidgetsService } from '../../../services/airi/widgets'
import { createAutoUpdaterService } from '../../../services/electron'
import { setupBaseWindowElectronInvokes } from '../../shared/window'
import { createAutoUpdaterService, createScreenService, createWindowService } from '../../../services/electron'
export async function setupSettingsWindowInvokes(params: {
settingsWindow: BrowserWindow
@@ -31,13 +31,16 @@ export async function setupSettingsWindowInvokes(params: {
// manage events within eventa's context system.
ipcMain.setMaxListeners(0)
const { context } = createContext(ipcMain, params.settingsWindow)
await setupBaseWindowElectronInvokes({ context, window: params.settingsWindow, i18n: params.i18n, serverChannel: params.serverChannel })
const { context } = createContext(ipcMain, params.settingsWindow, {
onlySameWindow: true,
})
createScreenService({ context, window: params.settingsWindow })
createWindowService({ context, window: params.settingsWindow })
createWidgetsService({ context, widgetsManager: params.widgetsManager, window: params.settingsWindow })
createAutoUpdaterService({ context, window: params.settingsWindow, service: params.autoUpdater })
createMcpServersService({ context, manager: params.mcpStdioManager })
createServerChannelService({ serverChannel: params.serverChannel })
createMcpServersService({ context, manager: params.mcpStdioManager, allowManageConfig: true })
defineInvokeHandler(context, electronOpenSettingsDevtools, async () => params.settingsWindow.webContents.openDevTools({ mode: 'detach' }))
defineInvokeHandler(context, electronOpenDevtoolsWindow, async (payload) => {
+2 -1
View File
@@ -173,11 +173,12 @@ export interface ElectronMcpToolDescriptor {
export interface ElectronMcpCallToolPayload {
name: string
arguments?: Record<string, unknown>
requestId?: string
}
export interface ElectronMcpCallToolResult {
content?: Array<Record<string, unknown>>
structuredContent?: Record<string, unknown>
structuredContent?: unknown
toolResult?: unknown
isError?: boolean
}
+66 -23
View File
@@ -25,6 +25,30 @@ export interface StreamOptions {
tools?: Tool[] | (() => Promise<Tool[] | undefined>)
}
function createToolsCompatibilityKey(model: string, chatProvider: ChatProvider): string {
return `${chatProvider.chat(model).baseURL}-${model}`
}
function isKnownToolsUnsupportedError(error: unknown): boolean {
const message = String(error).toLowerCase()
return (
// OpenAI / Azure OpenAI / Ollama
message.includes('does not support tools')
// OpenRouter
|| message.includes('no endpoints found that support tool use')
// Anthropic
|| message.includes('does not support tool use')
// Together AI
|| message.includes('tool use is not supported')
// Fireworks AI
|| message.includes('tools are not supported')
// Google Gemini
|| message.includes('tool use with function calling is unsupported')
// Cloudflare Workers AI / vLLM / SGLang
|| message.includes('function calling is not supported')
)
}
// TODO: proper format for other error messages.
function sanitizeMessages(messages: unknown[]): Message[] {
return messages.map((m: any) => {
@@ -39,7 +63,16 @@ function sanitizeMessages(messages: unknown[]): Message[] {
}
function streamOptionsToolsCompatibilityOk(model: string, chatProvider: ChatProvider, _: Message[], options?: StreamOptions): boolean {
return !!(options?.supportsTools || options?.toolsCompatibility?.get(`${chatProvider.chat(model).baseURL}-${model}`))
if (typeof options?.supportsTools === 'boolean') {
return options.supportsTools
}
const discovered = options?.toolsCompatibility?.get(createToolsCompatibilityKey(model, chatProvider))
if (typeof discovered === 'boolean') {
return discovered
}
return true
}
async function streamFrom(model: string, chatProvider: ChatProvider, messages: Message[], options?: StreamOptions) {
@@ -97,15 +130,22 @@ async function streamFrom(model: string, chatProvider: ChatProvider, messages: M
}
try {
streamText({
const stream = streamText({
...chatConfig,
maxSteps: 10,
parallelToolCalls: false,
messages: sanitized,
headers,
// TODO: we need Automatic tools discovery
tools,
onEvent,
})
void stream.steps.then(() => {
resolveOnce()
}).catch((error) => {
rejectOnce(error)
})
}
catch (err) {
rejectOnce(err)
@@ -120,23 +160,8 @@ export async function attemptForToolsCompatibilityDiscovery(model: string, chatP
return true
}
catch (err) {
if (err instanceof Error && err.name === new XSAIError('').name) {
// TODO: if you encountered many more errors like these, please, add them here.
// Ollama
/**
* {"error":{"message":"registry.ollama.ai/<scope>/<model> does not support tools","type":"api_error","param":null,"code":null}}
*/
if (String(err).includes('does not support tools')) {
return false
}
// OpenRouter
/**
* {"error":{"message":"No endpoints found that support tool use. To learn more about provider routing, visit: https://openrouter.ai/docs/provider-routing","code":404}}
*/
if (String(err).includes('No endpoints found that support tool use.')) {
return false
}
if (err instanceof Error && err.name === new XSAIError('').name && isKnownToolsUnsupportedError(err)) {
return false
}
throw err
@@ -187,17 +212,35 @@ export const useLLM = defineStore('llm', () => {
const toolsCompatibility = ref<Map<string, boolean>>(new Map())
async function discoverToolsCompatibility(model: string, chatProvider: ChatProvider, _: Message[], options?: Omit<StreamOptions, 'supportsTools'>) {
const key = createToolsCompatibilityKey(model, chatProvider)
// Cached, no need to discover again
if (toolsCompatibility.value.has(`${chatProvider.chat(model).baseURL}-${model}`)) {
if (toolsCompatibility.value.has(key)) {
return
}
const res = await attemptForToolsCompatibilityDiscovery(model, chatProvider, _, { ...options, toolsCompatibility: toolsCompatibility.value })
toolsCompatibility.value.set(`${chatProvider.chat(model).baseURL}-${model}`, res)
toolsCompatibility.value.set(key, res)
}
function stream(model: string, chatProvider: ChatProvider, messages: Message[], options?: StreamOptions) {
return streamFrom(model, chatProvider, messages, { ...options, toolsCompatibility: toolsCompatibility.value })
async function stream(model: string, chatProvider: ChatProvider, messages: Message[], options?: StreamOptions) {
const key = createToolsCompatibilityKey(model, chatProvider)
try {
return await streamFrom(model, chatProvider, messages, { ...options, toolsCompatibility: toolsCompatibility.value })
}
catch (error) {
if (isKnownToolsUnsupportedError(error)) {
toolsCompatibility.value.set(key, false)
return streamFrom(model, chatProvider, messages, {
...options,
supportsTools: false,
toolsCompatibility: toolsCompatibility.value,
})
}
throw error
}
}
async function models(apiUrl: string, apiKey: string) {
@@ -9,11 +9,12 @@ export interface McpToolDescriptor {
export interface McpCallToolPayload {
name: string
arguments?: Record<string, unknown>
requestId?: string
}
export interface McpCallToolResult {
content?: Array<Record<string, unknown>>
structuredContent?: Record<string, unknown>
structuredContent?: unknown
toolResult?: unknown
isError?: boolean
}
+117 -1
View File
@@ -1,10 +1,20 @@
import type { JsonSchema } from 'xsschema'
import { describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { clearMcpToolBridge, setMcpToolBridge } from '../stores/mcp-tool-bridge'
import { mcp } from './mcp'
describe('tools mcp schema', () => {
beforeEach(() => {
clearMcpToolBridge()
})
afterEach(() => {
clearMcpToolBridge()
vi.restoreAllMocks()
})
it('emits strict parameter objects', async () => {
const tools = await mcp()
const toolNames = [
@@ -28,4 +38,110 @@ describe('tools mcp schema', () => {
expect(items).toBeDefined()
expect(items.additionalProperties).toBe(false)
})
it('keeps mcp_call_tool parameter value schema explicit for remote providers', async () => {
const tools = await mcp()
const callTool = tools.find(entry => entry.function.name === 'mcp_call_tool')
expect(callTool).toBeDefined()
const items = ((callTool!.function.parameters as JsonSchema).properties?.parameters as JsonSchema)?.items as JsonSchema
const valueSchema = items.properties?.value as JsonSchema
expect(valueSchema).toBeDefined()
expect(Array.isArray(valueSchema.anyOf)).toBe(true)
expect((valueSchema.anyOf || []).length).toBeGreaterThan(0)
})
it('mcp_list_tools returns bridge tools and falls back to empty array on error', async () => {
const listTools = vi.fn().mockResolvedValueOnce([
{
serverName: 'demo',
name: 'demo::tools-list',
toolName: 'tools-list',
inputSchema: { type: 'object' },
},
]).mockRejectedValueOnce(new Error('boom'))
const callTool = vi.fn()
setMcpToolBridge({ listTools, callTool })
const tools = await mcp()
const list = tools.find(entry => entry.function.name === 'mcp_list_tools')
expect(list).toBeDefined()
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const first = await list!.execute({}, undefined as never)
const second = await list!.execute({}, undefined as never)
expect(first).toEqual([
{
serverName: 'demo',
name: 'demo::tools-list',
toolName: 'tools-list',
inputSchema: { type: 'object' },
},
])
expect(second).toEqual([])
expect(listTools).toHaveBeenCalledTimes(2)
expect(warn).toHaveBeenCalled()
})
it('mcp_call_tool maps parameters and returns fallback error payload', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
const callTool = vi.fn()
.mockResolvedValueOnce({
content: [{ type: 'text', text: 'ok' }],
isError: false,
})
.mockRejectedValueOnce(new Error('tool failed'))
const listTools = vi.fn().mockResolvedValue([])
setMcpToolBridge({ listTools, callTool })
const tools = await mcp()
const call = tools.find(entry => entry.function.name === 'mcp_call_tool')
expect(call).toBeDefined()
const first = await call!.execute({
name: 'demo::echo',
parameters: [
{ name: 'message', value: 'hello' },
{ name: 'count', value: 2 },
],
}, {
toolCallId: 'tool-call-1',
messages: [],
} as never)
const second = await call!.execute({
name: 'demo::echo',
parameters: [
{ name: 'message', value: 'hello' },
],
}, undefined as never)
expect(callTool).toHaveBeenNthCalledWith(1, {
name: 'demo::echo',
arguments: {
message: 'hello',
count: 2,
},
requestId: 'tool-call-1',
})
expect(first).toEqual({
content: [{ type: 'text', text: 'ok' }],
isError: false,
})
expect(second).toEqual({
isError: true,
content: [
{
type: 'text',
text: 'tool failed',
},
],
})
})
})
+11 -3
View File
@@ -3,6 +3,13 @@ import { z } from 'zod'
import { getMcpToolBridge } from '../stores/mcp-tool-bridge'
const mcpParameterPrimitiveSchema = z.union([z.string(), z.number(), z.boolean(), z.null()])
const mcpParameterValueSchema = z.union([
mcpParameterPrimitiveSchema,
z.array(mcpParameterPrimitiveSchema),
z.object({}).catchall(mcpParameterPrimitiveSchema),
])
const tools = [
tool({
name: 'mcp_list_tools',
@@ -21,17 +28,18 @@ const tools = [
tool({
name: 'mcp_call_tool',
description: 'Call a tool on the MCP server. The result is a list of content and a boolean indicating whether the tool call is an error.',
execute: async ({ name, parameters }) => {
execute: async ({ name, parameters }, options) => {
try {
const parametersObject = Object.fromEntries(parameters.map(({ name, value }) => [name, value]))
const result = await getMcpToolBridge().callTool({
name,
arguments: parametersObject,
...(options?.toolCallId ? { requestId: options.toolCallId } : {}),
})
return result satisfies {
content?: Record<string, unknown>[]
isError?: boolean
structuredContent?: Record<string, unknown>
structuredContent?: unknown
toolResult?: unknown
}
}
@@ -52,7 +60,7 @@ const tools = [
name: z.string().describe('The qualified tool name to call. Use format "<serverName>::<toolName>"'),
parameters: z.array(z.object({
name: z.string().describe('The name of the parameter'),
value: z.unknown().describe('The value of the parameter'),
value: mcpParameterValueSchema.describe('The value of the parameter'),
}).strict()).describe('The parameters to pass to the tool'),
}).strict(),
}),