feat: implement MCP UIs

This commit is contained in:
Li Xin
2025-04-24 15:41:33 +08:00
parent d9ffb19950
commit 10b1d63834
32 changed files with 1419 additions and 321 deletions

View File

@@ -1,11 +1,11 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { env } from "~/env";
import type { MCPServerMetadata } from "../mcp";
import { fetchStream } from "../sse";
import { sleep } from "../utils";
import { resolveServiceURL } from "./resolve-service-url";
import type { ChatEvent } from "./types";
export function chatStream(
@@ -15,23 +15,29 @@ export function chatStream(
max_plan_iterations: number;
max_step_num: number;
interrupt_feedback?: string;
mcp_settings?: {
servers: Record<
string,
MCPServerMetadata & {
enabled_tools: string[];
add_to_agents: string[];
}
>;
};
},
options: { abortSignal?: AbortSignal } = {},
) {
if (location.search.includes("mock")) {
return chatStreamMock(userMessage, params, options);
}
return fetchStream<ChatEvent>(
(env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000/api") + "/chat/stream",
{
body: JSON.stringify({
messages: [{ role: "user", content: userMessage }],
auto_accepted_plan: false,
...params,
}),
signal: options.abortSignal,
},
);
return fetchStream<ChatEvent>(resolveServiceURL("chat/stream"), {
body: JSON.stringify({
messages: [{ role: "user", content: userMessage }],
auto_accepted_plan: false,
...params,
}),
signal: options.abortSignal,
});
}
async function* chatStreamMock(

View File

@@ -2,4 +2,6 @@
// SPDX-License-Identifier: MIT
export * from "./chat";
export * from "./mcp";
export * from "./podcast";
export * from "./types";

20
web/src/core/api/mcp.ts Normal file
View File

@@ -0,0 +1,20 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import type { SimpleMCPServerMetadata } from "../mcp";
import { resolveServiceURL } from "./resolve-service-url";
export async function queryMCPServerMetadata(config: SimpleMCPServerMetadata) {
const response = await fetch(resolveServiceURL("mcp/server/metadata"), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(config),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}

View File

@@ -1,20 +1,16 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { env } from "~/env";
import { resolveServiceURL } from "./resolve-service-url";
export async function generatePodcast(content: string) {
const response = await fetch(
(env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000/api") +
"/podcast/generate",
{
method: "post",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ content }),
const response = await fetch(resolveServiceURL("podcast/generate"), {
method: "post",
headers: {
"Content-Type": "application/json",
},
);
body: JSON.stringify({ content }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}

View File

@@ -0,0 +1,12 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { env } from "~/env";
export function resolveServiceURL(path: string) {
let BASE_URL = env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000/api/";
if (!BASE_URL.endsWith("/")) {
BASE_URL += "/";
}
return new URL(path, BASE_URL).toString();
}

View File

@@ -0,0 +1,6 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
export * from "./schema";
export * from "./types";
export * from "./utils";

View File

@@ -0,0 +1,57 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { z } from "zod";
export const MCPConfigSchema = z.object({
mcpServers: z.record(
z.union(
[
z.object({
command: z.string({
message: "`command` must be a string",
}),
args: z
.array(z.string(), {
message: "`args` must be an array of strings",
})
.optional(),
env: z
.record(z.string(), {
message: "`env` must be an object of key-value pairs",
})
.optional(),
}),
z.object({
url: z
.string({
message:
"`url` must be a valid URL starting with http:// or https://",
})
.refine(
(value) => {
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
},
{
message:
"`url` must be a valid URL starting with http:// or https://",
},
),
env: z
.record(z.string(), {
message: "`env` must be an object of key-value pairs",
})
.optional(),
}),
],
{
message: "Invalid server type",
},
),
),
});

43
web/src/core/mcp/types.ts Normal file
View File

@@ -0,0 +1,43 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
export interface MCPToolMetadata {
name: string;
description: string;
inputSchema?: Record<string, unknown>;
}
export interface GenericMCPServerMetadata<T extends string> {
name: string;
transport: T;
enabled: boolean;
env?: Record<string, string>;
tools: MCPToolMetadata[];
createdAt: number;
updatedAt: number;
}
export interface StdioMCPServerMetadata
extends GenericMCPServerMetadata<"stdio"> {
transport: "stdio";
command: string;
args?: string[];
}
export type SimpleStdioMCPServerMetadata = Omit<
StdioMCPServerMetadata,
"enabled" | "tools" | "createdAt" | "updatedAt"
>;
export interface SSEMCPServerMetadata extends GenericMCPServerMetadata<"sse"> {
transport: "sse";
url: string;
}
export type SimpleSSEMCPServerMetadata = Omit<
SSEMCPServerMetadata,
"enabled" | "tools" | "createdAt" | "updatedAt"
>;
export type MCPServerMetadata = StdioMCPServerMetadata | SSEMCPServerMetadata;
export type SimpleMCPServerMetadata =
| SimpleStdioMCPServerMetadata
| SimpleSSEMCPServerMetadata;

16
web/src/core/mcp/utils.ts Normal file
View File

@@ -0,0 +1,16 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { useSettingsStore } from "../store";
export function findMCPTool(name: string) {
const mcpServers = useSettingsStore.getState().mcp.servers;
for (const server of mcpServers) {
for (const tool of server.tools) {
if (tool.name === name) {
return tool;
}
}
}
return null;
}

View File

@@ -1,5 +1,10 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { create } from "zustand";
import type { MCPServerMetadata } from "../mcp";
const SETTINGS_KEY = "deerflow.settings";
const DEFAULT_SETTINGS: SettingsState = {
@@ -7,6 +12,34 @@ const DEFAULT_SETTINGS: SettingsState = {
maxPlanIterations: 1,
maxStepNum: 3,
},
mcp: {
servers: [
{
enabled: true,
name: "Zapier",
transport: "sse",
url: "https://actions.zapier.com/mcp/sk-ak-OnJ4kVKzxcLjpvpLChkT7RCYuh/sse",
env: { API_KEY: "123" },
createdAt: new Date("2025-04-20").valueOf(),
updatedAt: new Date("2025-04-20").valueOf(),
tools: [
{
name: "youtube_get_report",
description:
"Creates a report on specified data from your owned and managed channels.",
},
{
name: "edit_actions",
description: "Edit your existing MCP provider actions",
},
{
name: "add_actions",
description: "Add new actions to your MCP provider",
},
],
},
],
},
};
export type SettingsState = {
@@ -14,6 +47,9 @@ export type SettingsState = {
maxPlanIterations: number;
maxStepNum: number;
};
mcp: {
servers: MCPServerMetadata[];
};
};
export const useSettingsStore = create<SettingsState>(() => ({

View File

@@ -4,13 +4,13 @@
import { nanoid } from "nanoid";
import { create } from "zustand";
import { chatStream } from "../api";
import { generatePodcast } from "../api/podcast";
import { chatStream, generatePodcast } from "../api";
import type { Message } from "../messages";
import { mergeMessage } from "../messages";
import { parseJSON } from "../utils";
import { useSettingsStore } from "./settings-store";
import type { MCPServerMetadata, SimpleMCPServerMetadata } from "../mcp";
const THREAD_ID = nanoid();
@@ -57,7 +57,52 @@ export async function sendMessage(
setResponding(true);
try {
const generalSettings = useSettingsStore.getState().general;
const settings = useSettingsStore.getState();
const generalSettings = settings.general;
const mcpServers = settings.mcp.servers.filter((server) => server.enabled);
let mcpSettings:
| {
servers: Record<
string,
MCPServerMetadata & {
enabled_tools: string[];
add_to_agents: string[];
}
>;
}
| undefined = undefined;
if (mcpServers.length > 0) {
mcpSettings = {
servers: mcpServers.reduce((acc, cur) => {
const { transport, env } = cur;
let server: SimpleMCPServerMetadata;
if (transport === "stdio") {
server = {
name: cur.name,
transport,
env,
command: cur.command,
args: cur.args,
};
} else {
server = {
name: cur.name,
transport,
env,
url: cur.url,
};
}
return {
...acc,
[cur.name]: {
...server,
enabled_tools: cur.tools.map((tool) => tool.name),
add_to_agents: ["researcher"],
},
};
}, {}),
};
}
const stream = chatStream(
content,
{
@@ -65,6 +110,7 @@ export async function sendMessage(
max_plan_iterations: generalSettings.maxPlanIterations,
max_step_num: generalSettings.maxStepNum,
interrupt_feedback: interruptFeedback,
mcp_settings: mcpSettings,
},
options,
);