From c3108ad333419ecb0d16a031d4f4603f0f781832 Mon Sep 17 00:00:00 2001 From: Kadxy <2230318258@qq.com> Date: Sat, 28 Dec 2024 14:31:43 +0800 Subject: [PATCH] feat: simple MCP example --- app/mcp/actions.ts | 33 ++++++++++++++++ app/mcp/client.ts | 87 ++++++++++++++++++++++++++++++++++++++++ app/mcp/example.ts | 92 +++++++++++++++++++++++++++++++++++++++++++ app/mcp/logger.ts | 60 ++++++++++++++++++++++++++++ app/mcp/mcp_config.ts | 40 +++++++++++++++++++ app/store/chat.ts | 19 ++++++++- next.config.mjs | 9 +++-- package.json | 6 ++- tsconfig.json | 4 +- yarn.lock | 72 ++++++++++++++++++++++++++++++++- 10 files changed, 413 insertions(+), 9 deletions(-) create mode 100644 app/mcp/actions.ts create mode 100644 app/mcp/client.ts create mode 100644 app/mcp/example.ts create mode 100644 app/mcp/logger.ts create mode 100644 app/mcp/mcp_config.ts diff --git a/app/mcp/actions.ts b/app/mcp/actions.ts new file mode 100644 index 000000000..3d6ca4a68 --- /dev/null +++ b/app/mcp/actions.ts @@ -0,0 +1,33 @@ +"use server"; + +import { createClient, executeRequest } from "./client"; +import { MCPClientLogger } from "./logger"; +import { MCP_CONF } from "@/app/mcp/mcp_config"; + +const logger = new MCPClientLogger("MCP Server"); + +let fsClient: any = null; + +async function initFileSystemClient() { + if (!fsClient) { + fsClient = await createClient(MCP_CONF.filesystem, "fs"); + logger.success("FileSystem client initialized"); + } + return fsClient; +} + +export async function executeMcpAction(request: any) { + "use server"; + + try { + if (!fsClient) { + await initFileSystemClient(); + } + + logger.info("Executing MCP request for fs"); + return await executeRequest(fsClient, request); + } catch (error) { + logger.error(`MCP execution error: ${error}`); + throw error; + } +} diff --git a/app/mcp/client.ts b/app/mcp/client.ts new file mode 100644 index 000000000..d71314f3a --- /dev/null +++ b/app/mcp/client.ts @@ -0,0 +1,87 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { MCPClientLogger } from "./logger"; +import { z } from "zod"; + +export interface ServerConfig { + command: string; + args?: string[]; + env?: Record; +} + +const logger = new MCPClientLogger(); + +export async function createClient( + serverConfig: ServerConfig, + name: string, +): Promise { + logger.info(`Creating client for server ${name}`); + + const transport = new StdioClientTransport({ + command: serverConfig.command, + args: serverConfig.args, + env: serverConfig.env, + }); + const client = new Client( + { + name: `nextchat-mcp-client-${name}`, + version: "1.0.0", + }, + { + capabilities: { + roots: { + // listChanged indicates whether the client will emit notifications when the list of roots changes. + // listChanged 指示客户端在根列表更改时是否发出通知。 + listChanged: true, + }, + }, + }, + ); + await client.connect(transport); + return client; +} + +interface Primitive { + type: "resource" | "tool" | "prompt"; + value: any; +} + +/** List all resources, tools, and prompts */ +export async function listPrimitives(client: Client) { + const capabilities = client.getServerCapabilities(); + const primitives: Primitive[] = []; + const promises = []; + if (capabilities?.resources) { + promises.push( + client.listResources().then(({ resources }) => { + resources.forEach((item) => + primitives.push({ type: "resource", value: item }), + ); + }), + ); + } + if (capabilities?.tools) { + promises.push( + client.listTools().then(({ tools }) => { + tools.forEach((item) => primitives.push({ type: "tool", value: item })); + }), + ); + } + if (capabilities?.prompts) { + promises.push( + client.listPrompts().then(({ prompts }) => { + prompts.forEach((item) => + primitives.push({ type: "prompt", value: item }), + ); + }), + ); + } + await Promise.all(promises); + return primitives; +} + +export async function executeRequest(client: Client, request: any) { + const r = client.request(request, z.any()); + console.log(r); + return r; +} diff --git a/app/mcp/example.ts b/app/mcp/example.ts new file mode 100644 index 000000000..d924ba664 --- /dev/null +++ b/app/mcp/example.ts @@ -0,0 +1,92 @@ +import { createClient, listPrimitives } from "@/app/mcp/client"; +import { MCPClientLogger } from "@/app/mcp/logger"; +import { z } from "zod"; +import { MCP_CONF } from "@/app/mcp/mcp_config"; + +const logger = new MCPClientLogger("MCP FS Example", true); + +const ListAllowedDirectoriesResultSchema = z.object({ + content: z.array( + z.object({ + type: z.string(), + text: z.string(), + }), + ), +}); + +const ReadFileResultSchema = z.object({ + content: z.array( + z.object({ + type: z.string(), + text: z.string(), + }), + ), +}); + +async function main() { + logger.info("Connecting to server..."); + + const client = await createClient(MCP_CONF.filesystem, "fs"); + const primitives = await listPrimitives(client); + + logger.success(`Connected to server fs`); + + logger.info( + `server capabilities: ${Object.keys( + client.getServerCapabilities() ?? [], + ).join(", ")}`, + ); + + logger.debug("Server supports the following primitives:"); + + primitives.forEach((primitive) => { + logger.debug("\n" + JSON.stringify(primitive, null, 2)); + }); + + const listAllowedDirectories = async () => { + const result = await client.request( + { + method: "tools/call", + params: { + name: "list_allowed_directories", + arguments: {}, + }, + }, + ListAllowedDirectoriesResultSchema, + ); + logger.success(`Allowed directories: ${result.content[0].text}`); + return result; + }; + + const readFile = async (path: string) => { + const result = await client.request( + { + method: "tools/call", + params: { + name: "read_file", + arguments: { + path: path, + }, + }, + }, + ReadFileResultSchema, + ); + logger.success(`File contents for ${path}:\n${result.content[0].text}`); + return result; + }; + + try { + logger.info("Example 1: List allowed directories\n"); + await listAllowedDirectories(); + + logger.info("\nExample 2: Read a file\n"); + await readFile("/users/kadxy/desktop/test.txt"); + } catch (error) { + logger.error(`Error executing examples: ${error}`); + } +} + +main().catch((error) => { + logger.error(error); + process.exit(1); +}); diff --git a/app/mcp/logger.ts b/app/mcp/logger.ts new file mode 100644 index 000000000..a39304afe --- /dev/null +++ b/app/mcp/logger.ts @@ -0,0 +1,60 @@ +const colors = { + reset: "\x1b[0m", + bright: "\x1b[1m", + dim: "\x1b[2m", + green: "\x1b[32m", + yellow: "\x1b[33m", + red: "\x1b[31m", + blue: "\x1b[34m", +}; + +export class MCPClientLogger { + private readonly prefix: string; + private readonly debugMode: boolean; + + constructor( + prefix: string = "NextChat MCP Client", + debugMode: boolean = false, + ) { + this.prefix = prefix; + this.debugMode = debugMode; + } + + info(message: any) { + this.log(colors.blue, message); + } + + success(message: any) { + this.log(colors.green, message); + } + + error(message: any) { + const formattedMessage = this.formatMessage(message); + console.error( + `${colors.red}${colors.bright}[${this.prefix}]${colors.reset} ${formattedMessage}`, + ); + } + + warn(message: any) { + this.log(colors.yellow, message); + } + + debug(message: any) { + if (this.debugMode) { + this.log(colors.dim, message); + } + } + + private formatMessage(message: any): string { + return typeof message === "object" + ? JSON.stringify(message, null, 2) + : message; + } + + private log(color: string, message: any) { + const formattedMessage = this.formatMessage(message); + console.log( + `${color}${colors.bright}[${this.prefix}]${colors.reset} ${formattedMessage}`, + ); + } +} diff --git a/app/mcp/mcp_config.ts b/app/mcp/mcp_config.ts new file mode 100644 index 000000000..044d04052 --- /dev/null +++ b/app/mcp/mcp_config.ts @@ -0,0 +1,40 @@ +export const MCP_CONF = { + "brave-search": { + command: "npx", + args: ["-y", "@modelcontextprotocol/server-brave-search"], + env: { + BRAVE_API_KEY: "", + }, + }, + filesystem: { + command: "npx", + args: [ + "-y", + "@modelcontextprotocol/server-filesystem", + "/Users/kadxy/Desktop", + ], + }, + github: { + command: "npx", + args: ["-y", "@modelcontextprotocol/server-github"], + env: { + GITHUB_PERSONAL_ACCESS_TOKEN: "", + }, + }, + "google-maps": { + command: "npx", + args: ["-y", "@modelcontextprotocol/server-google-maps"], + env: { + GOOGLE_MAPS_API_KEY: "", + }, + }, + "aws-kb-retrieval": { + command: "npx", + args: ["-y", "@modelcontextprotocol/server-aws-kb-retrieval"], + env: { + AWS_ACCESS_KEY_ID: "", + AWS_SECRET_ACCESS_KEY: "", + AWS_REGION: "", + }, + }, +}; diff --git a/app/store/chat.ts b/app/store/chat.ts index 63d7394ec..27d1f8620 100644 --- a/app/store/chat.ts +++ b/app/store/chat.ts @@ -29,6 +29,7 @@ import { ModelConfig, ModelType, useAppConfig } from "./config"; import { useAccessStore } from "./access"; import { collectModelsWithDefaultModel } from "../utils/model"; import { createEmptyMask, Mask } from "./mask"; +import { executeMcpAction } from "../mcp/actions"; const localStorage = safeLocalStorage(); @@ -425,9 +426,25 @@ export const useChatStore = createPersistStore( session.messages = session.messages.concat(); }); }, - onFinish(message) { + async onFinish(message) { botMessage.streaming = false; if (message) { + // console.log("[Bot Response] ", message); + const mcpMatch = message.match(/```json:mcp([\s\S]*?)```/); + if (mcpMatch) { + try { + const mcp = JSON.parse(mcpMatch[1]); + console.log("[MCP Request]", mcp); + + // 直接调用服务器端 action + const result = await executeMcpAction(mcp); + console.log("[MCP Response]", result); + } catch (error) { + console.error("[MCP Error]", error); + } + } else { + console.log("[MCP] No MCP found in response"); + } botMessage.content = message; botMessage.date = new Date().toLocaleString(); get().onNewMessage(botMessage, session); diff --git a/next.config.mjs b/next.config.mjs index 2bb6bc4f4..802419139 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -32,6 +32,7 @@ const nextConfig = { }, experimental: { forceSwcTransforms: true, + serverActions: true, }, }; @@ -71,8 +72,10 @@ if (mode !== "export") { // }, { // https://{resource_name}.openai.azure.com/openai/deployments/{deploy_name}/chat/completions - source: "/api/proxy/azure/:resource_name/deployments/:deploy_name/:path*", - destination: "https://:resource_name.openai.azure.com/openai/deployments/:deploy_name/:path*", + source: + "/api/proxy/azure/:resource_name/deployments/:deploy_name/:path*", + destination: + "https://:resource_name.openai.azure.com/openai/deployments/:deploy_name/:path*", }, { source: "/api/proxy/google/:path*", @@ -99,7 +102,7 @@ if (mode !== "export") { destination: "https://dashscope.aliyuncs.com/api/:path*", }, ]; - + return { beforeFiles: ret, }; diff --git a/package.json b/package.json index e081567a4..a17f8ffa9 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "dependencies": { "@fortaine/fetch-event-source": "^3.0.6", "@hello-pangea/dnd": "^16.5.0", + "@modelcontextprotocol/sdk": "^1.0.4", "@next/third-parties": "^14.1.0", "@svgr/webpack": "^6.5.1", "@vercel/analytics": "^0.1.11", @@ -49,11 +50,12 @@ "remark-breaks": "^3.0.2", "remark-gfm": "^3.0.1", "remark-math": "^5.1.1", + "rt-client": "https://github.com/Azure-Samples/aoai-realtime-audio-sdk/releases/download/js/v0.5.0/rt-client-0.5.0.tgz", "sass": "^1.59.2", "spark-md5": "^3.0.2", "use-debounce": "^9.0.4", - "zustand": "^4.3.8", - "rt-client": "https://github.com/Azure-Samples/aoai-realtime-audio-sdk/releases/download/js/v0.5.0/rt-client-0.5.0.tgz" + "zod": "^3.24.1", + "zustand": "^4.3.8" }, "devDependencies": { "@tauri-apps/api": "^1.6.0", diff --git a/tsconfig.json b/tsconfig.json index c73eef3e8..6d24b42f1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "ES2015", + "target": "ES2022", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, @@ -23,6 +23,6 @@ "@/*": ["./*"] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "app/calcTextareaHeight.ts"], + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "exclude": ["node_modules"] } diff --git a/yarn.lock b/yarn.lock index dffc35e9c..138f3c851 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1797,6 +1797,15 @@ "@jridgewell/resolve-uri" "3.1.0" "@jridgewell/sourcemap-codec" "1.4.14" +"@modelcontextprotocol/sdk@^1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@modelcontextprotocol/sdk/-/sdk-1.0.4.tgz#34ad1edd3db7dd7154e782312dfb29d2d0c11d21" + integrity sha512-C+jw1lF6HSGzs7EZpzHbXfzz9rj9him4BaoumlTciW/IDDgIpweF/qiCWKlP02QKg5PPcgY6xY2WCt5y2tpYow== + dependencies: + content-type "^1.0.5" + raw-body "^3.0.0" + zod "^3.23.8" + "@next/env@14.1.1": version "14.1.1" resolved "https://registry.yarnpkg.com/@next/env/-/env-14.1.1.tgz#80150a8440eb0022a73ba353c6088d419b908bac" @@ -3039,6 +3048,11 @@ busboy@1.6.0: dependencies: streamsearch "^1.1.0" +bytes@3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" + integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== + call-bind@^1.0.0, call-bind@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c" @@ -3285,6 +3299,11 @@ concurrently@^8.2.2: tree-kill "^1.2.2" yargs "^17.7.2" +content-type@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" + integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== + convert-source-map@^1.7.0: version "1.9.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f" @@ -3849,6 +3868,11 @@ delayed-stream@~1.0.0: resolved "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== +depd@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" + integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== + dequal@^2.0.0, dequal@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" @@ -5007,6 +5031,17 @@ html-to-image@^1.11.11: resolved "https://registry.npmmirror.com/html-to-image/-/html-to-image-1.11.11.tgz#c0f8a34dc9e4b97b93ff7ea286eb8562642ebbea" integrity sha512-9gux8QhvjRO/erSnDPv28noDZcPZmYE7e1vFsBLKLlRlKDSqNJYebj6Qz1TGd5lsRV+X+xYyjCKjuZdABinWjA== +http-errors@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" + integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== + dependencies: + depd "2.0.0" + inherits "2.0.4" + setprototypeof "1.2.0" + statuses "2.0.1" + toidentifier "1.0.1" + http-proxy-agent@^5.0.0: version "5.0.0" resolved "https://registry.npmmirror.com/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz#5129800203520d434f142bc78ff3c170800f2b43" @@ -5095,7 +5130,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2: +inherits@2, inherits@2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -7138,6 +7173,16 @@ randombytes@^2.1.0: dependencies: safe-buffer "^5.1.0" +raw-body@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-3.0.0.tgz#25b3476f07a51600619dae3fe82ddc28a36e5e0f" + integrity sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g== + dependencies: + bytes "3.1.2" + http-errors "2.0.0" + iconv-lite "0.6.3" + unpipe "1.0.0" + react-dom@^18.2.0: version "18.2.0" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d" @@ -7569,6 +7614,11 @@ serialize-javascript@^6.0.1: dependencies: randombytes "^2.1.0" +setprototypeof@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" + integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== + shebang-command@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" @@ -7699,6 +7749,11 @@ stack-utils@^2.0.3: dependencies: escape-string-regexp "^2.0.0" +statuses@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" + integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== + stop-iteration-iterator@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz#6a60be0b4ee757d1ed5254858ec66b10c49285e4" @@ -7977,6 +8032,11 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" +toidentifier@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" + integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== + tough-cookie@^4.1.2: version "4.1.4" resolved "https://registry.npmmirror.com/tough-cookie/-/tough-cookie-4.1.4.tgz#945f1461b45b5a8c76821c33ea49c3ac192c1b36" @@ -8219,6 +8279,11 @@ universalify@^0.2.0: resolved "https://registry.npmmirror.com/universalify/-/universalify-0.2.0.tgz#6451760566fa857534745ab1dde952d1b1761be0" integrity sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg== +unpipe@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" + integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== + update-browserslist-db@^1.0.10: version "1.0.10" resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz#0f54b876545726f17d00cd9a2561e6dade943ff3" @@ -8572,6 +8637,11 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== +zod@^3.23.8, zod@^3.24.1: + version "3.24.1" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.24.1.tgz#27445c912738c8ad1e9de1bea0359fa44d9d35ee" + integrity sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A== + zustand@^4.3.8: version "4.3.8" resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.3.8.tgz#37113df8e9e1421b0be1b2dca02b49b76210e7c4"