Skip to content

Commit 0929beb

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 0929beb

File tree

3 files changed

+246
-32
lines changed

3 files changed

+246
-32
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: 116 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,50 @@
11
{
22
"$comment": "This file is auto-generated from docs/reference/api/openapi.yaml. Do not edit manually. Run 'make generate-schema' to update.",
3-
"$id": "https://static.modelcontextprotocol.io/schemas/2025-10-11/server.schema.json",
3+
"$id": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
44
"$ref": "#/definitions/ServerDetail",
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: 111 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,16 +33,6 @@ func main() {
3333
log.Fatalf("Failed to parse OpenAPI YAML: %v", err)
3434
}
3535

36-
// Extract version from info section
37-
info, ok := openapi["info"].(map[string]interface{})
38-
if !ok {
39-
log.Fatal("Missing 'info' in OpenAPI spec")
40-
}
41-
version, ok := info["version"].(string)
42-
if !ok {
43-
log.Fatal("Missing 'info.version' in OpenAPI spec")
44-
}
45-
4636
// Extract components/schemas
4737
components, ok := openapi["components"].(map[string]interface{})
4838
if !ok {
@@ -88,18 +78,20 @@ func main() {
8878
}
8979
}
9080

91-
// Build the JSON Schema document with dynamic version from OpenAPI spec
92-
schemaID := fmt.Sprintf("https://static.modelcontextprotocol.io/schemas/%s/server.schema.json", version)
81+
// Build the JSON Schema document
9382
jsonSchema := map[string]interface{}{
9483
"$comment": "This file is auto-generated from docs/reference/api/openapi.yaml. Do not edit manually. Run 'make generate-schema' to update.",
9584
"$schema": "http://json-schema.org/draft-07/schema#",
96-
"$id": schemaID,
85+
"$id": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
9786
"title": "server.json defining a Model Context Protocol (MCP) server",
9887
"$ref": "#/definitions/ServerDetail",
9988
"definitions": definitions,
10089
}
10190

102-
// Replace all #/components/schemas/ references with #/definitions/
91+
// Convert OpenAPI discriminators to JSON Schema if/then/else patterns first
92+
jsonSchema = convertDiscriminators(jsonSchema).(map[string]interface{})
93+
94+
// Then replace all #/components/schemas/ references with #/definitions/
10395
jsonSchema = replaceComponentRefs(jsonSchema).(map[string]interface{})
10496

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

0 commit comments

Comments
 (0)