Skip to content

[v2] MRTR wipe-cache example reads unvalidated elicitation content and defaults malformed scope to all #2542

Description

@EvanGribar

What happened?

The v2 input_required documentation correctly states that
ctx.mcpReq.inputResponses is client-provided and untrusted, and recommends
using the schema-aware overload of acceptedContent().

However, the sequential wipe-cache example uses the unchecked generic
overload for both confirmation and scope:

const confirmed = acceptedContent<{ confirm: boolean }>(
    ctx.mcpReq.inputResponses,
    'confirm'
);

It later treats that response as proof that the operator confirmed:

if (confirmed?.confirm !== true) {
    return inputRequired({
        inputRequests: {
            confirm: inputRequired.elicit({
                message: 'Really wipe the cache?',
                requestedSchema: {
                    type: 'object',
                    properties: {
                        confirm: {
                            type: 'boolean'
                        }
                    },
                    required: ['confirm']
                }
            })
        }
    });
}

return inputRequired({
    inputRequests: {
        scope: inputRequired.elicit({
            message: 'Which scope?',
            requestedSchema: {
                type: 'object',
                properties: {
                    scope: {
                        type: 'string'
                    }
                },
                required: ['scope']
            }
        })
    },
    requestState: await stateCodec.mint({
        step: 'confirmed'
    })
});

The generic type parameter only affects TypeScript typing. It does not perform
runtime validation of the client-provided value.

For example, a malformed accepted response could contain:

{
  "action": "accept",
  "content": {
    "confirm": "false"
  }
}

The string "false" is truthy in JavaScript. Because the example only checks:

confirmed?.confirm !== true

this particular value does not equal the boolean true, so the current
!== true check fails closed.

However, the example still demonstrates reading untrusted client content using
a compile-time generic instead of the runtime-validating overload. A copied or
slightly modified version using a normal truthiness check such as:

if (!confirmed?.confirm) {
    // request confirmation
}

would treat "false" as confirmation.

The same example later reads the requested scope with the unchecked overload:

const scope = acceptedContent<{ scope: string }>(
    ctx.mcpReq.inputResponses,
    'scope'
);

return {
    content: [{
        type: 'text',
        text: `Wiped ${scope?.scope ?? 'all'}`
    }]
};

If the scope response is missing, malformed, or of an unexpected type, the
example falls back to "all". That feels unsafe for a destructive example and
conflicts with the page's earlier guidance to validate inputResponses using
the schema-aware overload.

This is primarily a documentation and defensive API-usage issue, not a claim
that a malicious MCP client gains a capability it did not already possess.

What did you expect?

I expected the destructive wipe-cache example to follow the page's own
guidance and validate all client-provided elicitation content at runtime.

For example:

const confirmationSchema = z.object({
    confirm: z.boolean()
});

const scopeSchema = z.object({
    scope: z.string()
});

const confirmed = acceptedContent(
    ctx.mcpReq.inputResponses,
    'confirm',
    confirmationSchema
);

if (confirmed?.confirm !== true) {
    return inputRequired({
        inputRequests: {
            confirm: inputRequired.elicit({
                message: 'Really wipe the cache?',
                requestedSchema: confirmationSchema
            })
        }
    });
}

const scope = acceptedContent(
    ctx.mcpReq.inputResponses,
    'scope',
    scopeSchema
);

if (scope === undefined) {
    return inputRequired({
        inputRequests: {
            scope: inputRequired.elicit({
                message: 'Which scope?',
                requestedSchema: scopeSchema
            })
        }
    });
}

return {
    content: [{
        type: 'text',
        text: `Wiped ${scope.scope}`
    }]
};

The example should also fail closed when scope content is missing or invalid,
rather than defaulting to "all".

Code to reproduce

Documentation page:

`docs/servers/input-required.md`

Relevant section:

`Carry state across rounds with requestState`

Relevant example:


const confirmed = acceptedContent<{ confirm: boolean }>(
    ctx.mcpReq.inputResponses,
    'confirm'
);

const scope = acceptedContent<{ scope: string }>(
    ctx.mcpReq.inputResponses,
    'scope'
);

return {
    content: [{
        type: 'text',
        text: `Wiped ${scope?.scope ?? 'all'}`
    }]
};


Minimal demonstration that the generic type does not validate at runtime:


const clientContent: unknown = {
    confirm: 'false'
};

const confirmed =
    clientContent as {
        confirm: boolean;
    };

console.log(typeof confirmed.confirm);
// "string"

console.log(Boolean(confirmed.confirm));
// true


The cast changes the TypeScript type but does not transform or validate the
runtime value.

SDK version

main at commit 1e1392e

Area

Documentation

Metadata

Metadata

Assignees

No one assigned

    Labels

    v2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions