Skip to content

Commit 86e3e61

Browse files
ochafikclaude
andcommitted
refactor: auto-discover union members instead of manual list
Replace manual BASE_TO_UNION_CONFIG (46 items) with auto-discovery that: - Finds interfaces transitively extending Request/Notification/Result - Finds type aliases referencing the base type (e.g., EmptyResult = Result) - Filters by naming convention (*Request, *Notification, *Result) - Excludes abstract bases via small exclusion list (4 items) This eliminates maintenance burden when spec adds new request/result types. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent ff03e42 commit 86e3e61

File tree

2 files changed

+149
-96
lines changed

2 files changed

+149
-96
lines changed

scripts/generate-schemas.ts

Lines changed: 118 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,16 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3939
import { dirname, join } from 'node:path';
4040
import { fileURLToPath } from 'node:url';
4141
import { generate } from 'ts-to-zod';
42-
import { Project, SyntaxKind, Node, CallExpression, PropertyAssignment } from 'ts-morph';
42+
import {
43+
Project,
44+
SyntaxKind,
45+
Node,
46+
CallExpression,
47+
PropertyAssignment,
48+
SourceFile,
49+
InterfaceDeclaration,
50+
TypeAliasDeclaration
51+
} from 'ts-morph';
4352

4453
const __filename = fileURLToPath(import.meta.url);
4554
const __dirname = dirname(__filename);
@@ -240,66 +249,94 @@ function removeIndexSignaturesFromTypes(content: string): string {
240249
}
241250

242251
/**
243-
* Configuration for converting base interfaces to union types.
244-
* Maps base interface name to its union members (the concrete types that extend it).
252+
* Check if an interface transitively extends a base interface.
245253
*/
246-
const BASE_TO_UNION_CONFIG: Record<string, string[]> = {
247-
Request: [
248-
'InitializeRequest',
249-
'PingRequest',
250-
'ListResourcesRequest',
251-
'ListResourceTemplatesRequest',
252-
'ReadResourceRequest',
253-
'SubscribeRequest',
254-
'UnsubscribeRequest',
255-
'ListPromptsRequest',
256-
'GetPromptRequest',
257-
'ListToolsRequest',
258-
'CallToolRequest',
259-
'SetLevelRequest',
260-
'CompleteRequest',
261-
'CreateMessageRequest',
262-
'ListRootsRequest',
263-
'ElicitRequest',
264-
'GetTaskRequest',
265-
'GetTaskPayloadRequest',
266-
'CancelTaskRequest',
267-
'ListTasksRequest'
268-
],
269-
Notification: [
270-
'CancelledNotification',
271-
'InitializedNotification',
272-
'ProgressNotification',
273-
'ResourceListChangedNotification',
274-
'ResourceUpdatedNotification',
275-
'PromptListChangedNotification',
276-
'ToolListChangedNotification',
277-
'LoggingMessageNotification',
278-
'RootsListChangedNotification',
279-
'TaskStatusNotification',
280-
'ElicitationCompleteNotification'
281-
],
282-
Result: [
283-
'EmptyResult',
284-
'InitializeResult',
285-
'CompleteResult',
286-
'GetPromptResult',
287-
'ListPromptsResult',
288-
'ListResourceTemplatesResult',
289-
'ListResourcesResult',
290-
'ReadResourceResult',
291-
'CallToolResult',
292-
'ListToolsResult',
293-
'CreateTaskResult',
294-
'GetTaskResult',
295-
'GetTaskPayloadResult',
296-
'ListTasksResult',
297-
'CancelTaskResult',
298-
'CreateMessageResult',
299-
'ListRootsResult',
300-
'ElicitResult'
301-
]
302-
};
254+
function extendsBase(
255+
iface: InterfaceDeclaration,
256+
baseName: string,
257+
sourceFile: SourceFile,
258+
checked: Set<string> = new Set()
259+
): boolean {
260+
const name = iface.getName();
261+
if (checked.has(name)) return false;
262+
checked.add(name);
263+
264+
for (const ext of iface.getExtends()) {
265+
// Handle generic types like "Foo<T>" -> "Foo"
266+
const extName = ext.getText().split('<')[0].trim();
267+
if (extName === baseName) return true;
268+
269+
const parent = sourceFile.getInterface(extName);
270+
if (parent && extendsBase(parent, baseName, sourceFile, checked)) {
271+
return true;
272+
}
273+
}
274+
return false;
275+
}
276+
277+
/**
278+
* Check if a type alias references a base type (e.g., `type EmptyResult = Result`).
279+
*/
280+
function referencesBase(alias: TypeAliasDeclaration, baseName: string): boolean {
281+
const typeText = alias.getTypeNode()?.getText() || '';
282+
// Match patterns like "Result", "Result & Foo", "Foo & Result"
283+
const pattern = new RegExp(`\\b${baseName}\\b`);
284+
return pattern.test(typeText);
285+
}
286+
287+
/**
288+
* Auto-discover union members by finding types that extend/reference a base type.
289+
*
290+
* Finds:
291+
* - Interfaces that transitively extend the base (e.g., ListResourcesRequest → PaginatedRequest → Request)
292+
* - Type aliases that reference the base (e.g., type EmptyResult = Result)
293+
*
294+
* Filters by naming convention (*Request, *Notification, *Result) and excludes abstract bases.
295+
*/
296+
function findUnionMembers(
297+
sourceFile: SourceFile,
298+
baseName: string,
299+
exclusions: Set<string>
300+
): string[] {
301+
const members: string[] = [];
302+
303+
// Find interfaces that extend base (transitively)
304+
for (const iface of sourceFile.getInterfaces()) {
305+
const name = iface.getName();
306+
if (exclusions.has(name)) continue;
307+
if (!name.endsWith(baseName)) continue;
308+
if (extendsBase(iface, baseName, sourceFile)) {
309+
members.push(name);
310+
}
311+
}
312+
313+
// Find type aliases that reference base
314+
for (const alias of sourceFile.getTypeAliases()) {
315+
const name = alias.getName();
316+
if (exclusions.has(name)) continue;
317+
if (!name.endsWith(baseName)) continue;
318+
// Skip union types we're creating (McpRequest, etc.)
319+
if (name.startsWith('Mcp')) continue;
320+
// Skip Client/Server subsets
321+
if (name.startsWith('Client') || name.startsWith('Server')) continue;
322+
if (referencesBase(alias, baseName)) {
323+
members.push(name);
324+
}
325+
}
326+
327+
return members.sort();
328+
}
329+
330+
/**
331+
* Abstract base types that should be excluded from union discovery.
332+
* These are intermediate types in the hierarchy, not concrete MCP messages.
333+
*/
334+
const UNION_EXCLUSIONS = new Set([
335+
'JSONRPCRequest',
336+
'JSONRPCNotification',
337+
'PaginatedRequest',
338+
'PaginatedResult'
339+
]);
303340

