Skip to content

Commit d0bff0f

Browse files
authored
Merge pull request #36 from Azure/copilot/add-support-for-mcp-servers
feat: add MCP server support to apiops extract and publish
2 parents 230fbe1 + f71c121 commit d0bff0f

15 files changed

Lines changed: 458 additions & 14 deletions

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ Desktop.ini
3636
.idea/
3737

3838
# Local testing output (use --output .local-extract for local runs)
39-
.local-extract/
39+
.local-extract*/
4040

4141
# Environment variables
4242
.env

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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/lib/resource-path.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,16 @@ export function isChildType(type: ResourceType): boolean {
413413
return firstPlaceholderIdx >= 0 && firstPlaceholderIdx < parts.length - 1;
414414
}
415415

416+
/**
417+
* Check if a resource type is a top-level singleton.
418+
*
419+
* Top-level singletons are singleton resources that are not children of
420+
* another resource type (for example ServicePolicy).
421+
*/
422+
export function isTopLevelSingleton(type: ResourceType): boolean {
423+
return isSingletonType(type) && !isChildType(type);
424+
}
425+
416426
/**
417427
* Compute the publish tier for a resource type based on ARM path structure.
418428
* Resources are published from lowest tier to highest; same tier runs in parallel.

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: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,73 @@ export interface ApiExtractionResult {
3131
releases: ExtractedResource[];
3232
tagDescriptions: ExtractedResource[];
3333
wiki: boolean;
34+
mcpServer: boolean;
3435
resolvers: ExtractedResource[];
3536
resolverPolicies: ExtractedResource[];
3637
policies: string[];
3738
}
3839

40+
function getApiProperties(apiJson: Record<string, unknown>): Record<string, unknown> | undefined {
41+
return apiJson.properties as Record<string, unknown> | undefined;
42+
}
43+
44+
function hasEmbeddedMcpConfiguration(apiJson: Record<string, unknown>): boolean {
45+
const properties = getApiProperties(apiJson);
46+
if (!properties) {
47+
return false;
48+
}
49+
50+
// Check if mcpTools has actual content (non-empty array), excluding null
51+
const mcpTools = properties.mcpTools as unknown[] | undefined | null;
52+
if (Array.isArray(mcpTools) && mcpTools.length > 0) {
53+
return true;
54+
}
55+
56+
// Check if mcpProperties exists and is not null or undefined
57+
if (properties.mcpProperties != null) {
58+
return true;
59+
}
60+
61+
return false;
62+
}
63+
64+
function buildEmbeddedMcpServerResource(apiJson: Record<string, unknown>): Record<string, unknown> {
65+
const properties = getApiProperties(apiJson) ?? {};
66+
const resourceProperties: Record<string, unknown> = {};
67+
68+
if (properties.mcpProperties !== undefined) {
69+
resourceProperties.mcpProperties = properties.mcpProperties;
70+
}
71+
if (properties.mcpTools !== undefined) {
72+
resourceProperties.mcpTools = properties.mcpTools;
73+
}
74+
75+
return {
76+
name: 'default',
77+
properties: resourceProperties,
78+
};
79+
80+
}
81+
82+
function hasMeaningfulMcpContent(mcpJson: Record<string, unknown>): boolean {
83+
const properties = mcpJson.properties as Record<string, unknown> | undefined;
84+
if (!properties) {
85+
return false;
86+
}
87+
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+
}
93+
94+
// Check if mcpProperties exists and is not null or undefined
95+
if (properties.mcpProperties != null) {
96+
return true;
97+
}
98+
99+
return false;
100+
}
39101
/**
40102
* Extract all API-specific resources for a single API.
41103
* This includes revisions, specifications, operations, policies, etc.
@@ -63,6 +125,7 @@ export async function extractApiResources(
63125
releases: [],
64126
tagDescriptions: [],
65127
wiki: false,
128+
mcpServer: false,
66129
resolvers: [],
67130
resolverPolicies: [],
68131
policies: [],
@@ -138,6 +201,11 @@ export async function extractApiResources(
138201
client, store, context, apiDescriptor, outputDir
139202
);
140203

204+
// Extract MCP server configuration (singleton per API; silently skipped when not present)
205+
result.mcpServer = await extractApiMcpServer(
206+
client, store, context, apiDescriptor, apiJson, outputDir
207+
);
208+
141209
// Extract GraphQL resolvers and their policies
142210
const resolverResult = await extractGraphQLResolvers(
143211
client, store, context, apiDescriptor, apiJson, outputDir, filter, workspace
@@ -237,6 +305,11 @@ async function extractApiSpecification(
237305
return false;
238306
}
239307

308+
if (apiType?.toLowerCase() === 'mcp') {
309+
logger.debug(`Skipping spec export for MCP API "${getNamePart(apiDescriptor.nameParts, 0)}" — MCP APIs use the Model Context Protocol endpoint, not OpenAPI`);
310+
return false;
311+
}
312+
240313
if (apiType?.toLowerCase() === 'graphql' && hasGraphQLSchema(extractedSchemas)) {
241314
logger.debug(
242315
`Skipping spec export for synthetic GraphQL API "${getNamePart(apiDescriptor.nameParts, 0)}" — schema is captured via ApiSchema`
@@ -449,7 +522,7 @@ async function extractGraphQLResolvers(
449522
const policies: string[] = [];
450523

451524
// Only extract resolvers for GraphQL APIs — use the already-fetched apiJson
452-
const properties = apiJson.properties as Record<string, unknown> | undefined;
525+
const properties = getApiProperties(apiJson);
453526
const apiType = properties?.type as string | undefined;
454527
if (apiType?.toLowerCase() !== 'graphql') {
455528
return { resolvers, resolverPolicies, policies };
@@ -492,3 +565,51 @@ async function extractGraphQLResolvers(
492565

493566
return { resolvers, resolverPolicies, policies };
494567
}
568+
569+
/**
570+
* 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.
573+
*/
574+
async function extractApiMcpServer(
575+
client: IApimClient,
576+
store: IArtifactStore,
577+
context: ApimServiceContext,
578+
apiDescriptor: ResourceDescriptor,
579+
apiJson: Record<string, unknown>,
580+
outputDir: string
581+
): Promise<boolean> {
582+
const apiType = (getApiProperties(apiJson)?.type as string | undefined)?.toLowerCase();
583+
584+
// Avoid creating MCP artifacts for non-MCP APIs unless they carry meaningful MCP metadata.
585+
if (apiType !== 'mcp' && !hasEmbeddedMcpConfiguration(apiJson)) {
586+
return false;
587+
}
588+
589+
const mcpDescriptor: ResourceDescriptor = {
590+
type: ResourceType.McpServer,
591+
nameParts: [...apiDescriptor.nameParts],
592+
workspace: apiDescriptor.workspace,
593+
};
594+
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+
}
615+
}

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

