【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场景需求出发,通过漏斗式筛选、供需确认与分类分级编目,实现数据资产化、服务化与价值最大化。
|
3月前
|
人工智能 自然语言处理 API
【Azure AI Search】 stopword 是什么,为什么它会影响搜索结果?
本文解析 Azure AI Search 中搜索 &quot;in brief&quot; 返回结果过多的问题,指出根源在于 analyzer 对停用词(如 &quot;in&quot;)的处理差异:默认 `standard.lucene` 保留停用词导致泛匹配,而 `en.microsoft` 会过滤停用词,使结果更精准。关键在于根据业务语义选择合适 analyzer。
255 121
|
26天前
|
人工智能 弹性计算 开发者
阿里云服务器相关活动参考:新用户抢购,新老用户同享低价长效特惠,AI 焕新季优惠券等活动
2026年阿里云服务器优惠活动,构建起“新用户秒杀+新老同享长效特惠+场景化优惠券”的三层福利矩阵。新用户可参与每日两场的轻量应用服务器38元/年起限时限量抢购,快速降低入门门槛;覆盖新老用户的“99元云服务器计划”实现2核2G经济型e实例新购续费同价,最长可享多年长效优惠,同时搭配u2i、c9i/g9i/r9i等不同算力规格的指定折扣,满足从通用场景到AI高性能计算的多元需求。此外,文章还梳理了学生专属300元无门槛券、AI焕新季礼包、迁云补贴等优惠券权益,指导用户实现活动价基础上的折上折,帮助不同身份、不同规模的用户精准匹配高性价比上云方案。
阿里云服务器相关活动参考:新用户抢购,新老用户同享低价长效特惠,AI 焕新季优惠券等活动
|
3月前
|
人工智能 自然语言处理 API
【Azure AI Search】Index的字段使用默认Analyzer(standard.lucene) 和 en.microsoft 有什么不同?
Azure AI Search英文检索因词形差异(如brief/briefs)无法匹配,根源在于analyzer选择:默认standard.lucene不处理词形还原,而en.microsoft支持lemmatization,可将变体还原为基本形式。需通过新增字段并配置en.microsoft analyzer解决,兼顾检索质量与业务需求。
324 124
|
2月前
|
数据安全/隐私保护
【Azure Key Vault】在 Logic App 中调用 Key Vault 的 Key 进行加密/解密操作时权限报错问题
Logic App调用Key Vault密钥加解密时403报错,常因密钥级权限未开启:即使访问策略已授权,仍需单独为该密钥勾选“Encrypt/Decrypt”等Permitted operations,否则操作被拒。
122 2
|
2月前
|
存储 网络协议 API
【Azure Storage Account】跨存储账号复制 Blob 会产生大量网络流量费用吗?
本文详解Azure跨账号复制Blob的流量与费用问题:采用服务器端复制(如`StartCopyFromUriAsync`)时,数据不经过应用网络,避免高额出站流量费;而“下载再上传”则会产生显著带宽和NAT等成本。关键看复制方式,非账号是否相同。
138 2
|
3月前
|
Python
【Azure Function App】升级 Python 运行时 3.9 到3.10 后遇见的问题
Azure Functions 升级 Python 3.9→3.10 时,仅改 Portal 配置易致故障:一是复用旧版依赖引发加载超时(Exit 137);二是 typing_extensions 与实际运行时(如 3.13)不兼容,抛 AttributeError。须重装依赖、锁定 typing_extensions≥4.12,并验证真实 Python 版本。
197 1
|
5月前
|
存储 Linux 数据安全/隐私保护
自建私人云盘|OpenList 保姆级部署,吊打各大网盘
OpenList是开源网盘聚合工具,支持阿里云盘、百度网盘、夸克等数十种存储服务。一键Docker部署,统一管理多网盘文件,视频直链播放不耗服务器带宽,隐私安全有保障。(239字)
|
5月前
|
人工智能 监控 网络协议
【App Service】常规排查 App Service 启动 Application Insights 无数据的步骤 (.NET版本)
本文详解Application Insights在Azure App Service中无日志数据的三大原因及排查方法:1)网络连通性(验证到AI端点的443端口访问);2)w3wp.exe进程是否成功加载AI模块;3)DLL冲突(检查并移除重复的Microsoft.ApplicationInsights等组件)。
245 10
|
5月前
|
NoSQL 网络协议 Cloud Native
【Azure Redis】云原生环境下的 Redis 超时之谜:为什么 15 分钟后应用才恢复?
云原生中Redis短暂不可用后应用持续超时15分钟?问题不在Redis,而在Linux TCP默认重传机制(tcp_retries2=15)与长连接模型的错位。需三管齐下:调低内核重传次数、客户端显式配置超时与自动重连、应用层引入断路器与弹性重试。
338 20