Skip to content

Commit 35bee99

Browse files
authored
Merge pull request #51 from Azure/copilot/update-mcp-server-support-tests
feat: Add real MCP server coverage to integration test APIM
2 parents c117941 + 620b377 commit 35bee99

12 files changed

Lines changed: 1653 additions & 165 deletions

File tree

.devcontainer/devcontainer.json

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
"features": {
55
"ghcr.io/devcontainers/features/github-cli:1": {},
66
"ghcr.io/devcontainers/features/azure-cli:1": {
7-
"installBicep": false
8-
}
7+
"installBicep": true
8+
},
9+
"ghcr.io/devcontainers/features/powershell:1": {}
910
},
1011
"postCreateCommand": "npm ci",
1112
"remoteEnv": {
@@ -33,7 +34,9 @@
3334
"GitHub.vscode-github-actions",
3435
"ms-azuretools.vscode-azureresourcegroups",
3536
"ms-azure-devops.azure-pipelines",
36-
"vitest.dev"
37+
"vitest.dev",
38+
"ms-vscode.powershell",
39+
"ms-azuretools.vscode-bicep"
3740
]
3841
}
3942
},

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ Desktop.ini
3838
# Local testing output (use --output .local-extract for local runs)
3939
.local-extract*/
4040

41+
# Log files for integration tests
42+
tests/integration/all-resource-types/logs/**
43+
4144
# Environment variables
4245
.env
4346
.env.local

src/cli/extract-command.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,10 @@ async function executeExtract(
7575
}
7676

7777
// Build service context
78-
const apiVersion = globalOpts.apiVersion ?? process.env.AZURE_API_VERSION ?? '2024-05-01';
78+
// Default to a recent preview API version so newer resource types (e.g.
79+
// MCP-typed APIs) are returned by ARM list endpoints. Older versions
80+
// (e.g. 2024-05-01) silently omit MCP APIs from /apis.
81+
const apiVersion = globalOpts.apiVersion ?? process.env.AZURE_API_VERSION ?? '2025-09-01-preview';
7982
const cloudName = globalOpts.cloud ?? 'public';
8083
const cloudConfig = getCloudConfig(cloudName);
8184
const baseUrl = buildArmBaseUrl(cloudName, subscriptionId, options.resourceGroup, options.serviceName);

src/cli/publish-command.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,11 @@ async function executePublish(
8787
}
8888

8989
// Build service context
90+
// Default to a recent preview API version so newer resource types (e.g.
91+
// MCP-typed APIs) are accepted by ARM. Older versions (e.g. 2024-05-01)
92+
// reject MCP API payloads.
9093
const apiVersion =
91-
globalOpts.apiVersion ?? process.env.AZURE_API_VERSION ?? '2024-05-01';
94+
globalOpts.apiVersion ?? process.env.AZURE_API_VERSION ?? '2025-09-01-preview';
9295
const cloudName = globalOpts.cloud ?? 'public';
9396
const cloudConfig = getCloudConfig(cloudName);
9497
const baseUrl = buildArmBaseUrl(

src/services/api-extractor.ts

Lines changed: 89 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -58,19 +58,47 @@ function hasEmbeddedMcpConfiguration(apiJson: Record<string, unknown>): boolean
5858
return true;
5959
}
6060

61+
// MCP APIs created from an existing MCP server are wired purely via
62+
// backendId + (optionally absent) mcpProperties. A non-null backendId on a
63+
// type='mcp' API is itself an MCP server configuration we must capture.
64+
if (typeof properties.backendId === 'string' && properties.backendId.length > 0) {
65+
return true;
66+
}
67+
6168
return false;
6269
}
6370

64-
function buildEmbeddedMcpServerResource(apiJson: Record<string, unknown>): Record<string, unknown> {
71+
function buildEmbeddedMcpServerResource(
72+
apiJson: Record<string, unknown>,
73+
backendUrl?: string
74+
): Record<string, unknown> {
6575
const properties = getApiProperties(apiJson) ?? {};
6676
const resourceProperties: Record<string, unknown> = {};
6777

68-
if (properties.mcpProperties !== undefined) {
69-
resourceProperties.mcpProperties = properties.mcpProperties;
78+
// Clone mcpProperties so we can augment it with serverUrl without mutating
79+
// the caller's apiJson.
80+
let mcpProperties: Record<string, unknown> | undefined;
81+
if (properties.mcpProperties && typeof properties.mcpProperties === 'object') {
82+
mcpProperties = { ...(properties.mcpProperties as Record<string, unknown>) };
83+
} else if (backendUrl) {
84+
mcpProperties = {};
85+
}
86+
87+
if (mcpProperties && backendUrl && mcpProperties.serverUrl === undefined) {
88+
mcpProperties.serverUrl = backendUrl;
89+
}
90+
91+
if (mcpProperties !== undefined) {
92+
resourceProperties.mcpProperties = mcpProperties;
7093
}
7194
if (properties.mcpTools !== undefined) {
7295
resourceProperties.mcpTools = properties.mcpTools;
7396
}
97+
// Preserve the link to the upstream backend so the MCP server sidecar is
98+
// self-describing for MCP-from-existing-MCP-server APIs.
99+
if (typeof properties.backendId === 'string' && properties.backendId.length > 0) {
100+
resourceProperties.backendId = properties.backendId;
101+
}
74102

75103
return {
76104
name: 'default',
@@ -79,25 +107,43 @@ function buildEmbeddedMcpServerResource(apiJson: Record<string, unknown>): Recor
79107

80108
}
81109

82-
function hasMeaningfulMcpContent(mcpJson: Record<string, unknown>): boolean {
83-
const properties = mcpJson.properties as Record<string, unknown> | undefined;
84-
if (!properties) {
85-
return false;
110+
/**
111+
* For an MCP API wired to a backend via `backendId`, fetch that backend and
112+
* return its `properties.url` so the MCP sidecar can carry the actual upstream
113+
* server URL. Returns undefined when the API has no backendId, the backend
114+
* cannot be fetched, or the backend has no url.
115+
*/
116+
async function resolveLinkedBackendUrl(
117+
client: IApimClient,
118+
context: ApimServiceContext,
119+
apiJson: Record<string, unknown>,
120+
workspace?: string
121+
): Promise<string | undefined> {
122+
const properties = getApiProperties(apiJson);
123+
const backendId = properties?.backendId;
124+
if (typeof backendId !== 'string' || backendId.length === 0) {
125+
return undefined;
86126
}
87127

88-
// Check if mcpTools has actual content (non-empty array), excluding null
89-
const mcpTools = properties.mcpTools as unknown[] | undefined | null;
90-
if (Array.isArray(mcpTools) && mcpTools.length > 0) {
91-
return true;
92-
}
128+
// backendId may be either a bare resource name or a full ARM resource id.
129+
// We only consume the trailing name segment for descriptor lookup.
130+
const backendName = backendId.includes('/') ? backendId.split('/').pop()! : backendId;
93131

94-
// Check if mcpProperties exists and is not null or undefined
95-
if (properties.mcpProperties != null) {
96-
return true;
132+
try {
133+
const backendJson = await client.getResource(context, {
134+
type: ResourceType.Backend,
135+
nameParts: [backendName],
136+
workspace,
137+
});
138+
const url = (backendJson?.properties as Record<string, unknown> | undefined)?.url;
139+
return typeof url === 'string' && url.length > 0 ? url : undefined;
140+
} catch (error) {
141+
const errorMessage = error instanceof Error ? error.message : String(error);
142+
logger.debug(`Could not resolve backend "${backendName}" for MCP server URL: ${errorMessage}`);
143+
return undefined;
97144
}
98-
99-
return false;
100145
}
146+
101147
/**
102148
* Extract all API-specific resources for a single API.
103149
* This includes revisions, specifications, operations, policies, etc.
@@ -568,8 +614,18 @@ async function extractGraphQLResolvers(
568614

569615
/**
570616
* Extract MCP (Model Context Protocol) server configuration for an API.
571-
* The MCP server is a singleton resource per API exposed at apis/{id}/mcpServers/default.
572-
* Silently skips if the API does not have MCP enabled or the resource does not exist.
617+
*
618+
* MCP configuration is embedded directly on the API resource
619+
* (`properties.mcpTools`, `properties.mcpProperties`, `properties.backendId`).
620+
* There is no separate child resource served by ARM — the
621+
* `apis/{id}/mcpServers/default` endpoint returns 404 even on working MCP APIs,
622+
* and `apis/{id}/mcpServers` returns 500 (no such collection). All MCP data
623+
* therefore comes from the API JSON itself.
624+
*
625+
* For MCP APIs created from an existing MCP server (the `backendId` pattern),
626+
* the upstream URL lives on the linked backend, not on the API. To make the
627+
* extracted `mcpServerInformation.json` self-describing, the extractor
628+
* resolves that backend and surfaces its URL as `mcpProperties.serverUrl`.
573629
*/
574630
async function extractApiMcpServer(
575631
client: IApimClient,
@@ -586,30 +642,24 @@ async function extractApiMcpServer(
586642
return false;
587643
}
588644

645+
if (!hasEmbeddedMcpConfiguration(apiJson)) {
646+
return false;
647+
}
648+
589649
const mcpDescriptor: ResourceDescriptor = {
590650
type: ResourceType.McpServer,
591651
nameParts: [...apiDescriptor.nameParts],
592652
workspace: apiDescriptor.workspace,
593653
};
594654

595-
if (hasEmbeddedMcpConfiguration(apiJson)) {
596-
await store.writeResource(outputDir, mcpDescriptor, buildEmbeddedMcpServerResource(apiJson));
597-
logger.info(`Extracted ${buildResourceLabel(mcpDescriptor)} from API metadata`);
598-
return true;
599-
}
600-
601-
try {
602-
const mcpJson = await client.getResource(context, mcpDescriptor);
603-
if (!mcpJson || !hasMeaningfulMcpContent(mcpJson)) {
604-
return false;
605-
}
606-
607-
await store.writeResource(outputDir, mcpDescriptor, mcpJson);
608-
logger.info(`Extracted ${buildResourceLabel(mcpDescriptor)}`);
609-
return true;
610-
} catch (error) {
611-
const errorMessage = error instanceof Error ? error.message : String(error);
612-
logger.debug(`No MCP server configuration ${buildResourceLabel(mcpDescriptor)}: ${errorMessage}`);
613-
return false;
614-
}
655+
// Resolve the upstream backend URL so the MCP sidecar carries the actual
656+
// server URL (not just a backendId reference + uri template).
657+
const backendUrl = await resolveLinkedBackendUrl(client, context, apiJson, apiDescriptor.workspace);
658+
await store.writeResource(
659+
outputDir,
660+
mcpDescriptor,
661+
buildEmbeddedMcpServerResource(apiJson, backendUrl)
662+
);
663+
logger.info(`Extracted ${buildResourceLabel(mcpDescriptor)} from API metadata`);
664+
return true;
615665
}

tests/integration/all-resource-types/Compare-ApimInstance.ps1

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ $ErrorActionPreference = 'Stop'
2828

2929
# ── Constants ───────────────────────────────────────────────────────────────
3030

31-
$ApiVersion = '2024-05-01'
31+
# Use the newest APIM ARM API version so resource types introduced in newer
32+
# previews (e.g. apis/{api}/mcpServers under 2025-09-01-preview) are queryable.
33+
$ApiVersion = '2025-09-01-preview'
3234

3335
$SourceBase = "https://management.azure.com/subscriptions/$SourceSubscriptionId/resourceGroups/$SourceResourceGroup/providers/Microsoft.ApiManagement/service/$SourceApimName"
3436
$TargetBase = "https://management.azure.com/subscriptions/$TargetSubscriptionId/resourceGroups/$TargetResourceGroup/providers/Microsoft.ApiManagement/service/$TargetApimName"
@@ -65,6 +67,9 @@ function Get-ArmResourceList {
6567
<#
6668
.SYNOPSIS
6769
GETs a paginated ARM list, following nextLink, and returns all items.
70+
A 404 / "Not Found" response is treated as an empty list — optional or
71+
singleton child resources (e.g. apis/{api}/mcpServers) legitimately do
72+
not exist on every parent.
6873
#>
6974
[CmdletBinding()]
7075
param(
@@ -82,6 +87,11 @@ function Get-ArmResourceList {
8287
try {
8388
$raw = az rest --method GET --url $fullUrl 2>&1
8489
if ($LASTEXITCODE -ne 0) {
90+
$rawText = "$raw"
91+
if ($rawText -match '(?i)\bNot Found\b' -or $rawText -match '"code"\s*:\s*"ResourceNotFound"' -or $rawText -match '"code"\s*:\s*"NotFound"') {
92+
Write-Verbose "GET $fullUrl returned Not Found — treating as empty"
93+
return $items
94+
}
8595
throw "az rest failed (exit $LASTEXITCODE): $raw"
8696
}
8797
$response = $raw | ConvertFrom-Json
@@ -650,6 +660,12 @@ try {
650660
@{ Label = 'Releases'; Suffix = 'releases' }
651661
@{ Label = 'Wikis'; Suffix = 'wikis' }
652662
@{ Label = 'Tag Descriptions'; Suffix = 'tagDescriptions' }
663+
# NOTE: 'mcpServers' is intentionally omitted. ARM does not expose a
664+
# list collection at apis/{api}/mcpServers (returns HTTP 500), and the
665+
# singleton apis/{api}/mcpServers/default returns 404 even on working
666+
# MCP APIs. MCP server configuration (mcpProperties, mcpTools,
667+
# backendId) lives on the parent API resource and is verified by the
668+
# top-level "Comparing APIs" deep comparison above.
653669
)
654670

655671
foreach ($apiName in $apiNames) {

tests/integration/all-resource-types/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ The kitchen sink APIM instance includes **every resource type and API protocol v
1616
| `src-websocket` | WebSocket | None |
1717
| `src-rest-versioned-v1` | REST (versioned) | OpenAPI |
1818
| `src-rest-revisioned` | REST (revisioned) | OpenAPI |
19-
| `src-rest-mcp-style` | REST (MCP-style backend) | None |
19+
| `src-mcp-from-api` | MCP (from existing API) | None |
20+
| `src-mcp-from-external` | MCP (from external MCP server) | None |
2021

2122
### Backend Variations
2223
| Backend | Type |

tests/integration/all-resource-types/expected-structure.json

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@
120120
]
121121
},
122122
"backends": {
123-
"minCount": 5,
123+
"minCount": 6,
124124
"expected": [
125125
{
126126
"name": "src-backend-http",
@@ -172,6 +172,17 @@
172172
"properties.pool.services": "exists"
173173
}
174174
}
175+
},
176+
{
177+
"name": "src-backend-mcp-external",
178+
"files": ["backendInformation.json"],
179+
"spotChecks": {
180+
"backendInformation.json": {
181+
"properties.url": "https://api.githubcopilot.com/mcp",
182+
"properties.protocol": "http"
183+
}
184+
},
185+
"notes": "Upstream URL for the MCP-from-external API; the API resource references this backend via backendId"
175186
}
176187
]
177188
},
@@ -361,7 +372,7 @@
361372
]
362373
},
363374
"apis": {
364-
"minCount": 8,
375+
"minCount": 9,
365376
"expected": [
366377
{
367378
"name": "src-rest-openapi",
@@ -569,16 +580,39 @@
569580
"notes": "Revision 2 of src-rest-revisioned"
570581
},
571582
{
572-
"name": "src-rest-mcp-style",
573-
"files": ["apiInformation.json"],
583+
"name": "src-mcp-from-api",
584+
"files": ["apiInformation.json", "mcpServerInformation.json"],
574585
"spotChecks": {
575586
"apiInformation.json": {
576-
"properties.displayName": "KS MCP-Style REST API",
577-
"properties.path": "ks/mcp",
587+
"properties.displayName": "KS MCP from Existing API",
588+
"properties.path": "ks/mcp-from-api",
589+
"properties.type": "mcp",
578590
"properties.subscriptionRequired": false
591+
},
592+
"mcpServerInformation.json": {
593+
"properties.mcpTools": "exists"
594+
}
595+
},
596+
"notes": "MCP API exposing operations of an existing REST API as MCP tools via mcpTools (each tool's operationId references the backing REST API; this MCP API has no operations of its own)"
597+
},
598+
{
599+
"name": "src-mcp-from-external",
600+
"files": ["apiInformation.json", "mcpServerInformation.json"],
601+
"spotChecks": {
602+
"apiInformation.json": {
603+
"properties.displayName": "KS MCP from External Server",
604+
"properties.path": "ks/mcp-external",
605+
"properties.type": "mcp",
606+
"properties.subscriptionRequired": false,
607+
"properties.backendId": "src-backend-mcp-external"
608+
},
609+
"mcpServerInformation.json": {
610+
"properties.mcpProperties": "exists",
611+
"properties.mcpProperties.endpoints.mcp.uriTemplate": "/mcp",
612+
"properties.backendId": "src-backend-mcp-external"
579613
}
580614
},
581-
"notes": "REST API with explicitly defined operation (no operation-level policies, so no operations directory)"
615+
"notes": "MCP API repackaging an external MCP server: backendId points to the backend that holds the upstream URL (https://api.githubcopilot.com/mcp), and mcpProperties.endpoints.mcp.uriTemplate addresses the MCP endpoint exposed by that backend"
582616
}
583617
]
584618
},

0 commit comments

Comments
 (0)