Repository navigation
fix: let clients call tools whose required array items declare required - #577
Conversation
051753e to
2f128ef
Compare
Some MCP clients cannot build the arguments for an array that is required at the top level when its items schema declares required of its own. They never send tools/call and keep asking the user for the item keys instead. Declare those item fields optional in the input schema of the four affected tools and move the guarantee that they are required to a shared runtime check, which reports every missing field before the kintone API is called.
2f128ef to
74a196b
Compare
nameless-mc
left a comment
There was a problem hiding this comment.
Could we express this with a zod pipe, instead of making the fields optional and checking them in the callback?
// Some clients never send tools/call when a required array's items declare
// `required` (mondaycom/mcp#499). The input side of the pipe is what gets
// advertised, and the output side is what validates.
export const withoutAdvertisedRequired = <T extends z.ZodObject>(schema: T) =>
schema.partial().pipe(schema);const updateRecordSchema = z.object({
id: z.string().describe("Required. Record ID to update (numeric value as string)"),
record: recordSchemaForParameter.describe("Required. ..."),
revision: z.string().optional().describe("..."),
});
const inputSchema = {
app: ...,
records: z
.array(withoutAdvertisedRequired(updateRecordSchema))
.min(1)
.max(100)
.describe("..."),
};buildToolDefinition calls z.toJSONSchema with io: "input", which renders the input side of a pipe, so items has no required. tools/call is validated against the whole pipe, so a missing field is still rejected by the SDK before the callback runs. I checked both with zod 4.4.3. For records: [{ record: {} }, { id: "2" }] the error lists every missing field with its path:
[
{ "code": "invalid_type", "path": ["records", 0, "id"], "message": "Invalid input: expected string, received undefined" },
{ "code": "invalid_type", "path": ["records", 1, "record"], "message": "Invalid input: expected object, received undefined" }
]The output type of the pipe is the original object, so id is string in the callback and revision stays optional (checked with tsc).
| // "kintone-search: query" is a tuple with a rest element, so it also | ||
| // advertises prefixItems. Whether that shape trips clients is unconfirmed. | ||
| const known = ["kintone-search: query"]; |
There was a problem hiding this comment.
Could we check kintone-search's query against Copilot Studio before merging, instead of leaving it as a known exception?
query is a required array of objects whose items declare required, which is the shape this PR fixes. It is advertised as:
{
"type": "array",
"prefixItems": [
{ "type": "object", "properties": { "operator": { ... }, "keywords": { ... } }, "required": ["operator", "keywords"] }
],
"items": { "type": "object", "properties": { "operator": { ... }, "keywords": { ... } }, "required": ["operator", "keywords"] },
"minItems": 1
}It differs from the four tools only in having prefixItems as well, so it looks at least as likely to hit the same problem. If it does, kintone-search can't be called from Copilot Studio at all, since query is required.
known records it as unconfirmed. That keeps this test passing whichever way it turns out, so nothing would tell us later that it needs fixing. Whichever result we get, it would be good to settle it in this PR:
- If it fails: drop
requiredfrom bothprefixItems[0]anditems(e.g.z.tuple([withoutAdvertisedRequired(searchQuerySchema)]).rest(withoutAdvertisedRequired(searchQuerySchema))if the pipe suggestion is taken), and remove the entry fromknown. The test only looks atitems, so it may need to look atprefixItemstoo. - If it works: keep the entry, and replace "unconfirmed" in the comment above with what was checked.
There was a problem hiding this comment.
Checked with Copilot Studio, and it does hit the same problem. With query as it is on main, the agent kept asking for the operator and never sent tools/call.
To narrow it down, I also tried a version where query is a plain array and the other parameters are left as they are, including the ones advertised with type: ["array", "null"]. Both that version and the tuple with the pipe on its head and rest element sent tools/call. So prefixItems itself is not the problem, and the item-level required is.
query now wraps both elements with the pipe, known is gone, and the test looks at prefixItems as well as items.
|
Thanks, switched to the pipe. I also checked the pipe against Copilot Studio: |
| .optional() | ||
| .describe("The expected revision number"), | ||
| }), | ||
| withoutAdvertisedRequired( |
There was a problem hiding this comment.
Here the whole inline z.object({...}) is wrapped in withoutAdvertisedRequired(...), so the wrapper call opens before the object and closes after it. A reader has to find the closing parenthesis to see that the item schema is piped. update-records.ts, update-statuses.ts and search/input.ts instead keep the item schema as a named const and wrap it where the array uses it, so the wrapping is visible on one line.
Could we do the same here?
const deployAppSchema = z.object({
app: z.string().describe("Required. The ID of the app to deploy (numeric value as string)"),
revision: z.string().optional().describe("The expected revision number"),
});
const inputSchema = {
apps: z
.array(withoutAdvertisedRequired(deployAppSchema))
...| "For ORGANIZATION entity, also include affiliated departments. Ignored for USER and GROUP entities (default: false).", | ||
| ), | ||
| }), | ||
| withoutAdvertisedRequired( |
There was a problem hiding this comment.
Same as the comment on deploy-app.ts. This one nests four levels (array → withoutAdvertisedRequired → object → entity: object), which makes it harder to see which level .refine belongs to. Extracting the item schema (e.g. const spaceMemberSchema = z.object({...})) and using .array(withoutAdvertisedRequired(spaceMemberSchema)) would keep it flat.
Why
Five tools declare an array that is required at the top level whose item schema declares
requiredof its own. Some MCP clients cannot build the arguments for that shape: they never sendtools/calland keep asking the user for the item keys instead, so the call never reaches the kintone API.requiredkintone-update-recordsrecordsid,recordkintone-update-statusesrecordsid,actionkintone-deploy-appappsappkintone-add-space-from-templatemembersentitykintone-searchqueryoperator,keywords(in bothprefixItems[0]anditems)An array that is required at the top level is fine as long as its item schema does not declare
requireddirectly:kintone-add-records.records(z.record),kintone-delete-records.ids(items arestring) andkintone-update-form-layout.layout(nested inanyOf) all work.The same symptom is reported for another MCP server in mondaycom/mcp#499.
What
withoutAdvertisedRequiredinsrc/schema/without-advertised-required.ts, which turns an object schema intoschema.partial().pipe(schema). Tool schemas are advertised withio: "input", which renders the input side of the pipe, so the item schema no longer listsrequired.tools/callis still validated against the whole pipe, so a missing field is rejected by the SDK before the callback runs, and the callback still sees the fields as required.kintone-search, both the tuple head and the rest element are wrapped, since the tuple advertises bothprefixItemsanditems.Required., and make the description of each array name the fields an item must carry, so a client that reads descriptions rather thanrequiredstill learns of them.requireddirectly on theitemsorprefixItemsof an array that is required at the top level.requiredfurther down (nested inproperties,anyOforadditionalProperties) is left alone, becausekintone-add-recordsadvertises it today and is not affected.requiredand typed asarray, so the item-levelrequiredis not dropped by dropping the array itself.A call that omits one of those item fields is rejected exactly as before, and a well-formed call is unaffected.
How to test
pnpm testpasses 696 tests in 35 files.To check the advertised schema by hand, start the server and call
tools/list:inputSchema.properties.records.itemsofkintone-update-recordsno longer hasrequired, whileinputSchema.requiredstill containsrecords.Checklist
pnpm lintandpnpm teston the root directory.