src/services/publish-service.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { logger } from '../lib/logger.js';
1515
import { isAutoGeneratedId } from '../lib/auto-generated.js';
1616
import { EXIT_SUCCESS, EXIT_PARTIAL, EXIT_FATAL } from '../lib/exit-codes.js';
1717
import { buildResourceLabel } from '../lib/resource-uri.js';
18-
import { getNamePart, isChildType } from '../lib/resource-path.js';
18+
import { getNamePart, isChildType, isTopLevelSingleton } from '../lib/resource-path.js';
1919

2020
// Import from other agents' files (will be created in parallel)
2121
import { publishResource, ResourcePublishResult } from './resource-publisher.js';
@@ -188,6 +188,12 @@ async function executePuts(
188188
// so we exclude those children from tiers 3/4 to avoid double-publishing.
189189
const parentNamesByType = new Map<ResourceType, Set<string>>();
190190
for (const d of tierGroups.get(2) || []) {
191+
// Top-level singletons such as ServicePolicy do not participate in
192+
// parent-name child filtering.
193+
if (isTopLevelSingleton(d.type)) {
194+
continue;
195+
}
196+
191197
// Top-level types are those that are NOT child types
192198
if (!isChildType(d.type)) {
193199
if (!parentNamesByType.has(d.type)) {

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', () => {

0 commit comments

Comments
 (0)