aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsigoden <sigoden@gmail.com>2024-12-11 20:46:17 +0800
committerGitHub <noreply@github.com>2024-12-11 20:46:17 +0800
commit20d1ec47f9970caa119c3715a1c0c7a69e5aa65f (patch)
tree76b0d3585a40ce2b269fa50b54786aa865641920
parentc58abcbaf89f27e5e3806f4309880a1eac2b7095 (diff)
downloadllm-functions-docker-20d1ec47f9970caa119c3715a1c0c7a69e5aa65f.tar.gz
feat: support MCP bridge (#140)
-rw-r--r--.gitignore3
-rw-r--r--Argcfile.sh23
-rw-r--r--mcp/bridge/README.md42
-rw-r--r--mcp/bridge/index.js195
-rw-r--r--mcp/bridge/package.json22
-rw-r--r--scripts/mcp.sh176
-rwxr-xr-xscripts/run-mcp-tool.sh93
7 files changed, 549 insertions, 5 deletions
diff --git a/.gitignore b/.gitignore
index 92e2554..a8b41bb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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 "$@"