Skip to content

Commit ea17bae

Browse files
committed
Auto-generate discriminated unions from OpenAPI discriminators
- Add discriminators to OpenAPI spec for transport and Argument types - Enhance schema generation tool to auto-convert discriminators to allOf with if/then blocks - Replace manual discriminated union patterns with auto-generated schema - Ensures schema stays in sync with OpenAPI spec and produces cleaner validation errors
1 parent da2f77c commit ea17bae

File tree

3 files changed

+243
-18
lines changed

3 files changed

+243
-18
lines changed

docs/reference/api/openapi.yaml

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -347,11 +347,16 @@ components:
347347
description: A hint to help clients determine the appropriate runtime for the package. This field should be provided when `runtimeArguments` are present.
348348
examples: [npx, uvx, docker, dnx]
349349
transport:
350-
anyOf:
350+
discriminator:
351+
propertyName: type
352+
mapping:
353+
stdio: '#/components/schemas/StdioTransport'
354+
streamable-http: '#/components/schemas/StreamableHttpTransport'
355+
sse: '#/components/schemas/SseTransport'
356+
oneOf:
351357
- $ref: '#/components/schemas/StdioTransport'
352358
- $ref: '#/components/schemas/StreamableHttpTransport'
353359
- $ref: '#/components/schemas/SseTransport'
354-
description: Transport protocol configuration for the package
355360
runtimeArguments:
356361
type: array
357362
description: A list of arguments to be passed to the package's runtime command (such as docker or npx). The `runtimeHint` field should be provided when `runtimeArguments` are present.
@@ -478,7 +483,12 @@ components:
478483

479484
Argument:
480485
description: "Warning: Arguments construct command-line parameters that may contain user-provided input. This creates potential command injection risks if clients execute commands in a shell environment. For example, a malicious argument value like ';rm -rf ~/Development' could execute dangerous commands. Clients should prefer non-shell execution methods (e.g., posix_spawn) when possible to eliminate injection risks entirely. Where not possible, clients should obtain consent from users or agents to run the resolved command before execution."
481-
anyOf:
486+
discriminator:
487+
propertyName: type
488+
mapping:
489+
positional: '#/components/schemas/PositionalArgument'
490+
named: '#/components/schemas/NamedArgument'
491+
oneOf:
482492
- $ref: '#/components/schemas/PositionalArgument'
483493
- $ref: '#/components/schemas/NamedArgument'
484494

@@ -624,7 +634,12 @@ components:
624634
remotes:
625635
type: array
626636
items:
627-
anyOf:
637+
discriminator:
638+
propertyName: type
639+
mapping:
640+
streamable-http: '#/components/schemas/StreamableHttpTransport'
641+
sse: '#/components/schemas/SseTransport'
642+
oneOf:
628643
- $ref: '#/components/schemas/StreamableHttpTransport'
629644
- $ref: '#/components/schemas/SseTransport'
630645
_meta:

docs/reference/server-json/server.schema.json