304341
/**
305342
* Convert base interfaces to union types in sdk.types.ts.
@@ -313,35 +350,51 @@ const BASE_TO_UNION_CONFIG: Record<string, string[]> = {
313350
* interface InitializeResult extends Result { ... }
314351
* type McpResult = InitializeResult | CompleteResult | ... // Union with Mcp prefix
315352
*
353+
* Union members are auto-discovered by finding types that:
354+
* - Extend the base interface (transitively), or
355+
* - Are type aliases referencing the base type
356+
* - Match the naming convention (*Request, *Notification, *Result)
357+
* - Are not in the exclusion list (abstract bases like PaginatedRequest)
358+
*
316359
* This enables TypeScript union narrowing while preserving backwards compatibility.
317360
* The base type keeps its original name, and the union gets an "Mcp" prefix.
318361
*/
319362
function convertBaseTypesToUnions(content: string): string {
320363
const project = new Project({ useInMemoryFileSystem: true });
321364
const sourceFile = project.createSourceFile('types.ts', content);
322365

323-
console.log(' 🔧 Converting base types to unions...');
366+
console.log(' 🔧 Converting base types to unions (auto-discovering members)...');
324367

325-
for (const [baseName, unionMembers] of Object.entries(BASE_TO_UNION_CONFIG)) {
368+
const baseNames = ['Request', 'Notification', 'Result'];
369+
370+
for (const baseName of baseNames) {
326371
const baseInterface = sourceFile.getInterface(baseName);
327372
if (!baseInterface) {
328373
console.warn(` ⚠️ Interface ${baseName} not found`);
329374
continue;
330375
}
331376

377+
// Auto-discover union members
378+
const unionMembers = findUnionMembers(sourceFile, baseName, UNION_EXCLUSIONS);
379+
380+
if (unionMembers.length === 0) {
381+
console.warn(` ⚠️ No members found for ${baseName}`);
382+
continue;
383+
}
384+
332385
// Base interface keeps its original name (Request, Notification, Result)
333386
// Union type gets Mcp prefix (McpRequest, McpNotification, McpResult)
334387
const unionName = `Mcp${baseName}`;
335388

336389
// Add the union type alias after the base interface
337-
const unionType = unionMembers.join(' | ');
390+
const unionType = unionMembers.join('\n | ');
338391
const insertPos = baseInterface.getEnd();
339392
sourceFile.insertText(
340393
insertPos,
341-
`\n\n/** Union of all MCP ${baseName.toLowerCase()} types for type narrowing. */\nexport type ${unionName} = ${unionType};`
394+
`\n\n/** Union of all MCP ${baseName.toLowerCase()} types for type narrowing. */\nexport type ${unionName} =\n | ${unionType};`
342395
);
343396

344-
console.log(` ✓ Created ${unionName} as union of ${unionMembers.length} types`);
397+
console.log(` ✓ Created ${unionName} with ${unionMembers.length} auto-discovered members`);
345398
}
346399

347400
return sourceFile.getFullText();

src/generated/sdk.types.ts

Lines changed: 31 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -76,26 +76,26 @@ export interface Request {
7676

7777
/** Union of all MCP request types for type narrowing. */
7878
export type McpRequest =
79-
| InitializeRequest
80-
| PingRequest
81-
| ListResourcesRequest
82-
| ListResourceTemplatesRequest
83-
| ReadResourceRequest
84-
| SubscribeRequest
85-
| UnsubscribeRequest
86-
| ListPromptsRequest
87-
| GetPromptRequest
88-
| ListToolsRequest
8979
| CallToolRequest
90-
| SetLevelRequest
80+
| CancelTaskRequest
9181
| CompleteRequest
9282
| CreateMessageRequest
93-
| ListRootsRequest
9483
| ElicitRequest
95-
| GetTaskRequest
84+
| GetPromptRequest
9685
| GetTaskPayloadRequest
97-
| CancelTaskRequest
98-
| ListTasksRequest;
86+
| GetTaskRequest
87+
| InitializeRequest
88+
| ListPromptsRequest
89+
| ListResourceTemplatesRequest
90+
| ListResourcesRequest
91+
| ListRootsRequest
92+
| ListTasksRequest
93+
| ListToolsRequest
94+
| PingRequest
95+
| ReadResourceRequest
96+
| SetLevelRequest
97+
| SubscribeRequest
98+
| UnsubscribeRequest;
9999

100100
/** @internal */
101101
export interface NotificationParams {
@@ -114,16 +114,16 @@ export interface Notification {
114114
/** Union of all MCP notification types for type narrowing. */
115115
export type McpNotification =
116116
| CancelledNotification
117+
| ElicitationCompleteNotification
117118
| InitializedNotification
119+
| LoggingMessageNotification
118120
| ProgressNotification
121+
| PromptListChangedNotification
119122
| ResourceListChangedNotification
120123
| ResourceUpdatedNotification
121-
| PromptListChangedNotification
122-
| ToolListChangedNotification
123-
| LoggingMessageNotification
124124
| RootsListChangedNotification
125125
| TaskStatusNotification
126-
| ElicitationCompleteNotification;
126+
| ToolListChangedNotification;
127127

128128
/**
129129
* @category Common Types
@@ -135,24 +135,24 @@ export interface Result {
135135

136136
/** Union of all MCP result types for type narrowing. */
137137
export type McpResult =
138-
| EmptyResult
139-
| InitializeResult
138+
| CallToolResult
139+
| CancelTaskResult
140140
| CompleteResult
141+
| CreateMessageResult
142+
| CreateTaskResult
143+
| ElicitResult
144+
| EmptyResult
141145
| GetPromptResult
146+
| GetTaskPayloadResult
147+
| GetTaskResult
148+
| InitializeResult
142149
| ListPromptsResult
143150
| ListResourceTemplatesResult
144151
| ListResourcesResult
145-
| ReadResourceResult
146-
| CallToolResult
147-
| ListToolsResult
148-
| CreateTaskResult
149-
| GetTaskResult
150-
| GetTaskPayloadResult
151-
| ListTasksResult
152-
| CancelTaskResult
153-
| CreateMessageResult
154152
| ListRootsResult
155-
| ElicitResult;
153+
| ListTasksResult
154+
| ListToolsResult
155+
| ReadResourceResult;
156156

157157
/**
158158
* @category Common Types

0 commit comments

Comments
 (0)