【Azure APIM】在 Azure API Management 中配置 SSE Transport 的 MCP Server:从 App Service 到 APIM 的一次实践

最近我把一个 MCP SSE 服务 Demo 部署到了 Azure App Service,并先用 MCP Inspector 直接连接后端服务做了验证。后端服务本身可以正常响应 MCP 调用。(注:MCP SSE Demo的源码见附录)

image

接下来的目标是:把这个已经可以工作的 MCP SSE Server 接入 Azure API Management(APIM),让 APIM 作为统一入口来透传 MCP 请求。

背景:为什么需要用 REST API 配置

我参考的是官方文档:在 API 管理中以编程方式管理 MCP 服务器。文档中提到,APIM 的 MCP Server 可以支持两类透传传输方式:

  • streamable:当前 MCP Streamable HTTP 传输方式。
  • sse:HTTP + Server-Sent Events 传输方式,需要同时配置 ssemessage 两个端点。

不过在我的 APIM 门户页面里,目前没有看到可以把 Transport Type 设置成 SSE 的 UI 控件,门户上默认显示的是 HTTP 相关配置。

因此这次实验选择直接调用 APIM Management REST API 来创建 MCP API。

 

环境与目标架构

这次环境在 Azure 中国区,几个关键点如下:
  • 后端 MCP SSE 服务:部署在 Azure App Service。
  • API 网关:Azure API Management。
  • 管理面 endpoint:https://management.chinacloudapi.cn
  • APIM Gateway endpoint:https://{apim-name}.azure-api.cn
  • MCP Transport:sse
  • SSE endpoint:/sse
  • Message endpoint:/messages

整体调用链路如下:

MCP Inspector
  |
  |  https://{apim-name}.azure-api.cn/my-mcp-sse/sse
  v
Azure API Management MCP API
  |
  |  backend
  v
Azure App Service 上的 MCP SSE Server

 

创建 SSE Transport 的 MCP API

在创建SSE MCP Server之前,需要先在 APIM 中创建了一个 Backend,Backend ID 为 mcpssebackend01,指向 App Service 的根地址。

image

