diff --git a/apisix/plugins/mcp-bridge.lua b/apisix/plugins/mcp-bridge.lua
index a73d94393d6c..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 {})})
@@ -109,9 +136,9 @@ local function on_connect(conf, ctx)
local line, _
line, _, stderr_partial = proc:stderr_read_line()
if line then
+ local content = stderr_partial and stderr_partial .. line or line
local ok, err = server.transport:send(
- '{"jsonrpc":"2.0","method":"notifications/stderr","params":{"content":"'
- .. (stderr_partial and stderr_partial .. line or line) .. '"}}')
+ _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/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": {}
+ }'
+```
diff --git a/t/plugin/mcp-bridge.t b/t/plugin/mcp-bridge.t
index 25369f02f821..fe967a286b3e 100644
--- a/t/plugin/mcp-bridge.t
+++ b/t/plugin/mcp-bridge.t
@@ -57,3 +57,37 @@ 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")
+ 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}))
+ proc:set_timeouts(nil, 1000, 1000)
+ local line = assert(proc:stderr_read_line())
+ proc:wait()
+
+ -- 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
+ ngx.say("not valid json: ", err)
+ return
+ end
+ ngx.say("valid json: ", decoded.params.content == payload)
+ }
+ }
+--- response_body
+valid json: true