Lines changed: 115 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,46 @@
55
"$schema": "http://json-schema.org/draft-07/schema#",
66
"definitions": {
77
"Argument": {
8-
"anyOf": [
8+
"allOf": [
99
{
10-
"$ref": "#/definitions/PositionalArgument"
10+
"if": {
11+
"properties": {
12+
"type": {
13+
"const": "positional"
14+
}
15+
}
16+
},
17+
"then": {
18+
"$ref": "#/definitions/PositionalArgument"
19+
}
1120
},
1221
{
13-
"$ref": "#/definitions/NamedArgument"
22+
"if": {
23+
"properties": {
24+
"type": {
25+
"const": "named"
26+
}
27+
}
28+
},
29+
"then": {
30+
"$ref": "#/definitions/NamedArgument"
31+
}
32+
}
33+
],
34+
"description": "Warning: Arguments construct command-line parameters that may contain user-provided input. This creates potential command injection risks if clients execute commands in a shell environment. For example, a malicious argument value like ';rm -rf ~/Development' could execute dangerous commands. Clients should prefer non-shell execution methods (e.g., posix_spawn) when possible to eliminate injection risks entirely. Where not possible, clients should obtain consent from users or agents to run the resolved command before execution.",
35+
"properties": {
36+
"type": {
37+
"enum": [
38+
"positional",
39+
"named"
40+
],
41+
"type": "string"
1442
}
43+
},
44+
"required": [
45+
"type"
1546
],
16-
"description": "Warning: Arguments construct command-line parameters that may contain user-provided input. This creates potential command injection risks if clients execute commands in a shell environment. For example, a malicious argument value like ';rm -rf ~/Development' could execute dangerous commands. Clients should prefer non-shell execution methods (e.g., posix_spawn) when possible to eliminate injection risks entirely. Where not possible, clients should obtain consent from users or agents to run the resolved command before execution."
47+
"type": "object"
1748
},
1849
"Icon": {
1950
"description": "An optionally-sized icon that can be displayed in a user interface.",
@@ -262,18 +293,58 @@
262293
"type": "string"
263294
},
264295
"transport": {
265-
"anyOf": [
296+
"allOf": [
266297
{
267-
"$ref": "#/definitions/StdioTransport"
298+
"if": {
299+
"properties": {
300+
"type": {
301+
"const": "stdio"
302+
}
303+
}
304+
},
305+
"then": {
306+
"$ref": "#/definitions/StdioTransport"
307+
}
268308
},
269309
{
270-
"$ref": "#/definitions/StreamableHttpTransport"
310+
"if": {
311+
"properties": {
312+
"type": {
313+
"const": "streamable-http"
314+
}
315+
}
316+
},
317+
"then": {
318+
"$ref": "#/definitions/StreamableHttpTransport"
319+
}
271320
},
272321
{
273-
"$ref": "#/definitions/SseTransport"
322+
"if": {
323+
"properties": {
324+
"type": {
325+
"const": "sse"
326+
}
327+
}
328+
},
329+
"then": {
330+
"$ref": "#/definitions/SseTransport"
331+
}
274332
}
275333
],
276-
"description": "Transport protocol configuration for the package"
334+
"properties": {
335+
"type": {
336+
"enum": [
337+
"stdio",
338+
"streamable-http",
339+
"sse"
340+
],
341+
"type": "string"
342+
}
343+
},
344+
"required": [
345+
"type"
346+
],
347+
"type": "object"
277348
},
278349
"version": {
279350
"description": "Package version. Must be a specific version. Version ranges are rejected (e.g., '^1.2.3', '~1.2.3', '\u003e=1.2.3', '1.x', '1.*').",
@@ -427,14 +498,45 @@
427498
},
428499
"remotes": {
429500
"items": {
430-
"anyOf": [
501+
"allOf": [
431502
{
432-
"$ref": "#/definitions/StreamableHttpTransport"
503+
"if": {
504+
"properties": {
505+
"type": {
506+
"const": "streamable-http"
507+
}
508+
}
509+
},
510+
"then": {
511+
"$ref": "#/definitions/StreamableHttpTransport"
512+
}
433513
},
434514
{
435-
"$ref": "#/definitions/SseTransport"
515+
"if": {
516+
"properties": {
517+
"type": {
518+
"const": "sse"
519+
}
520+
}
521+
},
522+
"then": {
523+
"$ref": "#/definitions/SseTransport"
524+
}
436525
}
437-
]
526+
],
527+
"properties": {
528+
"type": {
529+
"enum": [
530+
"streamable-http",
531+
"sse"
532+
],
533+
"type": "string"
534+
}
535+
},
536+
"required": [
537+
"type"
538+
],
539+
"type": "object"
438540
},
439541
"type": "array"
440542
},

tools/extract-server-schema/main.go

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,10 @@ func main() {
9999
"definitions": definitions,
100100
}
101101

102-
// Replace all #/components/schemas/ references with #/definitions/
102+
// Convert OpenAPI discriminators to JSON Schema if/then/else patterns first
103+
jsonSchema = convertDiscriminators(jsonSchema).(map[string]interface{})
104+
105+
// Then replace all #/components/schemas/ references with #/definitions/
103106
jsonSchema = replaceComponentRefs(jsonSchema).(map[string]interface{})
104107

105108
// Convert to JSON
@@ -191,3 +194,108 @@ func replaceComponentRefs(obj interface{}) interface{} {
191194
return obj
192195
}
193196
}
197+
198+
// convertDiscriminators converts OpenAPI discriminators to JSON Schema if/then/else patterns
199+
func convertDiscriminators(obj interface{}) interface{} {
200+
switch v := obj.(type) {
201+
case map[string]interface{}:
202+
// Check if this object has a discriminator with oneOf
203+
if discriminator, hasDiscriminator := v["discriminator"].(map[string]interface{}); hasDiscriminator {
204+
if oneOf, hasOneOf := v["oneOf"].([]interface{}); hasOneOf {
205+
// Extract discriminator property name and mapping
206+
propertyName, _ := discriminator["propertyName"].(string)
207+
mapping, _ := discriminator["mapping"].(map[string]interface{})
208+
209+
if propertyName != "" && mapping != nil && len(oneOf) > 0 {
210+
// Get description if present
211+
description, _ := v["description"].(string)
212+
213+
// Build the allOf with if/then blocks for discriminated union
214+
result := buildDiscriminatedUnion(propertyName, mapping, oneOf, description)
215+
216+
// Recursively convert discriminators in the result
217+
return convertDiscriminators(result)
218+
}
219+
}
220+
}
221+
222+
// Recursively convert discriminators in nested objects
223+
result := make(map[string]interface{})
224+
for key, value := range v {
225+
result[key] = convertDiscriminators(value)
226+
}
227+
return result
228+
229+
case []interface{}:
230+
result := make([]interface{}, len(v))
231+
for i, item := range v {
232+
result[i] = convertDiscriminators(item)
233+
}
234+
return result
235+
236+
default:
237+
return obj
238+
}
239+
}
240+
241+
// buildDiscriminatedUnion builds an allOf structure with separate if/then blocks for each discriminator value
242+
func buildDiscriminatedUnion(propertyName string, mapping map[string]interface{}, oneOf []interface{}, description string) map[string]interface{} {
243+
// Build a sorted list of mapping entries by extracting from oneOf order
244+
mappingList := make([]struct{ key, ref string }, 0, len(oneOf))
245+
for _, item := range oneOf {
246+
if refMap, ok := item.(map[string]interface{}); ok {
247+
if ref, ok := refMap["$ref"].(string); ok {
248+
// Find the key in mapping that matches this ref
249+
for key, value := range mapping {
250+
if refValue, ok := value.(string); ok && refValue == ref {
251+
mappingList = append(mappingList, struct{ key, ref string }{key, ref})
252+
break
253+
}
254+
}
255+
}
256+
}
257+
}
258+
259+
// Extract enum values in the same order
260+
enumValues := make([]interface{}, 0, len(mappingList))
261+
for _, item := range mappingList {
262+
enumValues = append(enumValues, item.key)
263+
}
264+
265+
// Build allOf array with separate if/then for each type
266+
allOfItems := make([]interface{}, 0, len(mappingList))
267+
for _, item := range mappingList {
268+
allOfItems = append(allOfItems, map[string]interface{}{
269+
"if": map[string]interface{}{
270+
"properties": map[string]interface{}{
271+
propertyName: map[string]interface{}{
272+
"const": item.key,
273+
},
274+
},
275+
},
276+
"then": map[string]interface{}{
277+
"$ref": item.ref,
278+
},
279+
})
280+
}
281+
282+
// Build result as regular map (will be alphabetically sorted by Go's json.Marshal)
283+
result := map[string]interface{}{
284+
"type": "object",
285+
"properties": map[string]interface{}{
286+
propertyName: map[string]interface{}{
287+
"type": "string",
288+
"enum": enumValues,
289+
},
290+
},
291+
"required": []interface{}{propertyName},
292+
"allOf": allOfItems,
293+
}
294+
295+
// Add description if present
296+
if description != "" {
297+
result["description"] = description
298+
}
299+
300+
return result
301+
}

0 commit comments

Comments
 (0)