Skip to content

fix(mcp-server): Handle oneOf documents as operation input/output roots - #1345

Merged
adwsingh merged 2 commits into
smithy-lang:mainfrom
psp65:mcp-oneof-root-schema
Sep 15, 2026
Merged

adwsingh merged 2 commits into
smithy-lang:mainfrom
psp65:mcp-oneof-root-schema

Conversation

@psp65

@psp65 psp65 commented Sep 1, 2026

Copy link
Copy Markdown

What behavior changes?

Tool schema generation (McpSchemaFactory, formerly McpService) no longer fails (or silently degrades) when a shape carrying the smithy.mcp#oneOf trait is used directly as an operation input or output.

With a model like:

@oneOf(discriminator: "__type", members: [...])
document ShapeWithOneOf

operation GetShape {
    output: ShapeWithOneOf
}

operation ProcessShape {
    input: ProcessShapeInput // contains a member targeting ShapeWithOneOf
}

building the MCP service previously had two order-dependent failures, because the per-service schema cache is shared across schema kinds:

  • If a nested reference (ProcessShape) was processed first, the cache held a JsonOneOfSchema for the shape, and createObjectSchema for the GetShape output root then threw ClassCastException: JsonOneOfSchema cannot be cast to JsonObjectSchema — failing construction of the entire service (every tool), not just the offending operation.
  • If the root reference was processed first, createObjectSchema treated the document as a plain (member-less) shape and cached an empty JsonObjectSchema under the shape id, which nested references then reused — silently dropping all oneOf variants.

After this change both positions render correctly: the root position produces {"type": "object", "oneOf": [...]} (the MCP spec requires tool input/output schemas to be object-typed; the oneOf variants are all objects, so the constraints compose), and nested references keep the cached JsonOneOfSchema exactly as before.

Why is this change needed?

Bundled models (loaded through ModelBundles, which assembles with validation disabled) can and do contain document-typed operation outputs: tooling that converts service models for MCP represents polymorphic type hierarchies as @oneOf documents, and a service with a polymorphic operation output currently cannot list tools at all. Observed in production as a deterministic ClassCastException at McpSchemaFactory.createObjectSchema (formerly McpService.createJsonObjectSchema) for every such service.

How was this validated?

  • Two new regression tests in StdioMcpServerTest. The schema cache is per operation and an operation's input schema is built before its output, so each test uses a single operation (in its own service) to pin one ordering:
    • testOneOfDocumentAsOperationOutputRootWithCachedSchemaGetShape: nested reference in the input caches the JsonOneOfSchema, then the output requests the same shape as its root (the ClassCastException order).
    • testOneOfDocumentAsOperationInputRootBeforeNestedReferencePutShape: the document is the input root, then the output references it as a nested member (the order that silently dropped the variants).
    • The test model is assembled with disableValidation() to mirror ModelBundles.
  • Verified both tests fail on main without the fix (with the ClassCastException and the missing variants respectively), and that each fails only for its own mechanism when that mechanism is mutated out (blind cast restored / document guard removed).
  • ./gradlew :mcp:mcp-server:test passes.

What should reviewers focus on?

  • McpSchemaFactory#asJsonObjectSchema: the re-shaping of a JsonOneOfSchema into an object-typed schema, and the new @oneOf guard in createObjectSchema that routes trait-carrying shapes through createOneOfSchema so the cache keeps the full oneOf schema for other references.
  • mcp-schemas/model/main.smithy: JsonObjectSchema gains an optional oneOf member so the object-typed root can carry the variants. The converted root preserves the type: "object", oneOf, and description a JsonOneOfSchema would serialize, plus the $schema annotation that all object-typed roots already carry.
  • The new guard in createObjectSchema is scoped to ShapeType.DOCUMENT (the trait's selector), so a non-document shape incorrectly carrying the trait in an unvalidated model keeps its existing rendering — matching what runtime input/output adaptation recognizes.

Scope note

This change fixes schema generation (tools/list). Invoking a tool whose input root is a @oneOf document is a separate, pre-existing limitation: SchemaGuidedDocumentBuilder rejects document roots during input adaptation. That behavior is unchanged here.

Additional Links

None.

A document carrying the smithy.mcp#oneOf trait (a discriminated
polymorphic type) can appear as an operation's input or output in
bundled models, which are loaded with validation disabled. The
per-operation schema cache is shared across schema kinds and the input
schema is built before the output, so this failed in one of two
order-dependent ways:

- If a nested reference was rendered first, the cache held a
  JsonOneOfSchema and createObjectSchema for the root then threw
  ClassCastException, failing tool listing for the whole service.
- If the root was rendered first, the document produced an empty
  object schema that was cached under the shape id, silently dropping
  the oneOf variants from every nested reference.

Route oneOf documents in object positions through createOneOfSchema
(preserving the cached oneOf schema for other references) and re-shape
the result into an object-typed schema: JsonObjectSchema gains an
optional oneOf member, producing {"type": "object", "oneOf": [...]} as
the MCP spec requires for tool schemas. The guard is scoped to
ShapeType.DOCUMENT (the trait's selector), so any other shape kind
carrying the trait keeps its regular rendering, matching what runtime
input/output adaptation recognizes.

Two regression tests pin one ordering each within a single operation
(nested reference then document root, and document root then nested
reference); both fail on main and each fails only for its own
mechanism when that mechanism is mutated out.
@psp65
psp65 force-pushed the mcp-oneof-root-schema branch from d4e248f to dda9ec9 Compare September 14, 2026 17:46

@adwsingh adwsingh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@psp65 Please see my latest commit which should fix my comment.

@adwsingh
adwsingh force-pushed the mcp-oneof-root-schema branch from 7206c85 to a1c7a15 Compare September 14, 2026 23:13
@adwsingh
adwsingh enabled auto-merge (rebase) September 15, 2026 01:05
@adwsingh
adwsingh merged commit dc955f1 into smithy-lang:main Sep 15, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants