fix(stage-*): can’t get mcp servers when use remote api (#1181)
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user