这是因为MCP API 不能只依赖 serviceUrl。对于透传的 MCP SSE Server,需要设置 `backendId',否则会遇见如下错误。

{
  "error": {
    "code": "ValidationError",
    "details": [
      {
        "message": "Either BackendId or MCP tools must be set, but not both for MCP API."
      }
    ]
  }
}

此外,还遇见了另一个问题:API version (2025-09-01-preview) 在 Azure 中国区不支持。

错误消息:

{
  "error": {
    "code": "NoRegisteredProviderFound",
    "message": "No registered resource provider found for location 'chinanorth3' and API version '2025-09-01-preview' for type 'service'. The supported api-versions are ... '2024-10-01-preview'."
  }
}

这个错误说明:当前区域和云环境下,Microsoft.ApiManagement/service 还没有注册或开放 2025-09-01-preview。

解决方法是改用错误信息中列出的可用版本,本次测试使用的是 2024-10-01-preview。

 

最终,把如下CMD脚本中占位符(<your-subscription-id> ,<your-resource-group> ,<your-apim-name>)替换为Azure上APIM资源的信息后,就可以直接Windows CMD窗口执行:

set "SUBSCRIPTION_ID=<your-subscription-id>"
set "RESOURCE_GROUP=<your-resource-group>"
set "APIM_NAME=<your-apim-name>"
set "API_VERSION=2024-10-01-preview"
set "MCP_SERVER_ID=my-mcp-sse"
set "BACKEND_ID=mcpssebackend01"

set "BASE_URL=https://management.chinacloudapi.cn/subscriptions/%SUBSCRIPTION_ID%/resourceGroups/%RESOURCE_GROUP%/providers/Microsoft.ApiManagement/service/%APIM_NAME%"

for /f "delims=" %T in ('az account get-access-token --resource https://management.chinacloudapi.cn --query accessToken -o tsv') do set "TOKEN=%T"

set "BODY_FILE=%TEMP%\apim-mcp-body.json"

(
echo {
echo   "properties": {
echo     "type": "mcp",
echo     "path": "my-mcp-sse",
echo     "displayName": "My SSE MCP Server",
echo     "description": "Passthrough MCP server using SSE transport",
echo     "protocols": ["https"],
echo     "backendId": "%BACKEND_ID%",
echo     "mcpProperties": {
echo       "transportType": "sse",
echo       "endpoints": {
echo         "sse": {
echo           "uriTemplate": "/sse"
echo         },
echo         "message": {
echo           "uriTemplate": "/messages"
echo         }
echo       }
echo     }
echo   }
echo }
) > "%BODY_FILE%"

curl -s -X PUT ^
  "%BASE_URL%/apis/%MCP_SERVER_ID%?api-version=%API_VERSION%" ^
  -H "Authorization: Bearer %TOKEN%" ^
  -H "Content-Type: application/json" ^
  -H "If-Match: *" ^
  -d "@%BODY_FILE%"

注意:因为调用APIM配置接口需要认证, 所以需要登录到Azure China,以便脚本中的“for /f "delims=" %T in ('az account get-access-token --resource https://management.chinacloudapi.cn --query accessToken -o tsv') do set "TOKEN=%T"” 设置TOKEN。

执行的结果如下图:

image

关键配置解释

这段请求体里最关键的是:

  • type: "mcp":告诉 APIM 这是一个 MCP 类型的 API。
  • path: "my-mcp-sse":决定客户端访问 APIM Gateway 时的路径前缀。
  • backendId:引用已经创建好的 APIM Backend。
  • transportType: "sse":声明后端 MCP Server 使用 SSE Transport。
  • endpoints.sse.uriTemplate:SSE 事件流端点。
  • endpoints.message.uriTemplate:客户端消息发送端点。

在 APIM 门户中也可以看到已经创建好的 MCP API。

image

 

用 MCP Inspector 测试 APIM MCP SSE

创建完成后,可以用 MCP Inspector 测试 APIM 暴露出来的 SSE endpoint。

连接地址格式如下:https://{apim-name}.azure-api.cn/{mcp-path}/sse

对应本次示例就是:https://{apim-name}.azure-api.cn/my-mcp-sse/sse

在中国区Azure上测试APIM MCP Service(SSE),无法连接到MCP服务。

image

测试失败。目前推测还是APIM服务自身的某些设定没有完成,还不能完全支持SSE协议

 

结论

在 Azure China 环境中,MCP SSE 透传配置可创建,但最终集成测试未通过,说明 APIM 侧对 SSE 仍存在能力限制。

 

附录: .NET MCP SSE Demo

using System.Collections.Concurrent;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading.Channels;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddCors(options =>
{
    options.AddDefaultPolicy(policy => policy
        .AllowAnyOrigin()
        .AllowAnyHeader()
        .AllowAnyMethod());
});

var app = builder.Build();
var sessions = new ConcurrentDictionary<string, SseClient>();
var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
    WriteIndented = false
};

app.UseCors();

app.MapGet("/", () => Results.Json(new
{
    name = "MCP SSE Demo",
    endpoints = new
    {
        sse = "/sse",
        messages = "/messages?sessionId={sessionId}",
        hello = "/api/hello?name=World",
        add = "/api/add"
    }
}));

app.MapGet("/health", () => Results.Ok(new { status = "ok", time = DateTimeOffset.UtcNow }));

app.MapGet("/api/hello", (string? name) => Results.Ok(new
{
    message = $"Hello, {(string.IsNullOrWhiteSpace(name) ? "World" : name)}!",
    time = DateTimeOffset.UtcNow
}));

app.MapPost("/api/add", (AddRequest request) => Results.Ok(new
{
    request.A,
    request.B,
    sum = request.A + request.B
}));

app.MapGet("/sse", async (HttpContext context) =>
{
    var sessionId = Guid.NewGuid().ToString("N");
    var client = new SseClient(Channel.CreateUnbounded<string>());
    sessions[sessionId] = client;

    context.Response.Headers.CacheControl = "no-cache";
    context.Response.Headers.Connection = "keep-alive";
    context.Response.Headers.ContentType = "text/event-stream";


    // Use a relative message endpoint so reverse proxies/APIM can resolve it
    // against the public MCP SSE URL instead of leaking the backend host.
    var endpoint = $"/messages?sessionId={sessionId}";
        
    context.Response.Headers.CacheControl = "no-cache, no-transform";
    context.Response.Headers["X-Mcp-Endpoint-Source"] = "relative-v2";
    context.Response.Headers["X-Mcp-Endpoint-Value"] = endpoint;

    Console.WriteLine($"Writing MCP endpoint event: {endpoint}");

    await WriteSseAsync(context.Response, endpoint, "endpoint", context.RequestAborted);

    try
    {
        while (!context.RequestAborted.IsCancellationRequested)
        {
            var messageAvailable = client.Messages.Reader.WaitToReadAsync(context.RequestAborted).AsTask();
            var heartbeat = Task.Delay(TimeSpan.FromSeconds(15), context.RequestAborted);
            var completed = await Task.WhenAny(messageAvailable, heartbeat);

            if (completed == heartbeat)
            {
                await WriteSseAsync(context.Response, DateTimeOffset.UtcNow.ToString("O"), "ping", context.RequestAborted);
                continue;
            }

            if (!await messageAvailable)
            {
                break;
            }

            while (client.Messages.Reader.TryRead(out var payload))
            {
                await WriteSseAsync(context.Response, payload, "message", context.RequestAborted);
            }
        }
    }
    catch (OperationCanceledException)
    {
        // Client disconnected.
    }
    finally
    {
        sessions.TryRemove(sessionId, out _);
    }
});

app.MapPost("/messages", async (HttpContext context) =>
{
    var sessionId = context.Request.Query["sessionId"].ToString();
    if (string.IsNullOrWhiteSpace(sessionId) || !sessions.TryGetValue(sessionId, out var client))
    {
        return Results.NotFound(new { error = "Unknown or expired sessionId. Connect to /sse first." });
    }

    JsonNode? rpc;
    try
    {
        rpc = await JsonNode.ParseAsync(context.Request.Body, cancellationToken: context.RequestAborted);
    }
    catch (JsonException ex)
    {
        await client.Messages.Writer.WriteAsync(CreateError(null, -32700, $"Parse error: {ex.Message}"), context.RequestAborted);
        return Results.Accepted();
    }

    if (rpc is not JsonObject request)
    {
        await client.Messages.Writer.WriteAsync(CreateError(null, -32600, "Invalid JSON-RPC request."), context.RequestAborted);
        return Results.Accepted();
    }

    var response = HandleJsonRpc(request);
    if (response is not null)
    {
        await client.Messages.Writer.WriteAsync(response, context.RequestAborted);
    }

    return Results.Accepted();
});

app.Run();

string? HandleJsonRpc(JsonObject request)
{
    var id = request["id"];
    var method = request["method"]?.GetValue<string>();

    if (string.IsNullOrWhiteSpace(method))
    {
        return CreateError(id, -32600, "Missing JSON-RPC method.");
    }

    if (method.StartsWith("notifications/", StringComparison.Ordinal))
    {
        return null;
    }

    return method switch
    {
        "initialize" => CreateResult(id, new JsonObject
        {
            ["protocolVersion"] = "2024-11-05",
            ["capabilities"] = new JsonObject
            {
                ["tools"] = new JsonObject
                {
                    ["listChanged"] = false
                }
            },
            ["serverInfo"] = new JsonObject
            {
                ["name"] = "dotnet-mcp-sse-demo",
                ["version"] = "1.0.0"
            }
        }),
        "tools/list" => CreateResult(id, BuildToolsList()),
        "tools/call" => CreateResult(id, CallTool(request["params"] as JsonObject)),
        "resources/list" => CreateResult(id, new JsonObject { ["resources"] = new JsonArray() }),
        "prompts/list" => CreateResult(id, new JsonObject { ["prompts"] = new JsonArray() }),
        _ => CreateError(id, -32601, $"Method not found: {method}")
    };
}

JsonObject BuildToolsList() => new()
{
    ["tools"] = new JsonArray
    {
        new JsonObject
        {
            ["name"] = "echo",
            ["description"] = "Return the provided text.",
            ["inputSchema"] = new JsonObject
            {
                ["type"] = "object",
                ["properties"] = new JsonObject
                {
                    ["text"] = new JsonObject
                    {
                        ["type"] = "string",
                        ["description"] = "Text to echo."
                    }
                },
                ["required"] = new JsonArray("text")
            }
        },
        new JsonObject
        {
            ["name"] = "server_time",
            ["description"] = "Get the current server UTC time.",
            ["inputSchema"] = new JsonObject
            {
                ["type"] = "object",
                ["properties"] = new JsonObject()
            }
        },
        new JsonObject
        {
            ["name"] = "add",
            ["description"] = "Add two numbers.",
            ["inputSchema"] = new JsonObject
            {
                ["type"] = "object",
                ["properties"] = new JsonObject
                {
                    ["a"] = new JsonObject { ["type"] = "number" },
                    ["b"] = new JsonObject { ["type"] = "number" }
                },
                ["required"] = new JsonArray("a", "b")
            }
        }
    }
};

JsonObject CallTool(JsonObject? parameters)
{
    var name = parameters?["name"]?.GetValue<string>();
    var arguments = parameters?["arguments"] as JsonObject ?? new JsonObject();

    var text = name switch
    {
        "echo" => arguments["text"]?.GetValue<string>() ?? string.Empty,
        "server_time" => DateTimeOffset.UtcNow.ToString("O"),
        "add" => Add(arguments),
        _ => $"Unknown tool: {name}"
    };

    return new JsonObject
    {
        ["content"] = new JsonArray
        {
            new JsonObject
            {
                ["type"] = "text",
                ["text"] = text
            }
        }
    };
}

static string Add(JsonObject arguments)
{
    var a = arguments["a"]?.GetValue<double>() ?? 0;
    var b = arguments["b"]?.GetValue<double>() ?? 0;
    return $"{a} + {b} = {a + b}";
}

string CreateResult(JsonNode? id, JsonNode result)
{
    return new JsonObject
    {
        ["jsonrpc"] = "2.0",
        ["id"] = CloneNode(id),
        ["result"] = result
    }.ToJsonString(jsonOptions);
}

string CreateError(JsonNode? id, int code, string message)
{
    return new JsonObject
    {
        ["jsonrpc"] = "2.0",
        ["id"] = CloneNode(id),
        ["error"] = new JsonObject
        {
            ["code"] = code,
            ["message"] = message
        }
    }.ToJsonString(jsonOptions);
}

static JsonNode? CloneNode(JsonNode? node) => node is null ? null : JsonNode.Parse(node.ToJsonString());

static async Task WriteSseAsync(HttpResponse response, string data, string? eventName, CancellationToken cancellationToken)
{
    if (!string.IsNullOrWhiteSpace(eventName))
    {
        await response.WriteAsync($"event: {eventName}\n", cancellationToken);
    }

    var lines = data.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n');
    foreach (var line in lines)
    {
        await response.WriteAsync($"data: {line}\n", cancellationToken);
    }

    await response.WriteAsync("\n", cancellationToken);
    await response.Body.FlushAsync(cancellationToken);
}

public sealed record AddRequest(double A, double B);

public sealed record SseClient(Channel<string> Messages);
View Code

 

 

posted @ 2026-08-18 19:43  编码者卢布  阅读(35)  评论(0)    收藏  举报