better-chatbot / src /app /api /chat /shared.chat.ts
Bot
fix: resolve Promise mapping bug in loadMcpTools and loadWorkFlowTools to correctly load and connect configured MCP servers
c27c221
Raw
History Blame Contribute Delete
14.2 kB
import "server-only";
import {
LoadAPIKeyError,
UIMessage,
Tool,
jsonSchema,
tool as createTool,
isToolUIPart,
UIMessagePart,
ToolUIPart,
getToolName,
UIMessageStreamWriter,
} from "ai";
import {
ChatMention,
ChatMetadata,
ManualToolConfirmTag,
} from "app-types/chat";
import { errorToString, exclude, objectFlow } from "lib/utils";
import logger from "logger";
import {
AllowedMCPServer,
McpServerCustomizationsPrompt,
VercelAIMcpTool,
VercelAIMcpToolTag,
} from "app-types/mcp";
import { MANUAL_REJECT_RESPONSE_PROMPT } from "lib/ai/prompts";
import { ObjectJsonSchema7 } from "app-types/util";
import { safe } from "ts-safe";
import { workflowRepository } from "lib/db/repository";
import {
VercelAIWorkflowTool,
VercelAIWorkflowToolStreaming,
VercelAIWorkflowToolStreamingResultTag,
VercelAIWorkflowToolTag,
} from "app-types/workflow";
import { createWorkflowExecutor } from "lib/ai/workflow/executor/workflow-executor";
import { NodeKind } from "lib/ai/workflow/workflow.interface";
import { mcpClientsManager } from "lib/ai/mcp/mcp-manager";
import { APP_DEFAULT_TOOL_KIT } from "lib/ai/tools/tool-kit";
import { AppDefaultToolkit } from "lib/ai/tools";
export function filterMCPToolsByMentions(
tools: Record<string, VercelAIMcpTool>,
mentions: ChatMention[],
) {
if (mentions.length === 0) {
return tools;
}
const toolMentions = mentions.filter(
(mention) => mention.type == "mcpTool" || mention.type == "mcpServer",
) as Extract<ChatMention, { type: "mcpTool" | "mcpServer" }>[];
const metionsByServer = toolMentions.reduce(
(acc, mention) => {
if (mention.type == "mcpServer") {
return {
...acc,
[mention.serverId]: Object.values(tools).map(
(tool) => tool._originToolName,
),
};
}
return {
...acc,
[mention.serverId]: [...(acc[mention.serverId] ?? []), mention.name],
};
},
{} as Record<string, string[]>,
);
return objectFlow(tools).filter((_tool) => {
if (!metionsByServer[_tool._mcpServerId]) return false;
return metionsByServer[_tool._mcpServerId].includes(_tool._originToolName);
});
}
export function filterMCPToolsByAllowedMCPServers(
tools: Record<string, VercelAIMcpTool>,
allowedMcpServers?: Record<string, AllowedMCPServer>,
): Record<string, VercelAIMcpTool> {
if (!allowedMcpServers || Object.keys(allowedMcpServers).length === 0) {
return {};
}
return objectFlow(tools).filter((_tool) => {
if (!allowedMcpServers[_tool._mcpServerId]?.tools) return false;
return allowedMcpServers[_tool._mcpServerId].tools.includes(
_tool._originToolName,
);
});
}
export function excludeToolExecution(
tool: Record<string, Tool>,
): Record<string, Tool> {
return objectFlow(tool).map((value) => {
return createTool({
inputSchema: value.inputSchema,
description: value.description,
});
});
}
export function mergeSystemPrompt(
...prompts: (string | undefined | false)[]
): string {
const filteredPrompts = prompts
.map((prompt) => (prompt ? prompt.trim() : ""))
.filter(Boolean);
return filteredPrompts.join("\n\n");
}
export function manualToolExecuteByLastMessage(
part: ToolUIPart,
tools: Record<string, VercelAIMcpTool | VercelAIWorkflowTool | Tool>,
abortSignal?: AbortSignal,
) {
const { input } = part;
const toolName = getToolName(part);
const tool = tools[toolName];
return safe(() => {
if (!tool) throw new Error(`tool not found: ${toolName}`);
if (!ManualToolConfirmTag.isMaybe(part.output))
throw new Error("manual tool confirm not found");
return part.output;
})
.map(({ confirm }) => {
if (!confirm) return MANUAL_REJECT_RESPONSE_PROMPT;
if (VercelAIWorkflowToolTag.isMaybe(tool)) {
return tool.execute!(input, {
toolCallId: part.toolCallId,
abortSignal: abortSignal ?? new AbortController().signal,
messages: [],
});
} else if (VercelAIMcpToolTag.isMaybe(tool)) {
return mcpClientsManager.toolCall(
tool._mcpServerId,
tool._originToolName,
input,
);
}
return tool.execute!(input, {
toolCallId: part.toolCallId,
abortSignal: abortSignal ?? new AbortController().signal,
messages: [],
});
})
.ifFail((error) => ({
isError: true,
statusMessage: `tool call fail: ${toolName}`,
error: errorToString(error),
}))
.unwrap();
}
export function handleError(error: any) {
if (LoadAPIKeyError.isInstance(error)) {
return error.message;
}
logger.error(error);
logger.error(`Route Error: ${error.name}`);
return errorToString(error.message);
}
export function extractInProgressToolPart(message: UIMessage): ToolUIPart[] {
if (message.role != "assistant") return [];
if ((message.metadata as ChatMetadata)?.toolChoice != "manual") return [];
return message.parts.filter(
(part) =>
isToolUIPart(part) &&
part.state == "output-available" &&
ManualToolConfirmTag.isMaybe(part.output),
) as ToolUIPart[];
}
export function filterMcpServerCustomizations(
tools: Record<string, VercelAIMcpTool>,
mcpServerCustomization: Record<string, McpServerCustomizationsPrompt>,
): Record<string, McpServerCustomizationsPrompt> {
const toolNamesByServerId = Object.values(tools).reduce(
(acc, tool) => {
if (!acc[tool._mcpServerId]) acc[tool._mcpServerId] = [];
acc[tool._mcpServerId].push(tool._originToolName);
return acc;
},
{} as Record<string, string[]>,
);
return Object.entries(mcpServerCustomization).reduce(
(acc, [serverId, mcpServerCustomization]) => {
if (!(serverId in toolNamesByServerId)) return acc;
if (
!mcpServerCustomization.prompt &&
!Object.keys(mcpServerCustomization.tools ?? {}).length
)
return acc;
const prompts: McpServerCustomizationsPrompt = {
id: serverId,
name: mcpServerCustomization.name,
prompt: mcpServerCustomization.prompt,
tools: mcpServerCustomization.tools
? objectFlow(mcpServerCustomization.tools).filter((_, key) => {
return toolNamesByServerId[serverId].includes(key as string);
})
: {},
};
acc[serverId] = prompts;
return acc;
},
{} as Record<string, McpServerCustomizationsPrompt>,
);
}
export const workflowToVercelAITool = ({
id,
description,
schema,
dataStream,
name,
}: {
id: string;
name: string;
description?: string;
schema: ObjectJsonSchema7;
dataStream: UIMessageStreamWriter;
}): VercelAIWorkflowTool => {
const toolName = name
.replace(/[^a-zA-Z0-9\s]/g, "")
.trim()
.replace(/\s+/g, "-")
.toUpperCase();
const tool = createTool({
description: `${name} ${description?.trim().slice(0, 50)}`,
inputSchema: jsonSchema(schema),
execute(query, { toolCallId, abortSignal }) {
const history: VercelAIWorkflowToolStreaming[] = [];
const toolResult = VercelAIWorkflowToolStreamingResultTag.create({
toolCallId,
workflowName: name,
startedAt: Date.now(),
endedAt: Date.now(),
history,
result: undefined,
status: "running",
});
return safe(id)
.map((id) =>
workflowRepository.selectStructureById(id, {
ignoreNote: true,
}),
)
.map((workflow) => {
if (!workflow) throw new Error("Not Found Workflow");
const executor = createWorkflowExecutor({
nodes: workflow.nodes,
edges: workflow.edges,
});
toolResult.workflowIcon = workflow.icon;
abortSignal?.addEventListener("abort", () => executor.exit());
executor.subscribe((e) => {
if (
e.eventType == "WORKFLOW_START" ||
e.eventType == "WORKFLOW_END"
)
return;
if (e.node.name == "SKIP") return;
if (e.eventType == "NODE_START") {
const node = workflow.nodes.find(
(node) => node.id == e.node.name,
)!;
if (!node) return;
history.push({
id: e.nodeExecutionId,
name: node.name,
status: "running",
startedAt: e.startedAt,
kind: node.kind as NodeKind,
});
} else if (e.eventType == "NODE_END") {
const result = history.find((r) => r.id == e.nodeExecutionId);
if (result) {
if (e.isOk) {
result.status = "success";
result.result = {
input: e.node.output.getInput(e.node.name),
output: e.node.output.getOutput({
nodeId: e.node.name,
path: [],
}),
};
} else {
result.status = "fail";
result.error = {
name: e.error?.name || "ERROR",
message: errorToString(e.error),
};
}
result.endedAt = e.endedAt;
}
}
dataStream.write({
type: "tool-output-available",
toolCallId,
output: toolResult,
});
});
return executor.run(
{
query: query ?? ({} as any),
},
{
disableHistory: true,
},
);
})
.map((result) => {
toolResult.endedAt = Date.now();
toolResult.status = result.isOk ? "success" : "fail";
toolResult.error = result.error
? {
name: result.error.name || "ERROR",
message: errorToString(result.error) || "Unknown Error",
}
: undefined;
const outputNodeResults = history
.filter((h) => h.kind == NodeKind.Output)
.map((v) => v.result?.output)
.filter(Boolean);
toolResult.history = history.map((h) => ({
...h,
result: undefined, // save tokens.
}));
toolResult.result =
outputNodeResults.length == 1
? outputNodeResults[0]
: outputNodeResults;
return toolResult;
})
.ifFail((err) => {
return {
error: {
name: err?.name || "ERROR",
message: errorToString(err),
history,
},
};
})
.unwrap();
},
}) as VercelAIWorkflowTool;
tool._workflowId = id;
tool._originToolName = name;
tool._toolName = toolName;
return VercelAIWorkflowToolTag.create(tool);
};
export const workflowToVercelAITools = (
workflows: {
id: string;
name: string;
description?: string;
schema: ObjectJsonSchema7;
}[],
dataStream: UIMessageStreamWriter,
) => {
return workflows
.map((v) =>
workflowToVercelAITool({
...v,
dataStream,
}),
)
.reduce(
(prev, cur) => {
prev[cur._toolName] = cur;
return prev;
},
{} as Record<string, VercelAIWorkflowTool>,
);
};
export const loadMcpTools = async (opt?: {
mentions?: ChatMention[];
allowedMcpServers?: Record<string, AllowedMCPServer>;
}) => {
try {
const tools = await mcpClientsManager.tools();
if (opt?.mentions?.length) {
return filterMCPToolsByMentions(tools, opt.mentions);
}
return filterMCPToolsByAllowedMCPServers(tools, opt?.allowedMcpServers);
} catch (e) {
return {} as Record<string, VercelAIMcpTool>;
}
};
export const loadWorkFlowTools = async (opt: {
mentions?: ChatMention[];
dataStream: UIMessageStreamWriter;
}) => {
try {
const tools = opt?.mentions?.length
? await workflowRepository.selectToolByIds(
opt?.mentions
?.filter((m) => m.type == "workflow")
.map(
(v) =>
(v as Extract<ChatMention, { type: "workflow" }>).workflowId,
),
)
: [];
return workflowToVercelAITools(tools, opt.dataStream);
} catch (e) {
return {} as Record<string, VercelAIWorkflowTool>;
}
};
export const loadAppDefaultTools = (opt?: {
mentions?: ChatMention[];
allowedAppDefaultToolkit?: string[];
}) =>
safe(APP_DEFAULT_TOOL_KIT)
.map((tools) => {
if (opt?.mentions?.length) {
const defaultToolMentions = opt.mentions.filter(
(m) => m.type == "defaultTool",
);
return Array.from(Object.values(tools)).reduce((acc, t) => {
const allowed = objectFlow(t).filter((_, k) => {
return defaultToolMentions.some((m) => m.name == k);
});
return { ...acc, ...allowed };
}, {});
}
const allowedAppDefaultToolkit =
opt?.allowedAppDefaultToolkit ?? Object.values(AppDefaultToolkit);
return (
allowedAppDefaultToolkit.reduce(
(acc, key) => {
return { ...acc, ...tools[key] };
},
{} as Record<string, Tool>,
) || {}
);
})
.ifFail((e) => {
console.error(e);
throw e;
})
.orElse({} as Record<string, Tool>);
export const convertToSavePart = <T extends UIMessagePart<any, any>>(
part: T,
) => {
return safe(
exclude(part as any, ["providerMetadata", "callProviderMetadata"]) as T,
)
.map((v) => {
if (isToolUIPart(v) && v.state.startsWith("output")) {
if (VercelAIWorkflowToolStreamingResultTag.isMaybe(v.output)) {
return {
...v,
output: {
...v.output,
history: v.output.history.map((h: any) => {
return {
...h,
result: undefined,
};
}),
},
};
}
}
return v;
})
.unwrap();
};