diff options
| author | sigoden <sigoden@gmail.com> | 2024-12-11 20:46:17 +0800 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2024-12-11 20:46:17 +0800 |
| commit | 20d1ec47f9970caa119c3715a1c0c7a69e5aa65f (patch) | |
| tree | 76b0d3585a40ce2b269fa50b54786aa865641920 | |
| parent | c58abcbaf89f27e5e3806f4309880a1eac2b7095 (diff) | |
| download | llm-functions-docker-20d1ec47f9970caa119c3715a1c0c7a69e5aa65f.tar.gz | |
feat: support MCP bridge (#140)
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | Argcfile.sh | 23 | ||||
| -rw-r--r-- | mcp/bridge/README.md | 42 | ||||
| -rw-r--r-- | mcp/bridge/index.js | 195 | ||||
| -rw-r--r-- | mcp/bridge/package.json | 22 | ||||
| -rw-r--r-- | scripts/mcp.sh | 176 | ||||
| -rwxr-xr-x | scripts/run-mcp-tool.sh | 93 |
7 files changed, 549 insertions, 5 deletions
@@ -14,4 +14,5 @@ __pycache__ node_modules /package.json package-lock.json -*.lock
\ No newline at end of file +*.lock +/mcp.json
\ No newline at end of file diff --git a/Argcfile.sh b/Argcfile.sh index 4c0747d..7c1e859 100644 --- a/Argcfile.sh +++ b/Argcfile.sh @@ -2,7 +2,7 @@ set -e BIN_DIR=bin -TMP_DIR="cache/tmp" +TMP_DIR="cache/__tmp__" VENV_DIR=".venv" LANG_CMDS=( \ @@ -421,9 +421,8 @@ test-demo@tool() { # @cmd Test agents # @alias agent:test test@agent() { - tmp_dir="cache/tmp" - mkdir -p "$tmp_dir" - names_file="$tmp_dir/agents.txt" + mkdir -p "$TMP_DIR" + names_file="$TMP_DIR/agents.txt" argc list@agent > "$names_file" argc build@agent --names-file "$names_file" test-demo@agent @@ -499,6 +498,12 @@ install() { fi } +# @cmd Run mcp command +# @arg args~[?`_choice_mcp_args`] The mcp command and arguments +mcp() { + bash ./scripts/mcp.sh "$@" +} + # @cmd Create a boilplate tool script # @alias tool:create # @arg args~ @@ -671,6 +676,16 @@ _choice_agent_action() { argc generate-declarations@agent "$1" --oneline | sed "$expr" } +_choice_mcp_args() { + if [[ "$ARGC_COMPGEN" -eq 1 ]]; then + args=( "${argc__positionals[@]}" ) + args[-1]="$ARGC_LAST_ARG" + argc --argc-compgen generic scripts/mcp.sh mcp "${args[@]}" + else + :; + fi +} + _die() { echo "$*" >&2 exit 1 diff --git a/mcp/bridge/README.md b/mcp/bridge/README.md new file mode 100644 index 0000000..bffa30c --- /dev/null +++ b/mcp/bridge/README.md @@ -0,0 +1,42 @@ +# MCP-Bridge + +Let MCP tools be used by LLM functions. + +## Get Started + +1. Create a `mpc.json` at `<llm-functions-dir>`. + +```json +{ + "mcpServers": { + "sqlite": { + "command": "uvx", + "args": [ + "mcp-server-sqlite", + "--db-path", + "/tmp/foo.db" + ] + }, + "github": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-github" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "<YOUR_TOKEN>" + } + } + } +} +``` + +> MCP-Bridge will launch the server and register all the tools listed by the server. The tool identifier will be `server_toolname` to avoid clashes. + +2. Run the bridge server, build mcp tool binaries, update functions.json, all with: + +``` +argc mcp start +``` + +> Run `argc mcp stop` to stop the bridge server, recover functions.json
\ No newline at end of file diff --git a/mcp/bridge/index.js b/mcp/bridge/index.js new file mode 100644 index 0000000..c978737 --- /dev/null +++ b/mcp/bridge/index.js @@ -0,0 +1,195 @@ +#!/usr/bin/env node + +import * as path from "node:path"; +import * as fs from "node:fs"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import express from "express"; + +const app = express(); +const PORT = process.env.MCP_BRIDGE_PORT || 8808; + +let [rootDir] = process.argv.slice(2); + +if (!rootDir) { + console.error("Usage: mcp-bridge <llm-functions-dir>"); + process.exit(1); +} + +let mcpServers = {}; +const mcpJsonPath = path.join(rootDir, "mcp.json"); +try { + const data = await fs.promises.readFile(mcpJsonPath, "utf8"); + mcpServers = JSON.parse(data)?.mcpServers; +} catch { + console.error(`Failed to read json at '${mcpJsonPath}'`); + process.exit(1); +} + +async function startMcpServer(id, serverConfig) { + console.log(`Starting ${id} server...`); + const capabilities = { tools: {} }; + const transport = new StdioClientTransport({ + ...serverConfig, + }); + const client = new Client( + { name: id, version: "1.0.0" }, + { capabilities } + ); + await client.connect(transport); + const { tools: toolDefinitions } = await client.listTools() + const tools = toolDefinitions.map( + ({ name, description, inputSchema }) => + ({ + spec: { + name: `${normalizeToolName(`${id}_${name}`)}`, + description, + parameters: inputSchema, + }, + impl: async args => { + const res = await client.callTool({ + name: name, + arguments: args, + }); + const content = res.content; + let text = arrayify(content)?.map(c => { + switch (c.type) { + case "text": + return c.text || "" + case "image": + return c.data + case "resource": + return c.resource?.uri || "" + default: + return c + } + }).join("\n"); + if (res.isError) { + text = `Tool Error\n${text}`; + } + return text; + }, + }) + ); + return { + tools, + [Symbol.asyncDispose]: async () => { + try { + console.log(`Closing ${id} server...`); + await client.close(); + await transport.close(); + } catch { } + }, + } +} + +async function runBridge() { + let hasError = false; + let runningMcpServers = await Promise.all( + Object.entries(mcpServers).map( + async ([name, serverConfig]) => { + try { + return await startMcpServer(name, serverConfig) + } catch (err) { + hasError = true; + console.error(`Failed to start ${name} server; ${err.message}`) + } + } + ) + ); + runningMcpServers = runningMcpServers.filter(s => !!s); + const stopMcpServers = () => Promise.all(runningMcpServers.map(s => s[Symbol.asyncDispose]())); + if (hasError) { + await stopMcpServers(); + return; + } + + const definitions = runningMcpServers.flatMap(s => s.tools.map(t => t.spec)); + const runTool = async (name, args) => { + for (const server of runningMcpServers) { + const tool = server.tools.find(t => t.spec.name === name); + if (tool) { + return tool.impl(args); + } + } + return `Not found tool '${name}'`; + }; + + app.use((err, _req, res, _next) => { + res.status(500).send(err?.message || err); + }); + + app.use(express.json()); + + app.get("/", (_req, res) => { + res.send(`# MCP Bridge API + +- POST /tools/:name + \`\`\` + curl -X POST http://localhost:8808/tools/filesystem_write_file \\ + -H 'content-type: application/json' \\ + -d '{"path": "/tmp/file1", "content": "hello world"}' + \`\`\` +- GET /tools + \`\`\` + curl http://localhost:8808/tools + \`\`\` + `); + }); + + app.get("/tools", (_req, res) => { + res.json(definitions); + }); + + app.post("/tools/:name", async (req, res) => { + try { + const output = await runTool(req.params.name, req.body); + res.send(output); + } catch (err) { + res.status(500).send(err); + } + }); + + app.get("/pid", (_req, res) => { + res.send(process.pid.toString()); + }); + + app.get("/health", (_req, res) => { + res.send("OK"); + }); + + app.use((_req, res, _next) => { + res.status(404).send("Not found"); + }); + + const server = app.listen(PORT, () => { + console.log(`Server is running on port ${PORT}`); + }); + + return async () => { + server.close(() => console.log("Http server closed")); + await stopMcpServers(); + }; +} + +function arrayify(a) { + let r; + if (a === undefined) r = []; + else if (Array.isArray(a)) r = a.slice(0); + else r = [a]; + + return r +} + +function normalizeToolName(name) { + return name.toLowerCase().replace(/-/g, "_"); +} + +runBridge() + .then(stop => { + if (stop) { + process.on('SIGINT', stop); + process.on('SIGTERM', stop); + } + }) + .catch(console.error);
\ No newline at end of file diff --git a/mcp/bridge/package.json b/mcp/bridge/package.json new file mode 100644 index 0000000..0e78df9 --- /dev/null +++ b/mcp/bridge/package.json @@ -0,0 +1,22 @@ +{ + "name": "mcp-bridge", + "version": "1.0.0", + "description": "Let MCP tools be used by LLM functions", + "license": "MIT", + "author": "sigoden <sigoden@gmail.com>", + "homepage": "https://github.com/sigoden/llm-functions/tree/main/mcp/bridge", + "repository": { + "type": "git", + "url": "git+https://github.com/sigoden/llm-functions.git", + "directory": "mcp/bridge" + }, + "private": true, + "type": "module", + "bin": { + "mcp-bridge": "index.js" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.3", + "express": "^4.21.2" + } +} diff --git a/scripts/mcp.sh b/scripts/mcp.sh new file mode 100644 index 0000000..e974476 --- /dev/null +++ b/scripts/mcp.sh @@ -0,0 +1,176 @@ +#!/usr/bin/env bash +set -e + +ROOT_DIR="$(cd -- "$( dirname -- "${BASH_SOURCE[0]}" )/.." &> /dev/null && pwd)" +BIN_DIR="$ROOT_DIR/bin" +MCP_DIR="$ROOT_DIR/cache/__mcp__" +MCP_JSON_PATH="$ROOT_DIR/mcp.json" +FUNCTIONS_JSON_PATH="$ROOT_DIR/functions.json" +MCP_BRIDGE_PORT="${MCP_BRIDGE_PORT:-8808}" + +# @cmd Start/Restart mcp bridge server +start() { + if [[ ! -f "$MCP_JSON_PATH" ]]; then + _die "error: not found mcp.json" + fi + stop + mkdir -p "$MCP_DIR" + index_js="$ROOT_DIR/mcp/bridge/index.js" + llm_functions_dir="$ROOT_DIR" + if _is_win; then + index_js="$(cygpath -w "$index_js")" + llm_functions_dir="$(cygpath -w "$llm_functions_dir")" + fi + echo "Run MCP Bridge server" + nohup node "$index_js" "$llm_functions_dir" > "$MCP_DIR/mcp-bridge.log" 2>&1 & + wait-for-server + echo "Merge MCP tools into functions.json" + merge-functions > "$MCP_DIR/functions.json" + cp -f "$MCP_DIR/functions.json" "$FUNCTIONS_JSON_PATH" + build-bin +} + +# @cmd Stop mcp bridge server +stop() { + pid="$(get-server-pid)" + if [[ -n "$pid" ]]; then + if _is_win; then + taskkill /PID "$pid" /F > /dev/null 2>&1 || true + else + kill -9 "$pid" > /dev/null 2>&1 || true + fi + fi + mkdir -p "$MCP_DIR" + unmerge-functions > "$MCP_DIR/functions.original.json" + cp -f "$MCP_DIR/functions.original.json" "$FUNCTIONS_JSON_PATH" +} + +# @cmd Call mcp tool +# @arg tool![`_choice_tool`] The tool name +# @arg json The json data +call() { + if [[ -z "$argc_json" ]]; then + declaration="$(build-declarations | jq --arg tool "$argc_tool" -r '.[] | select(.name == $tool)')" + if [[ -n "$declaration" ]]; then + _ask_json_data "$declaration" + fi + fi + if [[ -z "$argc_json" ]]; then + _die "error: no JSON data" + fi + bash "$ROOT_DIR/scripts/run-mcp-tool.sh" "$argc_tool" "$argc_json" +} + +# @cmd Show logs +# @flag -f --follow Follow mode +logs() { + args="" + if [[ -n "$argc_follow" ]]; then + args="$args -f" + fi + if [[ -f "$MCP_DIR/mcp-bridge.log" ]]; then + tail $args "$MCP_DIR/mcp-bridge.log" + fi +} + +# @cmd Build tools to bin +build-bin() { + tools=( $(build-declarations | jq -r '.[].name') ) + for tool in "${tools[@]}"; do + if _is_win; then + bin_file="$BIN_DIR/$tool.cmd" + _build_win_shim > "$bin_file" + else + bin_file="$BIN_DIR/$tool" + ln -s -f "$ROOT_DIR/scripts/run-mcp-tool.sh" "$bin_file" + fi + echo "Build bin/$tool" + done +} + +# @cmd Merge mcp tools into functions.json +merge-functions() { + jq --argjson json1 "$(unmerge-functions)" --argjson json2 "$(build-declarations)" -n '($json1 + $json2)' +} + +# @cmd Unmerge mcp tools from functions.json +unmerge-functions() { + functions="[]" + if [[ -f "$FUNCTIONS_JSON_PATH" ]]; then + functions="$(cat "$FUNCTIONS_JSON_PATH")" + fi + printf "%s" "$functions" | jq 'map(select(has("mcp") | not))' +} + +# @cmd Build tools to bin +build-declarations() { + curl -sS http://localhost:$MCP_BRIDGE_PORT/tools | jq '.[] |= . + {mcp: true}' +} + +# @cmd Wait for mcp bridge server to ready +wait-for-server() { + while true; do + if [[ "$(curl -fsS http://localhost:$MCP_BRIDGE_PORT/health 2>&1)" == "OK" ]]; then + break; + fi + sleep 1 + done +} + +# @cmd +get-server-pid() { + curl -fsSL http://localhost:$MCP_BRIDGE_PORT/pid 2>/dev/null || true +} + +_ask_json_data() { + declaration="$1" + echo 'Missing the JSON data but here are its properties:' + echo "$declaration" | ./scripts/declarations-util.sh pretty-print | sed -n '2,$s/^/>/p' + echo 'Generate placeholder data:' + data="$(echo "$declaration" | _declarations_json_data)" + echo "> $data" + read -e -r -p 'JSON data (Press ENTER to use placeholder): ' res + if [[ -z "$res" ]]; then + argc_json="$data" + else + argc_json="$res" + fi +} + +_declarations_json_data() { + ./scripts/declarations-util.sh generate-json | tail -n +2 +} + +_build_win_shim() { + run="\"$(argc --argc-shell-path)\" --noprofile --norc" + cat <<-EOF +@echo off +setlocal + +set "bin_dir=%~dp0" +for %%i in ("%bin_dir:~0,-1%") do set "script_dir=%%~dpi" +set "script_name=%~n0" + +$run "%script_dir%scripts\run-mcp-tool.sh" "%script_name%" %* +EOF +} + +_is_win() { + if [[ "$OS" == "Windows_NT" ]]; then + return 0 + else + return 1 + fi +} + +_choice_tool() { + build-declarations | jq -r '.[].name' +} + +_die() { + echo "$*" >&2 + exit 1 +} + +# See more details at https://github.com/sigoden/argc +eval "$(argc --argc-eval "$0" "$@")" diff --git a/scripts/run-mcp-tool.sh b/scripts/run-mcp-tool.sh new file mode 100755 index 0000000..94ccbe6 --- /dev/null +++ b/scripts/run-mcp-tool.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +set -e + +main() { + root_dir="$(cd -- "$( dirname -- "${BASH_SOURCE[0]}" )/.." &> /dev/null && pwd)" + self_name=run-mcp-tool.sh + parse_argv "$@" + load_env "$root_dir/.env" + run +} + +parse_argv() { + if [[ "$0" == *"$self_name" ]]; then + tool_name="$1" + tool_data="$2" + else + tool_name="$(basename "$0")" + tool_data="$1" + fi + if [[ "$tool_name" == *.sh ]]; then + tool_name="${tool_name:0:$((${#tool_name}-3))}" + fi + if [[ -z "$tool_data" ]] || [[ -z "$tool_name" ]]; then + die "usage: ./run-tool.sh <tool-name> <tool-data>" + fi +} + + +load_env() { + local env_file="$1" env_vars + if [[ -f "$env_file" ]]; then + while IFS='=' read -r key value; do + if [[ "$key" == $'#'* ]] || [[ -z "$key" ]]; then + continue + fi + if [[ -z "${!key+x}" ]]; then + env_vars="$env_vars $key=$value" + fi + done < <(cat "$env_file"; echo "") + if [[ -n "$env_vars" ]]; then + eval "export $env_vars" + fi + fi +} + +run() { + no_llm_output=0 + if [[ -z "$LLM_OUTPUT" ]]; then + no_llm_output=1 + export LLM_OUTPUT="$(mktemp)" + fi + curl -sS "http://localhost:${MCP_BRIDGE_PORT:-8808}/tools/$tool_name" \ + -X POST \ + -H 'content-type: application/json' \ + -d "$tool_data" > "$LLM_OUTPUT" + + if [[ "$no_llm_output" -eq 1 ]]; then + cat "$LLM_OUTPUT" + else + dump_result + fi +} + +dump_result() { + if [ ! -t 1 ]; then + return; + fi + + local agent_env_name agent_env_value func_env_name func_env_value show_result=0 + agent_env_name="LLM_AGENT_DUMP_RESULT_$(echo "$LLM_AGENT_NAME" | tr '[:lower:]' '[:upper:]' | tr '-' '_')" + agent_env_value="${!agent_env_name:-"$LLM_AGENT_DUMP_RESULT"}" + func_env_name="${agent_env_name}_$(echo "$LLM_AGENT_FUNC" | tr '[:lower:]' '[:upper:]' | tr '-' '_')" + func_env_value="${!func_env_name}" + if [[ "$agent_env_value" == "1" || "$agent_env_value" == "true" ]]; then + if [[ "$func_env_value" != "0" && "$func_env_value" != "false" ]]; then + show_result=1 + fi + else + if [[ "$func_env_value" == "1" || "$func_env_value" == "true" ]]; then + show_result=1 + fi + fi + if [[ "$show_result" -ne 1 ]]; then + return + fi + cat <<EOF +$(echo -e "\e[2m")---------------------- +$(cat "$LLM_OUTPUT") +----------------------$(echo -e "\e[0m") +EOF +} + +main "$@" |
