Skip to content

Enum constraints from MCP tool schemas are dropped #38

Open

Description

@dimabullet

Summary

Enum constraints on MCP tool parameters never reach the model. getSchemeParameter() resolves a property by matching on type alone, so a standard JSON Schema enum is turned into a plain StringSchema and the allowed values are dropped.

src/Relay.php

$type = data_get($property, 'type');

return match ($type) {
    'string' => new StringSchema($name, $description),   // enum is dropped here
    'enum'   => new EnumSchema($name, $description, data_get($property, 'options', [])),
    ...
};

MCP servers emit standard JSON Schema, where an enum is a string carrying an enum keyword:

{ "type": "string", "enum": ["open", "closed"] }

The 'enum' branch expects {"type": "enum", "options": [...]}, which is not JSON Schema and which no MCP server can produce, so that branch is effectively unreachable. Enums nested inside array items are lost the same way.

Worth noting Prism's own EnumSchema::toArray() serialises to {"description": ..., "enum": [...], "type": "string"}, so Relay cannot even read back what Prism itself writes.

Steps to reproduce

Expose an MCP tool with an enum parameter, for example with laravel/mcp:

public function schema(JsonSchema $schema): array
{
    return [
        'status' => $schema->string()
            ->enum(['open', 'closed'])
            ->description('The status to filter on'),
    ];
}

Then resolve it:

$tool = Relay::tools('my-server')[0];

$tool->parameters()['status'];
// Prism\Prism\Schema\StringSchema  <- expected EnumSchema, options are gone

Impact

The model receives a free text parameter with no allowed values, so it invents them. In our case a directory search tool was called with categories that do not exist, the filters were silently ignored, and the answer was built from an unfiltered result set.

The only workaround is to repeat every allowed value in the parameter description, which Relay does preserve. That means each MCP server has to duplicate its own enums into prose and pay the extra tokens on every request.

Possible fix

Resolve the enum keyword before the type based matching. Happy to send a PR, it is a small change.

Activity

  1. ftrudeau-pelcro commented on Aug 22, 2026

    @ftrudeau-pelcro

    Confirming this on relay v1.8.0, and it bites in production.

    Our MCP server (TypeScript SDK, zod 4) emits exactly the shape described. z.enum(["id","-id","email","-email"]) goes over the wire as:

    {"description":"Sort by id or email only; prefix with - for descending.","type":"string","enum":["id","-id","email","-email"]}

    getSchemeParameter() matches type: "string" first, so the tool definition the model receives has sort as a bare string with no allowed values. Symptom, from a real agent: asked for "the last 20 customers", Claude Haiku sends sort: "-created_at" on a cold conversation essentially every time. The server rejects it with the enum list, and unless the model retries, the turn is lost. The same MCP server behaves correctly in Claude Code and Claude Desktop, which read the raw input schema — so the failure is specific to the Relay conversion.

    Two things worth adding:

    • Enums nested in array items are lost by the same line: the recursive call re-enters the same match.
    • The conversion drops more than enums. type: "integer" is emitted as number, and minimum / maximum / multipleOf / pattern / format / minItems never survive, even though NumberSchema and StringSchema accept them — the match arms construct every schema with (name, description) only. So a limit documented as 1..100 reaches the model as an unbounded number. Happy to open that separately if you'd prefer it tracked on its own.

    We have patched locally by overriding getSchemeParameter() in a Relay subclass, which is effectively #39. Would be good to see that merged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions