From d1de208cc7d00ed651f8363d4e9a6dab4c3cac9a Mon Sep 17 00:00:00 2001 From: Abhishek Choudhary Date: Fri, 12 Jun 2026 15:09:33 +0800 Subject: [PATCH 1/5] fix(mcp-bridge): encode stderr notifications as JSON Build the notifications/stderr message with core.json.encode instead of string concatenation, so subprocess stderr output containing double quotes, backslashes or other JSON special characters no longer breaks the framing of the JSON-RPC notification sent to the client. --- apisix/plugins/mcp-bridge.lua | 10 +++++++--- t/plugin/mcp-bridge.t | 37 +++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/apisix/plugins/mcp-bridge.lua b/apisix/plugins/mcp-bridge.lua index a73d94393d6c..d6ffa7a03933 100644 --- a/apisix/plugins/mcp-bridge.lua +++ b/apisix/plugins/mcp-bridge.lua @@ -109,9 +109,13 @@ local function on_connect(conf, ctx) local line, _ line, _, stderr_partial = proc:stderr_read_line() if line then - local ok, err = server.transport:send( - '{"jsonrpc":"2.0","method":"notifications/stderr","params":{"content":"' - .. (stderr_partial and stderr_partial .. line or line) .. '"}}') + local ok, err = server.transport:send(core.json.encode({ + jsonrpc = "2.0", + method = "notifications/stderr", + params = { + content = stderr_partial and stderr_partial .. line or line, + }, + })) if not ok then core.log.info("session ", server.session_id, " exit, failed to send response message: ", err) diff --git a/t/plugin/mcp-bridge.t b/t/plugin/mcp-bridge.t index 25369f02f821..4e51b61de0ab 100644 --- a/t/plugin/mcp-bridge.t +++ b/t/plugin/mcp-bridge.t @@ -57,3 +57,40 @@ property "command" is required property "command" validation failed: wrong type: expected string, got number done property "args" validation failed: wrong type: expected array, got string + + + +=== TEST 2: stderr content with JSON special characters keeps the notification well-formed +--- config + location /t { + content_by_lua_block { + local core = require("apisix.core") + local pipe = require("ngx.pipe") + + -- a subprocess line that contains double quotes, a backslash and the + -- "}}" sequence, all of which break naive string concatenation + local payload = [[Error: invalid input "x\y" }}]] + + local proc = assert(pipe.spawn({"sh", "-c", "printf '%s\\n' '" .. payload .. "' 1>&2"})) + proc:set_timeouts(nil, 1000, 1000) + local line = assert(proc:stderr_read_line()) + + -- build the notification the same way the plugin does + local msg = core.json.encode({ + jsonrpc = "2.0", + method = "notifications/stderr", + params = { + content = line, + }, + }) + + local decoded, err = core.json.decode(msg) + if not decoded then + ngx.say("not valid json: ", err) + return + end + ngx.say("valid json: ", decoded.params.content == payload) + } + } +--- response_body +valid json: true From 10c0f6f938d8f8d3f761ac6b463aca6d69203ee1 Mon Sep 17 00:00:00 2001 From: Shreemaan Abhishek Date: Mon, 15 Jun 2026 11:21:32 +0545 Subject: [PATCH 2/5] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- apisix/plugins/mcp-bridge.lua | 18 +++++++++++++++--- t/plugin/mcp-bridge.t | 2 +- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/apisix/plugins/mcp-bridge.lua b/apisix/plugins/mcp-bridge.lua index d6ffa7a03933..dc9d9e8f9fee 100644 --- a/apisix/plugins/mcp-bridge.lua +++ b/apisix/plugins/mcp-bridge.lua @@ -109,13 +109,25 @@ local function on_connect(conf, ctx) local line, _ line, _, stderr_partial = proc:stderr_read_line() if line then - local ok, err = server.transport:send(core.json.encode({ + local content = stderr_partial and stderr_partial .. line or line + local msg, enc_err = core.json.encode({ jsonrpc = "2.0", method = "notifications/stderr", params = { - content = stderr_partial and stderr_partial .. line or line, + content = content, }, - })) + }) + if not msg then + msg = core.json.encode({ + jsonrpc = "2.0", + method = "notifications/stderr", + params = { + content = "failed to encode stderr content: " .. (enc_err or "unknown"), + }, + }) + end + + local ok, err = server.transport:send(msg) if not ok then core.log.info("session ", server.session_id, " exit, failed to send response message: ", err) diff --git a/t/plugin/mcp-bridge.t b/t/plugin/mcp-bridge.t index 4e51b61de0ab..2375c9b37a07 100644 --- a/t/plugin/mcp-bridge.t +++ b/t/plugin/mcp-bridge.t @@ -71,7 +71,7 @@ property "args" validation failed: wrong type: expected array, got string -- "}}" sequence, all of which break naive string concatenation local payload = [[Error: invalid input "x\y" }}]] - local proc = assert(pipe.spawn({"sh", "-c", "printf '%s\\n' '" .. payload .. "' 1>&2"})) + local proc = assert(pipe.spawn({"sh", "-c", [[printf '%s\n' "$1" 1>&2]], "sh", payload})) proc:set_timeouts(nil, 1000, 1000) local line = assert(proc:stderr_read_line()) From c4fe868bf1fc784b0e30ed0a9cc98345902ef123 Mon Sep 17 00:00:00 2001 From: Shreemaan Abhishek Date: Mon, 15 Jun 2026 13:59:34 +0800 Subject: [PATCH 3/5] style(mcp-bridge): wrap long line to satisfy luacheck --- apisix/plugins/mcp-bridge.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apisix/plugins/mcp-bridge.lua b/apisix/plugins/mcp-bridge.lua index dc9d9e8f9fee..31687e78fb4b 100644 --- a/apisix/plugins/mcp-bridge.lua +++ b/apisix/plugins/mcp-bridge.lua @@ -122,7 +122,8 @@ local function on_connect(conf, ctx) jsonrpc = "2.0", method = "notifications/stderr", params = { - content = "failed to encode stderr content: " .. (enc_err or "unknown"), + content = "failed to encode stderr content: " + .. (enc_err or "unknown"), }, }) end From 7598934d73ece39939683a00615da850202efd82 Mon Sep 17 00:00:00 2001 From: Shreemaan Abhishek Date: Mon, 15 Jun 2026 14:19:15 +0800 Subject: [PATCH 4/5] refactor(mcp-bridge): build stderr notification via shared helper Extract the notifications/stderr message construction into a helper that is also exercised directly by the regression test, log the encode error on the fallback path, and reap the spawned subprocess in the test. --- apisix/plugins/mcp-bridge.lua | 48 +++++++++++++++++++++-------------- t/plugin/mcp-bridge.t | 15 +++++------ 2 files changed, 35 insertions(+), 28 deletions(-) diff --git a/apisix/plugins/mcp-bridge.lua b/apisix/plugins/mcp-bridge.lua index 31687e78fb4b..f9841f825629 100644 --- a/apisix/plugins/mcp-bridge.lua +++ b/apisix/plugins/mcp-bridge.lua @@ -65,6 +65,33 @@ function _M.check_schema(conf, schema_type) end +-- build a notifications/stderr JSON-RPC message from a subprocess stderr line. +-- core.json.encode is used so that special characters in the content are +-- escaped and the message stays well-formed. It is exposed on _M so the +-- behaviour can be exercised directly in tests. +function _M.build_stderr_notification(content) + local msg, encode_err = core.json.encode({ + jsonrpc = "2.0", + method = "notifications/stderr", + params = { + content = content, + }, + }) + if not msg then + core.log.error("failed to encode stderr notification: ", encode_err) + -- the fallback content is a plain ASCII string, so it always encodes + msg = core.json.encode({ + jsonrpc = "2.0", + method = "notifications/stderr", + params = { + content = "failed to encode stderr content", + }, + }) + end + return msg +end + + local function on_connect(conf, ctx) return function(additional) local proc, err = pipe.spawn({conf.command, unpack(conf.args or {})}) @@ -110,25 +137,8 @@ local function on_connect(conf, ctx) line, _, stderr_partial = proc:stderr_read_line() if line then local content = stderr_partial and stderr_partial .. line or line - local msg, enc_err = core.json.encode({ - jsonrpc = "2.0", - method = "notifications/stderr", - params = { - content = content, - }, - }) - if not msg then - msg = core.json.encode({ - jsonrpc = "2.0", - method = "notifications/stderr", - params = { - content = "failed to encode stderr content: " - .. (enc_err or "unknown"), - }, - }) - end - - local ok, err = server.transport:send(msg) + local ok, err = server.transport:send( + _M.build_stderr_notification(content)) if not ok then core.log.info("session ", server.session_id, " exit, failed to send response message: ", err) diff --git a/t/plugin/mcp-bridge.t b/t/plugin/mcp-bridge.t index 2375c9b37a07..fe967a286b3e 100644 --- a/t/plugin/mcp-bridge.t +++ b/t/plugin/mcp-bridge.t @@ -66,23 +66,20 @@ property "args" validation failed: wrong type: expected array, got string content_by_lua_block { local core = require("apisix.core") local pipe = require("ngx.pipe") + local mcp_bridge = require("apisix.plugins.mcp-bridge") -- a subprocess line that contains double quotes, a backslash and the -- "}}" sequence, all of which break naive string concatenation local payload = [[Error: invalid input "x\y" }}]] - local proc = assert(pipe.spawn({"sh", "-c", [[printf '%s\n' "$1" 1>&2]], "sh", payload})) + local proc = assert( + pipe.spawn({"sh", "-c", [[printf '%s\n' "$1" 1>&2]], "sh", payload})) proc:set_timeouts(nil, 1000, 1000) local line = assert(proc:stderr_read_line()) + proc:wait() - -- build the notification the same way the plugin does - local msg = core.json.encode({ - jsonrpc = "2.0", - method = "notifications/stderr", - params = { - content = line, - }, - }) + -- build the notification through the plugin's own code path + local msg = mcp_bridge.build_stderr_notification(line) local decoded, err = core.json.decode(msg) if not decoded then From d079326e3836a5a796d538fef8d4c40b44210f18 Mon Sep 17 00:00:00 2001 From: Shreemaan Abhishek Date: Mon, 15 Jun 2026 14:36:36 +0800 Subject: [PATCH 5/5] docs(mcp-bridge): add English and Chinese plugin documentation Document the mcp-bridge plugin in both languages, add it to the AI plugin category in the sidebars, and note that the plugin is currently experimental. --- docs/en/latest/config.json | 3 +- docs/en/latest/plugins/mcp-bridge.md | 123 +++++++++++++++++++++++++++ docs/zh/latest/config.json | 3 +- docs/zh/latest/plugins/mcp-bridge.md | 123 +++++++++++++++++++++++++++ 4 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 docs/en/latest/plugins/mcp-bridge.md create mode 100644 docs/zh/latest/plugins/mcp-bridge.md diff --git a/docs/en/latest/config.json b/docs/en/latest/config.json index 1dc91c6b3bcc..bb5b97cb2f72 100644 --- a/docs/en/latest/config.json +++ b/docs/en/latest/config.json @@ -80,7 +80,8 @@ "plugins/ai-prompt-decorator", "plugins/ai-prompt-template", "plugins/ai-rag", - "plugins/ai-request-rewrite" + "plugins/ai-request-rewrite", + "plugins/mcp-bridge" ] }, { diff --git a/docs/en/latest/plugins/mcp-bridge.md b/docs/en/latest/plugins/mcp-bridge.md new file mode 100644 index 000000000000..262c71dc9d48 --- /dev/null +++ b/docs/en/latest/plugins/mcp-bridge.md @@ -0,0 +1,123 @@ +--- +title: mcp-bridge +keywords: + - Apache APISIX + - API Gateway + - Plugin + - mcp-bridge + - MCP +description: This document contains information about the Apache APISIX mcp-bridge Plugin, which bridges a stdio-based MCP (Model Context Protocol) server to HTTP clients over SSE. +--- + + + + + + + +## Description + +The `mcp-bridge` Plugin bridges a stdio-based [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server to HTTP clients. APISIX spawns the MCP server as a subprocess and exposes it over the MCP [SSE transport](https://modelcontextprotocol.io/docs/concepts/transports), so that clients can talk to a local MCP server through the gateway without managing the process themselves. + +When a client connects, APISIX starts the configured `command` as a subprocess and relays data between them: + +- The subprocess's standard output is forwarded to the client as JSON-RPC messages. +- The subprocess's standard error is forwarded to the client as `notifications/stderr` notifications. +- Messages sent by the client are written to the subprocess's standard input. + +:::caution + +The `mcp-bridge` Plugin is currently experimental and under active development. Its configuration and behavior may change in future releases. + +::: + +## Attributes + +| Name | Type | Required | Default | Description | +|------------|-----------------|----------|---------|------------------------------------------------------------------------------------------------------------------------------| +| command | string | True | | Command used to start the MCP server subprocess, for example `npx`. The command must be available in the `PATH` of the APISIX process. | +| args | array[string] | False | | List of arguments passed to `command`. | +| base_uri | string | False | "" | Base path under which the SSE and message endpoints are exposed. It should match the prefix of the Route's `uri`. | + +With a given `base_uri`, the Plugin serves two endpoints: + +- `GET /sse`: establishes the SSE stream and advertises the message endpoint to the client. +- `POST /message?sessionId=`: the endpoint to which the client posts JSON-RPC messages. + +The Route should therefore be configured with a wildcard `uri` such as `/*` so that both endpoints are matched. + +## Example usage + +The following example bridges the [filesystem MCP server](https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem) and exposes it under `/mcp`. + +Create a Route with the `mcp-bridge` Plugin. The `uri` uses a wildcard so that both `/mcp/sse` and `/mcp/message` are matched, and `base_uri` is set to `/mcp`: + +```shell +curl -i "http://127.0.0.1:9180/apisix/admin/routes/1" -X PUT \ + -H "X-API-KEY: ${admin_key}" \ + -d '{ + "uri": "/mcp/*", + "plugins": { + "mcp-bridge": { + "base_uri": "/mcp", + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + "/path/to/serve" + ] + } + } + }' +``` + +Connect to the SSE endpoint to establish a session: + +```shell +curl -N "http://127.0.0.1:9080/mcp/sse" +``` + +```text +event: endpoint +data: /mcp/message?sessionId=0d9...e3a +``` + +The `data` field contains the message endpoint, including the `sessionId` to use for this session. Using that endpoint, send JSON-RPC requests to the MCP server. For example, to list the available tools: + +```shell +curl "http://127.0.0.1:9080/mcp/message?sessionId=0d9...e3a" -X POST \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' +``` + +The MCP server's responses are streamed back over the SSE connection opened in the previous step. + +## Delete Plugin + +To remove the `mcp-bridge` Plugin, you can delete the corresponding JSON configuration from the Plugin configuration. APISIX will automatically reload and you do not have to restart for this to take effect. + +```shell +curl "http://127.0.0.1:9180/apisix/admin/routes/1" -X PUT \ + -H "X-API-KEY: ${admin_key}" \ + -d '{ + "uri": "/mcp/*", + "plugins": {} + }' +``` diff --git a/docs/zh/latest/config.json b/docs/zh/latest/config.json index 6240fe6e4514..08f5bffa20f5 100644 --- a/docs/zh/latest/config.json +++ b/docs/zh/latest/config.json @@ -71,7 +71,8 @@ "plugins/ai-prompt-decorator", "plugins/ai-prompt-template", "plugins/ai-rag", - "plugins/ai-request-rewrite" + "plugins/ai-request-rewrite", + "plugins/mcp-bridge" ] }, { diff --git a/docs/zh/latest/plugins/mcp-bridge.md b/docs/zh/latest/plugins/mcp-bridge.md new file mode 100644 index 000000000000..2b09258ab85a --- /dev/null +++ b/docs/zh/latest/plugins/mcp-bridge.md @@ -0,0 +1,123 @@ +--- +title: mcp-bridge +keywords: + - Apache APISIX + - API 网关 + - Plugin + - mcp-bridge + - MCP +description: 本文档包含有关 Apache APISIX mcp-bridge 插件的信息,该插件通过 SSE 将基于 stdio 的 MCP(Model Context Protocol)服务器桥接给 HTTP 客户端。 +--- + + + + + + + +## 描述 + +`mcp-bridge` 插件将基于 stdio 的 [MCP(Model Context Protocol)](https://modelcontextprotocol.io/) 服务器桥接给 HTTP 客户端。APISIX 会将 MCP 服务器作为子进程启动,并通过 MCP 的 [SSE 传输](https://modelcontextprotocol.io/docs/concepts/transports)对外暴露,使客户端无需自行管理进程,即可通过网关与本地 MCP 服务器通信。 + +当客户端连接时,APISIX 会以子进程方式启动所配置的 `command`,并在两者之间转发数据: + +- 子进程的标准输出会作为 JSON-RPC 消息转发给客户端。 +- 子进程的标准错误会作为 `notifications/stderr` 通知转发给客户端。 +- 客户端发送的消息会写入子进程的标准输入。 + +:::caution + +`mcp-bridge` 插件目前处于实验阶段,仍在积极开发中。其配置和行为在未来版本中可能会发生变化。 + +::: + +## 属性 + +| 名称 | 类型 | 必选项 | 默认值 | 描述 | +|------------|-----------------|--------|--------|----------------------------------------------------------------------------------------------------------| +| command | string | 是 | | 用于启动 MCP 服务器子进程的命令,例如 `npx`。该命令必须位于 APISIX 进程的 `PATH` 中。 | +| args | array[string] | 否 | | 传递给 `command` 的参数列表。 | +| base_uri | string | 否 | "" | 暴露 SSE 与 message 端点的基础路径,应与路由 `uri` 的前缀保持一致。 | + +对于给定的 `base_uri`,插件会提供两个端点: + +- `GET /sse`:建立 SSE 流,并向客户端通告 message 端点。 +- `POST /message?sessionId=`:客户端用于发送 JSON-RPC 消息的端点。 + +因此,路由的 `uri` 应配置为通配形式(例如 `/*`),以便同时匹配上述两个端点。 + +## 使用示例 + +以下示例桥接 [filesystem MCP 服务器](https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem),并将其暴露在 `/mcp` 路径下。 + +创建一个启用 `mcp-bridge` 插件的路由。`uri` 使用通配符以同时匹配 `/mcp/sse` 与 `/mcp/message`,并将 `base_uri` 设置为 `/mcp`: + +```shell +curl -i "http://127.0.0.1:9180/apisix/admin/routes/1" -X PUT \ + -H "X-API-KEY: ${admin_key}" \ + -d '{ + "uri": "/mcp/*", + "plugins": { + "mcp-bridge": { + "base_uri": "/mcp", + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + "/path/to/serve" + ] + } + } + }' +``` + +连接 SSE 端点以建立会话: + +```shell +curl -N "http://127.0.0.1:9080/mcp/sse" +``` + +```text +event: endpoint +data: /mcp/message?sessionId=0d9...e3a +``` + +`data` 字段中包含 message 端点,其中携带了本次会话使用的 `sessionId`。使用该端点向 MCP 服务器发送 JSON-RPC 请求。例如,列出可用的工具: + +```shell +curl "http://127.0.0.1:9080/mcp/message?sessionId=0d9...e3a" -X POST \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' +``` + +MCP 服务器的响应会通过上一步建立的 SSE 连接以流的形式返回。 + +## 删除插件 + +当你需要删除 `mcp-bridge` 插件时,可以从插件配置中删除对应的 JSON 配置。APISIX 会自动重新加载,无需重启即可生效。 + +```shell +curl "http://127.0.0.1:9180/apisix/admin/routes/1" -X PUT \ + -H "X-API-KEY: ${admin_key}" \ + -d '{ + "uri": "/mcp/*", + "plugins": {} + }' +```