Skip to content

Commit 8632901

Browse files
CopilotEMaher
andauthored
Handle MCP publish from embedded API metadata (#173)
* fix: update MCP publish handling progress * fix: finalize MCP publish handling * test: clarify MCP publish coverage * fixing round-trip test * fixing expected structure --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Elizabeth Maher <enewman@microsoft.com>
1 parent 9ff5962 commit 8632901

8 files changed

Lines changed: 163 additions & 243 deletions

File tree

src/models/resource-types.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -313,10 +313,12 @@ export const RESOURCE_TYPE_METADATA: Record<ResourceType, ResourceTypeMetadata>
313313
},
314314
[ResourceType.McpServer]: {
315315
// Singleton MCP (Model Context Protocol) server configuration per API.
316-
// ARM path: apis/{apiId}/mcpServers/default
316+
// Artifact data is embedded in apiInformation.json; retain the descriptor
317+
// only for ARM URI/dependency modeling. With infoFile unset, extract/publish
318+
// no longer read or write the legacy mcpServerInformation.json sidecar.
317319
armPathSuffix: 'apis/{0}/mcpServers/default',
318320
artifactDirectory: 'apis/{0}',
319-
infoFile: 'mcpServerInformation.json',
321+
infoFile: null, // Embedded in apiInformation.json
320322
supportsGet: true,
321323
},
322324
[ResourceType.Workspace]: {

src/services/api-extractor.ts

Lines changed: 1 addition & 165 deletions
Original file line numberDiff line numberDiff line change
@@ -40,113 +40,6 @@ export interface ApiExtractionResult {
4040
policies: string[];
4141
}
4242

43-
function getApiProperties(apiJson: Record<string, unknown>): Record<string, unknown> | undefined {
44-
return apiJson.properties as Record<string, unknown> | undefined;
45-
}
46-
47-
function hasEmbeddedMcpConfiguration(apiJson: Record<string, unknown>): boolean {
48-
const properties = getApiProperties(apiJson);
49-
if (!properties) {
50-
return false;
51-
}
52-
53-
// Check if mcpTools has actual content (non-empty array), excluding null
54-
const mcpTools = properties.mcpTools as unknown[] | undefined | null;
55-
if (Array.isArray(mcpTools) && mcpTools.length > 0) {
56-
return true;
57-
}
58-
59-
// Check if mcpProperties exists and is not null or undefined
60-
if (properties.mcpProperties != null) {
61-
return true;
62-
}
63-
64-
// MCP APIs created from an existing MCP server are wired purely via
65-
// backendId + (optionally absent) mcpProperties. A non-null backendId on a
66-
// type='mcp' API is itself an MCP server configuration we must capture.
67-
if (typeof properties.backendId === 'string' && properties.backendId.length > 0) {
68-
return true;
69-
}
70-
71-
return false;
72-
}
73-
74-
function buildEmbeddedMcpServerResource(
75-
apiJson: Record<string, unknown>,
76-
backendUrl?: string
77-
): Record<string, unknown> {
78-
const properties = getApiProperties(apiJson) ?? {};
79-
const resourceProperties: Record<string, unknown> = {};
80-
81-
// Clone mcpProperties so we can augment it with serverUrl without mutating
82-
// the caller's apiJson.
83-
let mcpProperties: Record<string, unknown> | undefined;
84-
if (properties.mcpProperties && typeof properties.mcpProperties === 'object') {
85-
mcpProperties = { ...(properties.mcpProperties as Record<string, unknown>) };
86-
} else if (backendUrl) {
87-
mcpProperties = {};
88-
}
89-
90-
if (mcpProperties && backendUrl && mcpProperties.serverUrl === undefined) {
91-
mcpProperties.serverUrl = backendUrl;
92-
}
93-
94-
if (mcpProperties !== undefined) {
95-
resourceProperties.mcpProperties = mcpProperties;
96-
}
97-
if (properties.mcpTools !== undefined) {
98-
resourceProperties.mcpTools = properties.mcpTools;
99-
}
100-
// Preserve the link to the upstream backend so the MCP server sidecar is
101-
// self-describing for MCP-from-existing-MCP-server APIs.
102-
if (typeof properties.backendId === 'string' && properties.backendId.length > 0) {
103-
resourceProperties.backendId = properties.backendId;
104-
}
105-
106-
return {
107-
name: 'default',
108-
properties: resourceProperties,
109-
};
110-
111-
}
112-
113-
/**
114-
* For an MCP API wired to a backend via `backendId`, fetch that backend and
115-
* return its `properties.url` so the MCP sidecar can carry the actual upstream
116-
* server URL. Returns undefined when the API has no backendId, the backend
117-
* cannot be fetched, or the backend has no url.
118-
*/
119-
async function resolveLinkedBackendUrl(
120-
client: IApimClient,
121-
context: ApimServiceContext,
122-
apiJson: Record<string, unknown>,
123-
workspace?: string
124-
): Promise<string | undefined> {
125-
const properties = getApiProperties(apiJson);
126-
const backendId = properties?.backendId;
127-
if (typeof backendId !== 'string' || backendId.length === 0) {
128-
return undefined;
129-
}
130-
131-
// backendId may be either a bare resource name or a full ARM resource id.
132-
// We only consume the trailing name segment for descriptor lookup.
133-
const backendName = backendId.includes('/') ? backendId.split('/').pop()! : backendId;
134-
135-
try {
136-
const backendJson = await client.getResource(context, {
137-
type: ResourceType.Backend,
138-
nameParts: [backendName],
139-
workspace,
140-
});
141-
const url = (backendJson?.properties as Record<string, unknown> | undefined)?.url;
142-
return typeof url === 'string' && url.length > 0 ? url : undefined;
143-
} catch (error) {
144-
const errorMessage = error instanceof Error ? error.message : String(error);
145-
logger.debug(`Could not resolve backend "${backendName}" for MCP server URL: ${errorMessage}`);
146-
return undefined;
147-
}
148-
}
149-
15043
/**
15144
* Extract all API-specific resources for a single API.
15245
* This includes revisions, specifications, operations, policies, etc.
@@ -259,11 +152,6 @@ export async function extractApiResources(
259152
client, store, context, apiDescriptor, outputDir
260153
);
261154

262-
// Extract MCP server configuration (singleton per API; silently skipped when not present)
263-
result.mcpServer = await extractApiMcpServer(
264-
client, store, context, apiDescriptor, apiJson, outputDir
265-
);
266-
267155
// Extract GraphQL resolvers and their policies
268156
const resolverResult = await extractGraphQLResolvers(
269157
client, store, context, apiDescriptor, apiJson, outputDir, filter, workspace
@@ -585,7 +473,7 @@ async function extractGraphQLResolvers(
585473
const policies: string[] = [];
586474

587475
// Only extract resolvers for GraphQL APIs — use the already-fetched apiJson
588-
const properties = getApiProperties(apiJson);
476+
const properties = apiJson.properties as Record<string, unknown> | undefined;
589477
const apiType = properties?.type as string | undefined;
590478
if (apiType?.toLowerCase() !== 'graphql') {
591479
return { resolvers, resolverPolicies, policies };
@@ -629,58 +517,6 @@ async function extractGraphQLResolvers(
629517
return { resolvers, resolverPolicies, policies };
630518
}
631519

632-
/**
633-
* Extract MCP (Model Context Protocol) server configuration for an API.
634-
*
635-
* MCP configuration is embedded directly on the API resource
636-
* (`properties.mcpTools`, `properties.mcpProperties`, `properties.backendId`).
637-
* There is no separate child resource served by ARM — the
638-
* `apis/{id}/mcpServers/default` endpoint returns 404 even on working MCP APIs,
639-
* and `apis/{id}/mcpServers` returns 500 (no such collection). All MCP data
640-
* therefore comes from the API JSON itself.
641-
*
642-
* For MCP APIs created from an existing MCP server (the `backendId` pattern),
643-
* the upstream URL lives on the linked backend, not on the API. To make the
644-
* extracted `mcpServerInformation.json` self-describing, the extractor
645-
* resolves that backend and surfaces its URL as `mcpProperties.serverUrl`.
646-
*/
647-
async function extractApiMcpServer(
648-
client: IApimClient,
649-
store: IArtifactStore,
650-
context: ApimServiceContext,
651-
apiDescriptor: ResourceDescriptor,
652-
apiJson: Record<string, unknown>,
653-
outputDir: string
654-
): Promise<boolean> {
655-
const apiType = (getApiProperties(apiJson)?.type as string | undefined)?.toLowerCase();
656-
657-
// Avoid creating MCP artifacts for non-MCP APIs unless they carry meaningful MCP metadata.
658-
if (apiType !== 'mcp' && !hasEmbeddedMcpConfiguration(apiJson)) {
659-
return false;
660-
}
661-
662-
if (!hasEmbeddedMcpConfiguration(apiJson)) {
663-
return false;
664-
}
665-
666-
const mcpDescriptor: ResourceDescriptor = {
667-
type: ResourceType.McpServer,
668-
nameParts: [...apiDescriptor.nameParts],
669-
workspace: apiDescriptor.workspace,
670-
};
671-
672-
// Resolve the upstream backend URL so the MCP sidecar carries the actual
673-
// server URL (not just a backendId reference + uri template).
674-
const backendUrl = await resolveLinkedBackendUrl(client, context, apiJson, apiDescriptor.workspace);
675-
await store.writeResource(
676-
outputDir,
677-
mcpDescriptor,
678-
buildEmbeddedMcpServerResource(apiJson, backendUrl)
679-
);
680-
logger.info(`Extracted ${buildResourceLabel(mcpDescriptor)} from API metadata`);
681-
return true;
682-
}
683-
684520
/**
685521
* Extract API tag associations in workspace scope using the tag-centric
686522
* `tags/{tag}/apiLinks` endpoint.

src/services/api-publisher.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ import type { ApimServiceContext, ResourceDescriptor } from '../models/types.js'
1313
import type { PublishConfig } from '../models/config.js';
1414
import * as yaml from 'js-yaml';
1515
import { ResourceType } from '../models/resource-types.js';
16-
import { publishResource, type ResourcePublishResult } from './resource-publisher.js';
16+
import {
17+
normalizeMcpToolOperationIds,
18+
publishResource,
19+
type ResourcePublishResult,
20+
} from './resource-publisher.js';
1721
import { runParallel } from '../lib/parallel-runner.js';
1822
import { applyOverrides } from './override-merger.js';
1923
import { logger } from '../lib/logger.js';
@@ -33,7 +37,6 @@ const API_CHILD_TYPES: ResourceType[] = [
3337
ResourceType.ApiRelease,
3438
ResourceType.ApiTagDescription,
3539
ResourceType.ApiWiki,
36-
ResourceType.McpServer,
3740
ResourceType.GraphQLResolver,
3841
ResourceType.GraphQLResolverPolicy,
3942
];
@@ -167,6 +170,11 @@ async function publishRootApi(
167170
};
168171
}
169172

173+
// Root APIs publish through api-publisher rather than publishResource, so
174+
// they need the same pre-override MCP tool normalization here that revision
175+
// APIs receive in resource-publisher.
176+
json = normalizeMcpToolOperationIds(json, context);
177+
170178
// Apply overrides
171179
json = applyOverrides(descriptor, json, config.overrides);
172180
const isCurrent = getApiIsCurrent(json);

src/services/resource-publisher.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,12 @@ export async function publishResource(
120120
};
121121
}
122122

123+
// Rewrite MCP tool operationIds against the target service before overrides
124+
// so explicit override values win unchanged.
125+
if (descriptor.type === ResourceType.Api) {
126+
json = normalizeMcpToolOperationIds(json, context);
127+
}
128+
123129
// Apply overrides (deep merge, preserves opaque structure)
124130
json = applyOverrides(descriptor, json, config.overrides);
125131

@@ -233,7 +239,6 @@ export async function publishResource(
233239
// validation errors in APIM's revision creation.
234240
if (descriptor.type === ResourceType.Api) {
235241
const apiName = getNamePart(descriptor.nameParts, 0);
236-
json = normalizeMcpToolOperationIds(json, context);
237242
if (apiName.includes(';rev=')) {
238243
const baseApiName = apiName.split(';rev=')[0];
239244
const props = json.properties as Record<string, unknown> | undefined;
@@ -516,7 +521,7 @@ function normalizeApiReleaseApiId(
516521
* operationId to target service ARM prefix so APIM validates MCP tools against
517522
* existing operations in the target instance.
518523
*/
519-
function normalizeMcpToolOperationIds(
524+
export function normalizeMcpToolOperationIds(
520525
json: Record<string, unknown>,
521526
context: ApimServiceContext
522527
): Record<string, unknown> {

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

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -553,34 +553,29 @@
553553
},
554554
{
555555
"name": "src-mcp-from-api",
556-
"files": ["apiInformation.json", "mcpServerInformation.json"],
556+
"files": ["apiInformation.json"],
557557
"spotChecks": {
558558
"apiInformation.json": {
559559
"properties.displayName": "KS MCP from Existing API",
560560
"properties.path": "ks/mcp-from-api",
561561
"properties.type": "mcp",
562-
"properties.subscriptionRequired": false
563-
},
564-
"mcpServerInformation.json": {
562+
"properties.subscriptionRequired": false,
565563
"properties.mcpTools": "exists"
566564
}
567565
},
568566
"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)"
569567
},
570568
{
571569
"name": "src-mcp-existing-server",
572-
"files": ["apiInformation.json", "mcpServerInformation.json", "policy.xml"],
570+
"files": ["apiInformation.json", "policy.xml"],
573571
"spotChecks": {
574572
"apiInformation.json": {
575573
"properties.displayName": "KS MCP Existing Server Demo",
576574
"properties.path": "ks/mcp-existing",
577575
"properties.type": "mcp",
578576
"properties.subscriptionRequired": false,
579-
"properties.backendId": "src-backend-mcp-learn"
580-
},
581-
"mcpServerInformation.json": {
582-
"properties.mcpProperties.endpoints.mcp.uriTemplate": "/mcp",
583-
"properties.backendId": "src-backend-mcp-learn"
577+
"properties.backendId": "src-backend-mcp-learn",
578+
"properties.mcpProperties.endpoints.mcp.uriTemplate": "/mcp"
584579
}
585580
},
586581
"notes": "Working existing-server MCP demo. APIM exposes a public Microsoft Learn MCP server through a policy-based MCP proxy so the API is extractable and demoable end to end."

tests/unit/services/api-product-extractor.test.ts

Lines changed: 4 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -850,7 +850,7 @@ describe('api-extractor', () => {
850850
);
851851
});
852852

853-
it('should extract MCP server configuration from embedded API metadata when present', async () => {
853+
it('should keep embedded MCP configuration in apiInformation.json without writing a sidecar file', async () => {
854854
const client = createMockClient({
855855
getApiSpecification: vi.fn().mockResolvedValue(undefined),
856856
getResource: vi.fn().mockResolvedValue(undefined),
@@ -873,21 +873,15 @@ describe('api-extractor', () => {
873873
client, store, testContext, apiDescriptor, apiJson, '/output'
874874
);
875875

876-
expect(result.mcpServer).toBe(true);
876+
expect(result.mcpServer).toBe(false);
877877
expect(client.getResource).not.toHaveBeenCalledWith(
878878
testContext,
879879
expect.objectContaining({ type: ResourceType.McpServer })
880880
);
881-
expect(store.writeResource).toHaveBeenCalledWith(
881+
expect(store.writeResource).not.toHaveBeenCalledWith(
882882
'/output',
883883
expect.objectContaining({ type: ResourceType.McpServer, nameParts: ['my-api'] }),
884-
{
885-
name: 'default',
886-
properties: {
887-
mcpProperties: { serverUrl: 'https://example.com/mcp' },
888-
mcpTools: [{ name: 'invokeTool' }],
889-
},
890-
}
884+
expect.anything()
891885
);
892886
});
893887

@@ -910,31 +904,6 @@ describe('api-extractor', () => {
910904

911905
expect(result.mcpServer).toBe(false);
912906
});
913-
914-
it('should return mcpServer=false and not throw when getResource throws for McpServer', async () => {
915-
const client = createMockClient({
916-
getApiSpecification: vi.fn().mockResolvedValue(undefined),
917-
getResource: vi.fn().mockImplementation(async (_ctx: unknown, desc: ResourceDescriptor) => {
918-
if (desc.type === ResourceType.McpServer) {
919-
throw new Error('404 Not Found');
920-
}
921-
return undefined;
922-
}),
923-
});
924-
const store = createMockStore();
925-
const apiDescriptor: ResourceDescriptor = {
926-
type: ResourceType.Api,
927-
nameParts: ['my-api'],
928-
};
929-
930-
const result = await extractApiResources(
931-
client, store, testContext, apiDescriptor,
932-
{ name: 'my-api' },
933-
'/output'
934-
);
935-
936-
expect(result.mcpServer).toBe(false);
937-
});
938907
});
939908
});
940909

0 commit comments

Comments
 (0)