Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 45 additions & 3 deletions src/app/(general)/_components/chat/messages/message-tool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { AnimatedShinyText } from "@/components/magicui/animated-shiny-text";
import { Card } from "@/components/ui/card";
import { HStack } from "@/components/ui/stack";
import { getClientToolkit } from "@/toolkits/toolkits/client";
import type { Toolkits, ServerToolkitNames } from "@/toolkits/toolkits/shared";
import type { Toolkits } from "@/toolkits/toolkits/shared";
import type { CreateMessage, DeepPartial, ToolInvocation } from "ai";
import { Loader2 } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
Expand Down Expand Up @@ -54,8 +54,17 @@ const MessageToolComponent: React.FC<Props> = ({ toolInvocation }) => {
);
}

const typedTool = tool as ServerToolkitNames[typeof typedServer];
const toolConfig = clientToolkit.tools[typedTool];
const toolConfig = clientToolkit.tools[tool];

if (!toolConfig) {
return (
<FallbackToolInvocation
toolInvocation={toolInvocation}
icon={clientToolkit.icon}
toolkitName={clientToolkit.name}
/>
);
}

return (
<motion.div
Expand Down Expand Up @@ -217,6 +226,39 @@ const MessageToolComponent: React.FC<Props> = ({ toolInvocation }) => {
);
};

const FallbackToolInvocation: React.FC<
Props & {
icon: React.FC<{ className?: string }>;
toolkitName: string;
}
> = ({ toolInvocation, icon: Icon, toolkitName }) => {
const argsDefined = toolInvocation.args !== undefined;
const isRunning =
toolInvocation.state === "call" || toolInvocation.state === "partial-call";

return (
<Card className="gap-0 overflow-hidden p-0">
<HStack className="border-b p-2">
<Icon className="size-4" />
<span className="text-lg font-medium">{toolkitName} Toolkit</span>
{isRunning && <Loader2 className="size-4 animate-spin opacity-60" />}
</HStack>
<div className="space-y-2 p-2">
{argsDefined && (
<pre className="bg-muted w-full max-w-full rounded-md p-2 text-xs whitespace-pre-wrap">
{JSON.stringify(toolInvocation.args, null, 2)}
</pre>
)}
{toolInvocation.state === "result" && (
<pre className="bg-muted w-full max-w-full rounded-md p-2 text-xs whitespace-pre-wrap">
{JSON.stringify(toolInvocation.result, null, 2)}
</pre>
)}
</div>
</Card>
);
};

const areEqual = (prevProps: Props, nextProps: Props): boolean => {
const { toolInvocation: prev } = prevProps;
const { toolInvocation: next } = nextProps;
Expand Down
5 changes: 2 additions & 3 deletions src/app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,9 +158,8 @@ export async function POST(request: Request) {
toolkits.map(async ({ id, parameters }) => {
const toolkit = getServerToolkit(id);
const tools = await toolkit.tools(parameters);
return Object.keys(tools).reduce(
(acc, toolName) => {
const serverTool = tools[toolName as keyof typeof tools];
return Object.entries(tools).reduce(
(acc, [toolName, serverTool]) => {
acc[`${id}_${toolName}`] = tool({
description: serverTool.description,
parameters: serverTool.inputSchema,
Expand Down
2 changes: 2 additions & 0 deletions src/toolkits/toolkits/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { spotifyClientToolkit } from "./spotify/client";
import { etsyClientToolkit } from "./etsy/client";
import { videoClientToolkit } from "./video/client";
import { twitterClientToolkit } from "./twitter/client";
import { mcpClientToolkit } from "./mcp/client";

export type ClientToolkits = {
[K in Toolkits]: ClientToolkit<
Expand All @@ -42,6 +43,7 @@ export const clientToolkits: ClientToolkits = {
[Toolkits.Etsy]: etsyClientToolkit,
[Toolkits.Video]: videoClientToolkit,
[Toolkits.Twitter]: twitterClientToolkit,
[Toolkits.Mcp]: mcpClientToolkit,
};

export function getClientToolkit<T extends Toolkits>(
Expand Down
47 changes: 47 additions & 0 deletions src/toolkits/toolkits/mcp/base.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { BaseTool, ToolkitConfig } from "@/toolkits/types";
import { z } from "zod";

const headersSchema = z
.string()
.optional()
.refine(
(headers) => {
const trimmedHeaders = headers?.trim();
if (!trimmedHeaders) return true;

try {
const parsed: unknown = JSON.parse(trimmedHeaders);

return (
!!parsed &&
!Array.isArray(parsed) &&
typeof parsed === "object" &&
Object.values(parsed).every((value) => typeof value === "string")
);
} catch {
return false;
}
},
{
message: "Headers must be a JSON object with string values.",
},
);

export const mcpParameters = z.object({
serverUrl: z
.string()
.trim()
.url()
.refine((value) => new URL(value).protocol === "https:", {
message: "MCP server URL must use HTTPS.",
}),
headers: headersSchema,
});
Comment on lines +30 to +39

export const baseMcpToolkitConfig: ToolkitConfig<
string,
typeof mcpParameters.shape
> = {
tools: {} as Record<string, BaseTool>,
parameters: mcpParameters,
};
23 changes: 23 additions & 0 deletions src/toolkits/toolkits/mcp/client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Cable } from "lucide-react";

import { createClientToolkit } from "@/toolkits/create-toolkit";
import { ToolkitGroups } from "@/toolkits/types";

import { baseMcpToolkitConfig } from "./base";
import { Form } from "./form";

export const mcpClientToolkit = createClientToolkit<
string,
typeof baseMcpToolkitConfig.parameters.shape
>(
baseMcpToolkitConfig,
{
name: "Hosted MCP",
description: "Connect tools from a hosted MCP server",
icon: Cable,
form: Form,
type: ToolkitGroups.DataSource,
envVars: [],
},
{},
);
52 changes: 52 additions & 0 deletions src/toolkits/toolkits/mcp/form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"use client";

import type React from "react";
import type { z, ZodObject } from "zod";

import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { VStack } from "@/components/ui/stack";

import type { mcpParameters } from "./base";

export const Form: React.ComponentType<{
parameters: z.infer<ZodObject<typeof mcpParameters.shape>>;
setParameters: (
parameters: z.infer<ZodObject<typeof mcpParameters.shape>>,
) => void;
}> = ({ parameters, setParameters }) => {
return (
<VStack className="items-stretch gap-4 px-4 py-2">
<div className="grid gap-2">
<Label htmlFor="mcp-server-url">Server URL</Label>
<Input
id="mcp-server-url"
placeholder="https://example.com/mcp"
value={parameters.serverUrl ?? ""}
onChange={(event) =>
setParameters({
...parameters,
serverUrl: event.target.value,
})
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="mcp-headers">Headers</Label>
<Textarea
id="mcp-headers"
className="min-h-24 font-mono text-sm"
placeholder='{ "Authorization": "Bearer ..." }'
value={parameters.headers ?? ""}
onChange={(event) =>
setParameters({
...parameters,
headers: event.target.value,
})
}
/>
</div>
</VStack>
);
};
Loading