【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);

 


 

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

相关文章
|
人工智能 运维 监控
从0开始全面认识高质量数据集建设(3)
本文系统阐述高质量数据集建设的端到端闭环流程,涵盖需求调研、数据规划、标准制定、工程实施等八大关键阶段,强调“业务驱动、标准先行、协同共建”,聚焦从AI场景需求出发,通过漏斗式筛选、供需确认与分类分级编目,实现数据资产化、服务化与价值最大化。
|
12月前
|
API C++
【Azure 环境】VS Code登录China Azure(Function)报错 An error occurred while signing in: invalid_request - AADSTS65002
An error occurred while signing in: invalid_request - AADSTS65002: Consent between first party application 'c27c220f-ce2f-4904-927d-333864217eeb' and first party resource '797f4846-ba00-4fd7-ba43-dac1f8f63013' must be configured via preauthorization - applications owned and operated by Microsoft mus
530 13
|
9月前
|
开发框架 Java .NET
【App Service】Java应用上传文件功能部署在App Service Windows上报错 413 Payload Too Large
Java应用在Azure App Service(Windows)部署时,因IIS默认限制上传文件不超过30MB,导致大文件上传出现413错误。虽无web.config文件,可通过Kudu工具手动创建并配置maxAllowedContentLength和maxRequestLength,突破限制,实现大文件上传。
423 3
|
4月前
|
人工智能 监控 网络协议
【App Service】常规排查 App Service 启动 Application Insights 无数据的步骤 (.NET版本)
本文详解Application Insights在Azure App Service中无日志数据的三大原因及排查方法:1)网络连通性(验证到AI端点的443端口访问);2)w3wp.exe进程是否成功加载AI模块;3)DLL冲突(检查并移除重复的Microsoft.ApplicationInsights等组件)。
215 10
|
4月前
|
前端开发 应用服务中间件 Linux
【Azure App Service】PHP页面上传文件413错误的解决方案
在使用 Azure App Service(Linux + PHP) 部署 Web 应用时,如果上传文件大于1MB,就会遇到 HTTP 413(Request Entity Too Large) 错误。 # 问题解答 ### 一、HTTP 413 错误的本质含义 413 Request Entity Too Large 是标准 HTTP 状态码,表示: > 客户端提交的请求体(Request Body)大小超过了服务器当前允许的最大限制。 在 Azure App Service(Linux)环境中,这个错误并不一定来自前端网关(Frontend),而更常见的来源是 App...
1500 13
|
4月前
|
NoSQL 网络协议 Cloud Native
【Azure Redis】云原生环境下的 Redis 超时之谜:为什么 15 分钟后应用才恢复?
云原生中Redis短暂不可用后应用持续超时15分钟?问题不在Redis,而在Linux TCP默认重传机制(tcp_retries2=15)与长连接模型的错位。需三管齐下:调低内核重传次数、客户端显式配置超时与自动重连、应用层引入断路器与弹性重试。
314 20
|
10月前
|
Java 开发工具
【Azure Storage Account】Java Code访问Storage Account File Share的上传和下载代码示例
本文介绍如何使用Java通过azure-storage-file-share SDK实现Azure文件共享的上传下载。包含依赖引入、客户端创建及完整示例代码,助你快速集成Azure File Share功能。
630 6
|
10月前
|
机器学习/深度学习 Kubernetes API
【Azure APIM】自建网关(self-host gateway)收集请求的Header和Body内容到日志中的办法
在Azure API Management中,通过配置trace策略可完整记录API请求的Header和Body信息。在Inbound和Outbound策略中分别使用context.Request/Response.Headers和Body.As&lt;string&gt;方法捕获数据,并写入Trace日志,便于排查与审计。
299 8
|
11月前
|
Java API 开发工具
【Azure Developer】Java代码实现获取Azure 资源的指标数据却报错 "invalid time interval input"
在使用 Java 调用虚拟机 API 获取指标数据时,因本地时区设置非 UTC,导致时间格式解析错误。解决方法是在代码中手动指定时区为 UTC,使用 `ZoneOffset.ofHours(0)` 并结合 `withOffsetSameInstant` 方法进行时区转换,从而避免因时区差异引发的时间格式问题。
445 4
|
4月前
|
网络协议 虚拟化 Docker
【Azure Developer】.NET Aspire 启动报错:listen tcp bind: An attempt was made to access a socket in a way forbidden by its access permissions
.NET Aspire在Windows启动时因Hyper-V端口保留机制,导致DCP代理无法绑定53209等端口(报错“访问被拒绝”)。虽端口未被占用,但已被系统保留。推荐方案:修改launchSettings.json,将服务端口改为7xxx等安全范围;或临时重启winnat服务、永久排除指定端口。
544 21