Skip to content

Commit 4fe70fa

Browse files
CopilotEMaher
andauthored
feat: add MCP server support for apiops extract and publish
Closes #17 Agent-Logs-Url: https://github.com/Azure/apiops-cli/sessions/09cf004e-7aec-4a98-aefa-7700a7749a8d Co-authored-by: EMaher <9244742+EMaher@users.noreply.github.com>
1 parent 0e91623 commit 4fe70fa

8 files changed

Lines changed: 192 additions & 9 deletions

File tree

src/lib/dependency-graph.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ export const DEPENDENCY_EDGES: DependencyEdge[] = [
5151
{ from: ResourceType.Subscription, to: ResourceType.Product, required: false },
5252
{ from: ResourceType.Subscription, to: ResourceType.Api, required: false },
5353

54+
{ from: ResourceType.McpServer, to: ResourceType.Api, required: true },
55+
5456
// Tier 3 -> Tier 4 dependencies
5557
{ from: ResourceType.ApiOperationPolicy, to: ResourceType.ApiOperation, required: true },
5658
{ from: ResourceType.GraphQLResolverPolicy, to: ResourceType.GraphQLResolver, required: true },
@@ -91,6 +93,7 @@ export const TIER_3_RESOURCES: ResourceType[] = [
9193
ResourceType.ApiRelease,
9294
ResourceType.ApiTagDescription,
9395
ResourceType.ApiWiki,
96+
ResourceType.McpServer,
9497
ResourceType.GraphQLResolver,
9598
ResourceType.GatewayApi,
9699
ResourceType.Subscription,

src/models/resource-types.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
22
* T006: ResourceType enum and metadata
3-
* All 33 APIM resource types with ARM path suffixes, artifact directories, and info file names
3+
* All 34 APIM resource types with ARM path suffixes, artifact directories, and info file names
44
*/
55

66
export enum ResourceType {
@@ -37,6 +37,8 @@ export enum ResourceType {
3737
ProductWiki = 'ProductWiki',
3838
GraphQLResolver = 'GraphQLResolver',
3939
GraphQLResolverPolicy = 'GraphQLResolverPolicy',
40+
/** MCP (Model Context Protocol) server configuration per API. Singleton per API. */
41+
McpServer = 'McpServer',
4042
}
4143

4244
/**
@@ -266,4 +268,12 @@ export const RESOURCE_TYPE_METADATA: Record<ResourceType, ResourceTypeMetadata>
266268
infoFile: 'policy.xml',
267269
supportsGet: true,
268270
},
271+
[ResourceType.McpServer]: {
272+
// Singleton MCP (Model Context Protocol) server configuration per API.
273+
// ARM path: apis/{apiId}/mcpServers/default
274+
armPathSuffix: 'apis/{0}/mcpServers/default',
275+
artifactDirectory: 'apis/{0}',
276+
infoFile: 'mcpServerInformation.json',
277+
supportsGet: true,
278+
},
269279
};

src/services/api-extractor.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export interface ApiExtractionResult {
3131
releases: ExtractedResource[];
3232
tagDescriptions: ExtractedResource[];
3333
wiki: boolean;
34+
mcpServer: boolean;
3435
resolvers: ExtractedResource[];
3536
resolverPolicies: ExtractedResource[];
3637
policies: string[];
@@ -63,6 +64,7 @@ export async function extractApiResources(
6364
releases: [],
6465
tagDescriptions: [],
6566
wiki: false,
67+
mcpServer: false,
6668
resolvers: [],
6769
resolverPolicies: [],
6870
policies: [],
@@ -138,6 +140,11 @@ export async function extractApiResources(
138140
client, store, context, apiDescriptor, outputDir
139141
);
140142

143+
// Extract MCP server configuration (if enabled for this API)
144+
result.mcpServer = await extractApiMcpServer(
145+
client, store, context, apiDescriptor, outputDir
146+
);
147+
141148
// Extract GraphQL resolvers and their policies
142149
const resolverResult = await extractGraphQLResolvers(
143150
client, store, context, apiDescriptor, apiJson, outputDir, filter, workspace
@@ -237,6 +244,11 @@ async function extractApiSpecification(
237244
return false;
238245
}
239246

247+
if (apiType?.toLowerCase() === 'mcp') {
248+
logger.debug(`OpenAPI does not apply to MCP APIs`);
249+
return false;
250+
}
251+
240252
if (apiType?.toLowerCase() === 'graphql' && hasGraphQLSchema(extractedSchemas)) {
241253
logger.debug(
242254
`Skipping spec export for synthetic GraphQL API "${getNamePart(apiDescriptor.nameParts, 0)}" — schema is captured via ApiSchema`
@@ -492,3 +504,37 @@ async function extractGraphQLResolvers(
492504

493505
return { resolvers, resolverPolicies, policies };
494506
}
507+
508+
/**
509+
* Extract MCP (Model Context Protocol) server configuration for an API.
510+
* The MCP server is a singleton resource per API exposed at apis/{id}/mcpServers/default.
511+
* Silently skips if the API does not have MCP enabled or the resource does not exist.
512+
*/
513+
async function extractApiMcpServer(
514+
client: IApimClient,
515+
store: IArtifactStore,
516+
context: ApimServiceContext,
517+
apiDescriptor: ResourceDescriptor,
518+
outputDir: string
519+
): Promise<boolean> {
520+
const mcpDescriptor: ResourceDescriptor = {
521+
type: ResourceType.McpServer,
522+
nameParts: [...apiDescriptor.nameParts],
523+
workspace: apiDescriptor.workspace,
524+
};
525+
526+
try {
527+
const mcpJson = await client.getResource(context, mcpDescriptor);
528+
if (!mcpJson) {
529+
return false;
530+
}
531+
532+
await store.writeResource(outputDir, mcpDescriptor, mcpJson);
533+
logger.info(`Extracted ${buildResourceLabel(mcpDescriptor)}`);
534+
return true;
535+
} catch (error) {
536+
const errorMessage = error instanceof Error ? error.message : String(error);
537+
logger.debug(`No MCP server configuration ${buildResourceLabel(mcpDescriptor)}: ${errorMessage}`);
538+
return false;
539+
}
540+
}

src/services/api-publisher.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const API_CHILD_TYPES: ResourceType[] = [
3030
ResourceType.ApiRelease,
3131
ResourceType.ApiTagDescription,
3232
ResourceType.ApiWiki,
33+
ResourceType.McpServer,
3334
ResourceType.GraphQLResolver,
3435
ResourceType.GraphQLResolverPolicy,
3536
];

src/services/extract-service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,7 @@ async function extractApiSubResources(
261261
apiResult.releases.filter((r) => r.status === 'success').length +
262262
apiResult.tagDescriptions.filter((r) => r.status === 'success').length +
263263
(apiResult.wiki ? 1 : 0) +
264+
(apiResult.mcpServer ? 1 : 0) +
264265
apiResult.resolvers.filter((r) => r.status === 'success').length +
265266
apiResult.resolverPolicies.filter((r) => r.status === 'success').length;
266267

tests/unit/lib/dependency-graph.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,13 @@ import { ResourceType } from '../../../src/models/resource-types.js';
1515

1616
describe('dependency-graph', () => {
1717
describe('tier constants', () => {
18-
it('should have 33 total resources across all tiers', () => {
18+
it('should have 34 total resources across all tiers', () => {
1919
const total =
2020
TIER_1_RESOURCES.length +
2121
TIER_2_RESOURCES.length +
2222
TIER_3_RESOURCES.length +
2323
TIER_4_RESOURCES.length;
24-
expect(total).toBe(33);
24+
expect(total).toBe(34);
2525
});
2626

2727
it('should not have duplicate resources across tiers', () => {
@@ -62,9 +62,9 @@ describe('dependency-graph', () => {
6262
});
6363

6464
describe('getTopologicalOrder', () => {
65-
it('should return all 33 resource types', () => {
65+
it('should return all 34 resource types', () => {
6666
const order = getTopologicalOrder();
67-
expect(order).toHaveLength(33);
67+
expect(order).toHaveLength(34);
6868
});
6969

7070
it('should return tier-1 resources before tier-2', () => {

tests/unit/models/resource-types.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ import { describe, it, expect } from 'vitest';
22
import { ResourceType, RESOURCE_TYPE_METADATA } from '../../../src/models/resource-types.js';
33

44
describe('ResourceType enum', () => {
5-
it('should have exactly 33 resource types', () => {
5+
it('should have exactly 34 resource types', () => {
66
const values = Object.values(ResourceType);
7-
expect(values).toHaveLength(33);
7+
expect(values).toHaveLength(34);
88
});
99

1010
it('should have unique enum values', () => {
@@ -15,10 +15,10 @@ describe('ResourceType enum', () => {
1515
});
1616

1717
describe('RESOURCE_TYPE_METADATA', () => {
18-
it('should have metadata for all 33 resource types', () => {
18+
it('should have metadata for all 34 resource types', () => {
1919
const metadataKeys = Object.keys(RESOURCE_TYPE_METADATA);
2020
const enumValues = Object.values(ResourceType);
21-
expect(metadataKeys).toHaveLength(33);
21+
expect(metadataKeys).toHaveLength(34);
2222
for (const val of enumValues) {
2323
expect(RESOURCE_TYPE_METADATA[val]).toBeDefined();
2424
}

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

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -747,6 +747,128 @@ describe('api-extractor', () => {
747747
// Revision filtered out — writeResource should not be called for it
748748
expect(result.revisions).toHaveLength(0);
749749
});
750+
751+
it('should skip specification export for MCP APIs without calling the client', async () => {
752+
const getApiSpecification = vi.fn();
753+
const client = createMockClient({
754+
getApiSpecification,
755+
getResource: vi.fn().mockResolvedValue(undefined),
756+
});
757+
const store = createMockStore();
758+
759+
const result = await extractApiResources(
760+
client, store, testContext,
761+
{ type: ResourceType.Api, nameParts: ['mcp-api'] },
762+
{ name: 'mcp-api', properties: { type: 'mcp' } },
763+
'/output'
764+
);
765+
766+
expect(result.specification).toBe(false);
767+
expect(getApiSpecification).not.toHaveBeenCalled();
768+
expect(store.writeContent).not.toHaveBeenCalledWith(
769+
expect.anything(), expect.anything(), expect.anything(), 'specification', expect.anything()
770+
);
771+
});
772+
773+
it('should skip specification export for MCP APIs regardless of type casing', async () => {
774+
const getApiSpecification = vi.fn();
775+
const client = createMockClient({
776+
getApiSpecification,
777+
getResource: vi.fn().mockResolvedValue(undefined),
778+
});
779+
const store = createMockStore();
780+
781+
const result = await extractApiResources(
782+
client, store, testContext,
783+
{ type: ResourceType.Api, nameParts: ['mcp-api'] },
784+
{ name: 'mcp-api', properties: { type: 'MCP' } },
785+
'/output'
786+
);
787+
788+
expect(result.specification).toBe(false);
789+
expect(getApiSpecification).not.toHaveBeenCalled();
790+
});
791+
792+
it('should extract MCP server configuration when present', async () => {
793+
const mcpJson = {
794+
name: 'default',
795+
properties: {
796+
modelContextProtocol: { enabled: true, endpoint: 'https://apim.azure-api.net/my-api/mcp' },
797+
},
798+
};
799+
const client = createMockClient({
800+
getApiSpecification: vi.fn().mockResolvedValue(undefined),
801+
getResource: vi.fn().mockImplementation(async (_ctx: unknown, desc: ResourceDescriptor) => {
802+
if (desc.type === ResourceType.McpServer) {
803+
return mcpJson;
804+
}
805+
return undefined;
806+
}),
807+
});
808+
const store = createMockStore();
809+
const apiDescriptor: ResourceDescriptor = {
810+
type: ResourceType.Api,
811+
nameParts: ['my-api'],
812+
};
813+
814+
const result = await extractApiResources(
815+
client, store, testContext, apiDescriptor,
816+
{ name: 'my-api' },
817+
'/output'
818+
);
819+
820+
expect(result.mcpServer).toBe(true);
821+
expect(store.writeResource).toHaveBeenCalledWith(
822+
'/output',
823+
expect.objectContaining({ type: ResourceType.McpServer, nameParts: ['my-api'] }),
824+
mcpJson
825+
);
826+
});
827+
828+
it('should return mcpServer=false and not throw when MCP server getResource returns undefined', async () => {
829+
const client = createMockClient({
830+
getApiSpecification: vi.fn().mockResolvedValue(undefined),
831+
getResource: vi.fn().mockResolvedValue(undefined),
832+
});
833+
const store = createMockStore();
834+
const apiDescriptor: ResourceDescriptor = {
835+
type: ResourceType.Api,
836+
nameParts: ['my-api'],
837+
};
838+
839+
const result = await extractApiResources(
840+
client, store, testContext, apiDescriptor,
841+
{ name: 'my-api' },
842+
'/output'
843+
);
844+
845+
expect(result.mcpServer).toBe(false);
846+
});
847+
848+
it('should return mcpServer=false and not throw when getResource throws for McpServer', async () => {
849+
const client = createMockClient({
850+
getApiSpecification: vi.fn().mockResolvedValue(undefined),
851+
getResource: vi.fn().mockImplementation(async (_ctx: unknown, desc: ResourceDescriptor) => {
852+
if (desc.type === ResourceType.McpServer) {
853+
throw new Error('404 Not Found');
854+
}
855+
return undefined;
856+
}),
857+
});
858+
const store = createMockStore();
859+
const apiDescriptor: ResourceDescriptor = {
860+
type: ResourceType.Api,
861+
nameParts: ['my-api'],
862+
};
863+
864+
const result = await extractApiResources(
865+
client, store, testContext, apiDescriptor,
866+
{ name: 'my-api' },
867+
'/output'
868+
);
869+
870+
expect(result.mcpServer).toBe(false);
871+
});
750872
});
751873
});
752874

0 commit comments

Comments
 (0)