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

简介: 本文记录在 Azure 中国区将 MCP SSE 服务接入 API Management(APIM)的实践:先部署 .NET SSE Demo 到 App Service,再通过 REST API 配置 APIM MCP Server(SSE 类型),解决 backendId 必填、API 版本不支持等坑。虽成功创建,但最终 SSE 测试未通,表明 APIM 当前对 SSE 透传仍存在能力限制。

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

接下来的目标是:把这个已经可以工作的 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 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 的根地址。

这是因为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。

执行的结果如下图:

关键配置解释

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

  • 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。

 

用 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服务。

测试失败。目前推测还是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);

 


 

当在复杂的环境中面临问题,格物之道需:浊而静之徐清,安以动之徐生。 云中,恰是如此!

相关文章
人工智能 缓存 前端开发
6295 19
人工智能 JavaScript 开发工具
3259 4
缓存 JavaScript Shell
1555 1
开发工具 Swift git
1053 1
Shell API 调度
866 2
|
13天前
|
存储 弹性计算 缓存
阿里云服务器租赁费用:新版租赁收费标准及活动报价参考
本文更新了2026年阿里云全系列云服务器租赁活动报价,所有特惠资源均可前往阿里云活动中心选购,整体覆盖从个人入门到企业级高性能场景的全梯度需求。其中轻量应用服务器主打极致性价比,2核2G峰值200M带宽配置每日10点、15点限时抢购价仅38元/年,2核4G配置379元/年起;高性价比的经济型e实例、通用算力型u2i实例覆盖2核4G至4核32G全档位,适配开发测试与中小型企业业务;搭载英特尔至强6处理器的第九代c9i企业级实例算力较上代提升20%,支撑高并发生产环境,不同实例规格价差清晰,用户可根据自身业务负载与预算灵活选型。
2121 121
阿里云服务器租赁费用:新版租赁收费标准及活动报价参考
|
14天前
|
人工智能 程序员 API
Codex 接入 DeepSeek-V4-Flash:还能补上识图,提供两套方案
Codex 接入 DeepSeek-V4-Flash 怎么配?本文覆盖 CLI 与桌面端,再用 qwen3-vl-flash 补识图,两套方案可直接照做
1771 13
安全 机器人 API
608 2
缓存 人工智能 算法
709 1