MCP tool calling woo!

This commit is contained in:
Glen Maddern 2025-06-19 14:07:28 +10:00
commit e94958865a
6 changed files with 143 additions and 22 deletions

View file

@ -51,8 +51,8 @@ importers:
specifier: ^4.0.14 specifier: ^4.0.14
version: 4.1.6 version: 4.1.6
use-mcp: use-mcp:
specifier: link:.. specifier: link:../..
version: link:.. version: link:../..
workers-ai-provider: workers-ai-provider:
specifier: ^0.1.3 specifier: ^0.1.3
version: 0.1.3(react@19.1.0) version: 0.1.3(react@19.1.0)

View file

@ -8,6 +8,7 @@ import { useIndexedDB } from "../hooks/useIndexedDB";
import { type Model } from "../types/models"; import { type Model } from "../types/models";
import { getSelectedModel, setSelectedModel as saveSelectedModel } from "../utils/modelPreferences"; import { getSelectedModel, setSelectedModel as saveSelectedModel } from "../utils/modelPreferences";
import { type IDBPDatabase } from "idb"; import { type IDBPDatabase } from "idb";
import { type Tool } from "use-mcp/react";
// eslint-disable-next-line @typescript-eslint/no-empty-object-type // eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface ChatAppProps {} interface ChatAppProps {}
@ -20,6 +21,7 @@ const ChatApp: React.FC<ChatAppProps> = () => {
const [sidebarVisible, setSidebarVisible] = useState<boolean>(true); const [sidebarVisible, setSidebarVisible] = useState<boolean>(true);
const [selectedModel, setSelectedModel] = useState<Model>(getSelectedModel()); const [selectedModel, setSelectedModel] = useState<Model>(getSelectedModel());
const [apiKeyUpdateTrigger, setApiKeyUpdateTrigger] = useState<number>(0); const [apiKeyUpdateTrigger, setApiKeyUpdateTrigger] = useState<number>(0);
const [mcpTools, setMcpTools] = useState<Tool[]>([]);
const db = useIndexedDB(); const db = useIndexedDB();
const handleApiKeyUpdate = () => { const handleApiKeyUpdate = () => {
@ -103,18 +105,19 @@ const ChatApp: React.FC<ChatAppProps> = () => {
<div className="flex h-dvh w-screen overflow-clip bg-white"> <div className="flex h-dvh w-screen overflow-clip bg-white">
<div className="flex flex-row flex-grow flex-1 overflow-hidden relative"> <div className="flex flex-row flex-grow flex-1 overflow-hidden relative">
<ChatSidebar <ChatSidebar
sidebarVisible={sidebarVisible} sidebarVisible={sidebarVisible}
setSidebarVisible={setSidebarVisible} setSidebarVisible={setSidebarVisible}
conversations={conversations} conversations={conversations}
conversationId={conversationId} conversationId={conversationId}
setConversationId={setConversationId} setConversationId={setConversationId}
deleteConversation={deleteConversation} deleteConversation={deleteConversation}
editConversationTitle={editConversationTitle} editConversationTitle={editConversationTitle}
startNewConversation={startNewConversation} startNewConversation={startNewConversation}
selectedModel={selectedModel} selectedModel={selectedModel}
onModelChange={handleModelChange} onModelChange={handleModelChange}
apiKeyUpdateTrigger={apiKeyUpdateTrigger} apiKeyUpdateTrigger={apiKeyUpdateTrigger}
/> onMcpToolsUpdate={setMcpTools}
/>
<div className="flex flex-col flex-grow h-full w-[calc(100%-16rem)] lg:w-[calc(100%-32rem)]"> <div className="flex flex-col flex-grow h-full w-[calc(100%-16rem)] lg:w-[calc(100%-32rem)]">
<ChatNavbar <ChatNavbar
sidebarVisible={sidebarVisible} sidebarVisible={sidebarVisible}
@ -128,6 +131,7 @@ const ChatApp: React.FC<ChatAppProps> = () => {
db={db as IDBPDatabase} db={db as IDBPDatabase}
selectedModel={selectedModel} selectedModel={selectedModel}
onApiKeyUpdate={handleApiKeyUpdate} onApiKeyUpdate={handleApiKeyUpdate}
mcpTools={mcpTools}
/> />
</div> </div>
</div> </div>

View file

@ -10,6 +10,7 @@ import ModelSelector from "./ModelSelector";
import { McpServers } from "./McpServers"; import { McpServers } from "./McpServers";
import { type Model } from "../types/models"; import { type Model } from "../types/models";
import { type Message } from "../types"; import { type Message } from "../types";
import { type Tool } from "use-mcp/react";
interface Conversation { interface Conversation {
id?: number; id?: number;
@ -29,6 +30,7 @@ interface ChatSidebarProps {
selectedModel: Model; selectedModel: Model;
onModelChange: (model: Model) => void; onModelChange: (model: Model) => void;
apiKeyUpdateTrigger: number; apiKeyUpdateTrigger: number;
onMcpToolsUpdate: (tools: Tool[]) => void;
} }
const ChatSidebar: React.FC<ChatSidebarProps> = ({ const ChatSidebar: React.FC<ChatSidebarProps> = ({
@ -43,6 +45,7 @@ const ChatSidebar: React.FC<ChatSidebarProps> = ({
selectedModel, selectedModel,
onModelChange, onModelChange,
apiKeyUpdateTrigger, apiKeyUpdateTrigger,
onMcpToolsUpdate,
}) => { }) => {
const handleConversationClick = (id: number | undefined) => { const handleConversationClick = (id: number | undefined) => {
setConversationId(id); setConversationId(id);
@ -163,7 +166,7 @@ const ChatSidebar: React.FC<ChatSidebarProps> = ({
onModelChange={onModelChange} onModelChange={onModelChange}
apiKeyUpdateTrigger={apiKeyUpdateTrigger} apiKeyUpdateTrigger={apiKeyUpdateTrigger}
/> />
<McpServers /> <McpServers onToolsUpdate={onMcpToolsUpdate} />
</div> </div>
</div> </div>
</div> </div>

View file

@ -5,6 +5,7 @@ import '../styles/github.css'
import { type Conversation, type Message } from '../types' import { type Conversation, type Message } from '../types'
import { type Model } from '../types/models' import { type Model } from '../types/models'
import { type IDBPDatabase } from 'idb' import { type IDBPDatabase } from 'idb'
import { type Tool } from 'use-mcp/react'
import ChatMessage from './ChatMessage' import ChatMessage from './ChatMessage'
import ChatInput from './ChatInput' import ChatInput from './ChatInput'
import { useAutoscroll } from '../hooks/useAutoscroll' import { useAutoscroll } from '../hooks/useAutoscroll'
@ -20,6 +21,7 @@ interface ConversationThreadProps {
db: IDBPDatabase db: IDBPDatabase
selectedModel: Model selectedModel: Model
onApiKeyUpdate: () => void onApiKeyUpdate: () => void
mcpTools: Tool[]
} }
const ConversationThread: React.FC<ConversationThreadProps> = ({ const ConversationThread: React.FC<ConversationThreadProps> = ({
@ -30,6 +32,7 @@ const ConversationThread: React.FC<ConversationThreadProps> = ({
db, db,
selectedModel, selectedModel,
onApiKeyUpdate, onApiKeyUpdate,
mcpTools,
}) => { }) => {
const [input, setInput] = useState<string>('') const [input, setInput] = useState<string>('')
const [apiKeyModal, setApiKeyModal] = useState<{ isOpen: boolean; model: Model | null }>({ const [apiKeyModal, setApiKeyModal] = useState<{ isOpen: boolean; model: Model | null }>({
@ -80,6 +83,7 @@ const ConversationThread: React.FC<ConversationThreadProps> = ({
scrollToBottom, scrollToBottom,
selectedModel, selectedModel,
onApiKeyRequired: handleApiKeyRequired, onApiKeyRequired: handleApiKeyRequired,
mcpTools,
}) })
const currentConversation = conversations.find((conv) => conv.id === conversationId) || { messages: [], title: '' } const currentConversation = conversations.find((conv) => conv.id === conversationId) || { messages: [], title: '' }

View file

@ -1,10 +1,11 @@
import { useState, useRef } from 'react' import { useState, useRef } from 'react'
import { streamText } from 'ai' import { streamText, tool, jsonSchema } from 'ai'
import { createGroq } from '@ai-sdk/groq' import { createGroq } from '@ai-sdk/groq'
import { createAnthropic } from '@ai-sdk/anthropic' import { createAnthropic } from '@ai-sdk/anthropic'
import { type Message, type Conversation } from '../types' import { type Message, type Conversation } from '../types'
import { type Model } from '../types/models' import { type Model } from '../types/models'
import { getApiKey } from '../utils/apiKeys' import { getApiKey } from '../utils/apiKeys'
import { type Tool } from 'use-mcp/react'
interface UseStreamResponseProps { interface UseStreamResponseProps {
conversationId?: number conversationId?: number
@ -12,6 +13,7 @@ interface UseStreamResponseProps {
scrollToBottom: () => void scrollToBottom: () => void
selectedModel: Model selectedModel: Model
onApiKeyRequired: (model: Model) => Promise<boolean> onApiKeyRequired: (model: Model) => Promise<boolean>
mcpTools: Tool[]
} }
export const useStreamResponse = ({ export const useStreamResponse = ({
@ -20,12 +22,52 @@ export const useStreamResponse = ({
scrollToBottom, scrollToBottom,
selectedModel, selectedModel,
onApiKeyRequired, onApiKeyRequired,
mcpTools,
}: UseStreamResponseProps) => { }: UseStreamResponseProps) => {
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [streamStarted, setStreamStarted] = useState(false) const [streamStarted, setStreamStarted] = useState(false)
const [controller, setController] = useState(new AbortController()) const [controller, setController] = useState(new AbortController())
const aiResponseRef = useRef<string>('') const aiResponseRef = useRef<string>('')
// Convert MCP tools to the format expected by the 'ai' package
const convertMcpToolsToAiTools = (mcpTools: Tool[]) => {
const aiTools: Record<string, any> = {}
console.log(`[useStreamResponse] Converting ${mcpTools.length} MCP tools to AI format`)
mcpTools.forEach((mcpTool) => {
const schema = mcpTool.inputSchema || { type: 'object', properties: {} }
console.log(`[useStreamResponse] Converting tool: ${mcpTool.name}`, { description: mcpTool.description, schema })
aiTools[mcpTool.name] = tool({
description: mcpTool.description,
parameters: jsonSchema(schema as any),
execute: async (args: unknown, options?: any) => {
console.log(`[useStreamResponse] Tool "${mcpTool.name}" execute called with args:`, args)
console.log(`[useStreamResponse] Tool execute options:`, options)
try {
// Call the MCP tool using the callTool method attached to the tool
if ('callTool' in mcpTool && typeof mcpTool.callTool === 'function') {
console.log(`[useStreamResponse] Calling MCP tool "${mcpTool.name}"...`)
const result = await mcpTool.callTool(args as Record<string, unknown>)
console.log(`[useStreamResponse] Tool "${mcpTool.name}" returned result:`, result)
return result
} else {
throw new Error(`Tool ${mcpTool.name} does not have a callable implementation`)
}
} catch (error) {
console.error(`[useStreamResponse] Error calling MCP tool ${mcpTool.name}:`, error)
throw error
}
},
})
})
console.log(`[useStreamResponse] Converted tools:`, Object.keys(aiTools))
return aiTools
}
const getModelInstance = (model: Model, apiKey: string) => { const getModelInstance = (model: Model, apiKey: string) => {
switch (model.provider.id) { switch (model.provider.id) {
case 'groq': { case 'groq': {
@ -48,7 +90,10 @@ export const useStreamResponse = ({
const streamResponse = async (messages: Message[]) => { const streamResponse = async (messages: Message[]) => {
let aiResponse = '' let aiResponse = ''
console.log({ messages }) console.log(`[useStreamResponse] streamResponse called with ${messages.length} messages`)
console.log(`[useStreamResponse] Messages:`, messages)
console.log(`[useStreamResponse] Current conversationId:`, conversationId)
console.log(`[useStreamResponse] MCP tools available:`, mcpTools.length)
// Check if API key is available // Check if API key is available
let apiKey = getApiKey(selectedModel.provider.id) let apiKey = getApiKey(selectedModel.provider.id)
@ -66,35 +111,88 @@ export const useStreamResponse = ({
try { try {
const modelInstance = getModelInstance(selectedModel, apiKey) const modelInstance = getModelInstance(selectedModel, apiKey)
console.log(`[useStreamResponse] Adding empty assistant message to conversation ${conversationId}`)
setConversations((prev) => { setConversations((prev) => {
const updated = [...prev] const updated = [...prev]
const conv = updated.find((c) => c.id === conversationId) const conv = updated.find((c) => c.id === conversationId)
if (conv) { if (conv) {
console.log('POPPING') console.log(`[useStreamResponse] Found conversation, adding assistant message. Current message count: ${conv.messages.length}`)
conv.messages.push({ role: 'assistant', content: '' }) conv.messages.push({ role: 'assistant', content: '' })
console.log(`[useStreamResponse] After adding assistant message, message count: ${conv.messages.length}`)
} else {
console.warn(`[useStreamResponse] Could not find conversation with id: ${conversationId}`)
} }
return updated return updated
}) })
console.log(`[useStreamResponse] Setting stream started to true`)
setStreamStarted(true) setStreamStarted(true)
// Convert MCP tools to AI package format
const aiTools = convertMcpToolsToAiTools(mcpTools)
console.log(`[useStreamResponse] Starting streamText with ${Object.keys(aiTools).length} tools available`)
console.log(`[useStreamResponse] Messages:`, messages.map(m => ({ role: m.role, content: m.content.substring(0, 100) + '...' })))
const result = await streamText({ const result = await streamText({
model: modelInstance, model: modelInstance,
messages: messages.map((msg) => ({ messages: messages.map((msg) => ({
role: msg.role, role: msg.role,
content: msg.content, content: msg.content,
})), })),
tools: Object.keys(aiTools).length > 0 ? aiTools : undefined,
maxSteps: 5, // Allow up to 5 steps for tool calling
onStepFinish({ text, toolCalls, toolResults, finishReason, usage }) {
console.log(`[useStreamResponse] Step finished:`, {
text: text ? text.substring(0, 100) + '...' : 'no text',
toolCallsCount: toolCalls?.length || 0,
toolResultsCount: toolResults?.length || 0,
finishReason,
usage
})
if (toolCalls && toolCalls.length > 0) {
console.log(`[useStreamResponse] Tool calls in step:`, toolCalls)
}
if (toolResults && toolResults.length > 0) {
console.log(`[useStreamResponse] Tool results in step:`, toolResults)
}
},
abortSignal: controller.signal, abortSignal: controller.signal,
}) })
console.log(`[useStreamResponse] streamText result obtained, starting to process stream`)
console.log(`[useStreamResponse] Result object:`, {
hasTextStream: !!result.textStream,
hasToolCalls: !!result.toolCalls,
hasToolResults: !!result.toolResults,
hasFullStream: !!result.fullStream
})
// Log tool calls if they exist
if (result.toolCalls) {
console.log(`[useStreamResponse] Tool calls detected:`, result.toolCalls)
}
// Log tool results if they exist
if (result.toolResults) {
console.log(`[useStreamResponse] Tool results detected:`, result.toolResults)
}
let chunkCount = 0
for await (const chunk of result.textStream) { for await (const chunk of result.textStream) {
try { try {
chunkCount++
console.log(`[useStreamResponse] Processing chunk ${chunkCount}:`, JSON.stringify(chunk))
aiResponse += chunk aiResponse += chunk
aiResponseRef.current = aiResponse aiResponseRef.current = aiResponse
console.log(`[useStreamResponse] Updated aiResponse (length: ${aiResponse.length}):`, aiResponse.substring(0, 200) + '...')
//custom extraction of <chat-title> tag //custom extraction of <chat-title> tag
const titleMatch = aiResponse.match(/<chat-title>(.*?)<\/chat-title>/) const titleMatch = aiResponse.match(/<chat-title>(.*?)<\/chat-title>/)
if (titleMatch) { if (titleMatch) {
const title = titleMatch[1].trim() const title = titleMatch[1].trim()
console.log(`[useStreamResponse] Extracted title: ${title}`)
setConversations((prev) => { setConversations((prev) => {
const updated = [...prev] const updated = [...prev]
const conv = updated.find((c) => c.id === conversationId) const conv = updated.find((c) => c.id === conversationId)
@ -110,32 +208,44 @@ export const useStreamResponse = ({
const updated = [...prev] const updated = [...prev]
const conv = updated.find((c) => c.id === conversationId) const conv = updated.find((c) => c.id === conversationId)
if (conv) { if (conv) {
console.log(`[useStreamResponse] Updating conversation message content (length: ${aiResponse.length})`)
conv.messages[conv.messages.length - 1].content = aiResponse conv.messages[conv.messages.length - 1].content = aiResponse
} else {
console.warn(`[useStreamResponse] Could not find conversation with id: ${conversationId}`)
} }
return updated return updated
}) })
scrollToBottom() scrollToBottom()
} catch (e) { } catch (e) {
console.log('Error in text chunk', e) console.error('[useStreamResponse] Error in text chunk processing:', e)
} }
} }
console.log(`[useStreamResponse] Finished processing ${chunkCount} chunks. Final response length: ${aiResponse.length}`)
} catch (error: unknown) { } catch (error: unknown) {
if (controller.signal.aborted) { if (controller.signal.aborted) {
console.log('Stream aborted') console.log('[useStreamResponse] Stream aborted')
} else { } else {
console.error('Error generating response:', error) console.error('[useStreamResponse] Error generating response:', error)
// Log additional error details
if (error instanceof Error) {
console.error('[useStreamResponse] Error stack:', error.stack)
console.error('[useStreamResponse] Error message:', error.message)
}
// Remove the assistant message if there was an error // Remove the assistant message if there was an error
setConversations((prev) => { setConversations((prev) => {
const updated = [...prev] const updated = [...prev]
const conv = updated.find((c) => c.id === conversationId) const conv = updated.find((c) => c.id === conversationId)
if (conv && conv.messages[conv.messages.length - 1].role === 'assistant') { if (conv && conv.messages[conv.messages.length - 1].role === 'assistant') {
console.log('[useStreamResponse] Removing assistant message due to error')
conv.messages.pop() conv.messages.pop()
} }
return updated return updated
}) })
} }
} finally { } finally {
console.log('[useStreamResponse] Cleaning up stream state')
setStreamStarted(false) setStreamStarted(false)
setController(new AbortController()) setController(new AbortController())
} }