Skip to content

fix: let clients call tools whose required array items declare required - #577

Merged
shabaraba merged 10 commits into
mainfrom
fix/copilot-studio-array-items-required
Oct 16, 2026
Merged

shabaraba merged 10 commits into
mainfrom
fix/copilot-studio-array-items-required

Conversation

@shabaraba

@shabaraba shabaraba commented Oct 7, 2026 •

Copy link
Copy Markdown
Member

Why

Five tools declare an array that is required at the top level whose item schema declares required of its own. Some MCP clients cannot build the arguments for that shape: they never send tools/call and keep asking the user for the item keys instead, so the call never reaches the kintone API.

Tool Parameter fields in the item's required
kintone-update-records records id, record
kintone-update-statuses records id, action
kintone-deploy-app apps app
kintone-add-space-from-template members entity
kintone-search query operator, keywords (in both prefixItems[0] and items)

An array that is required at the top level is fine as long as its item schema does not declare required directly: kintone-add-records.records (z.record), kintone-delete-records.ids (items are string) and kintone-update-form-layout.layout (nested in anyOf) all work.

The same symptom is reported for another MCP server in mondaycom/mcp#499.

What

  • Add withoutAdvertisedRequired in src/schema/without-advertised-required.ts, which turns an object schema into schema.partial().pipe(schema). Tool schemas are advertised with io: "input", which renders the input side of the pipe, so the item schema no longer lists required. tools/call is 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.
  • Wrap the item schemas of the five arrays with it. For kintone-search, both the tuple head and the rest element are wrapped, since the tuple advertises both prefixItems and items.
  • Prefix the descriptions of the affected fields with Required., and make the description of each array name the fields an item must carry, so a client that reads descriptions rather than required still learns of them.
  • Add tests on the advertised JSON Schema:
    • no tool may declare required directly on the items or prefixItems of an array that is required at the top level. required further down (nested in properties, anyOf or additionalProperties) is left alone, because kintone-add-records advertises it today and is not affected.
    • the five arrays stay required and typed as array, so the item-level required is not dropped by dropping the array itself.
    • the five tools still reject an item that is missing a field it requires.

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 lint && pnpm typecheck && pnpm test

pnpm test passes 696 tests in 35 files.

To check the advertised schema by hand, start the server and call tools/list: inputSchema.properties.records.items of kintone-update-records no longer has required, while inputSchema.required still contains records.

Checklist

  • Updated documentation if it is required. (no user-facing documentation describes these schemas)
  • Added tests if it is required.
  • Passed pnpm lint and pnpm test on the root directory.

@shabaraba
shabaraba requested a review from a team as a code owner October 7, 2026 03:26
@shabaraba
shabaraba requested review from chihiro-adachi and nameless-mc and removed request for a team October 7, 2026 03:26
@shabaraba
shabaraba force-pushed the fix/copilot-studio-array-items-required branch from 051753e to 2f128ef Compare October 7, 2026 03:32
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.
@shabaraba
shabaraba force-pushed the fix/copilot-studio-array-items-required branch from 2f128ef to 74a196b Compare October 7, 2026 04:33

@nameless-mc nameless-mc 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.

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).

Comment on lines +133 to +135
// "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"];

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.

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 required from both prefixItems[0] and items (e.g. z.tuple([withoutAdvertisedRequired(searchQuerySchema)]).rest(withoutAdvertisedRequired(searchQuerySchema)) if the pipe suggestion is taken), and remove the entry from known. The test only looks at items, so it may need to look at prefixItems too.
  • If it works: keep the entry, and replace "unconfirmed" in the comment above with what was checked.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

@shabaraba

Copy link
Copy Markdown
Member Author

Thanks, switched to the pipe. src/tools/validation.ts is gone, and the item schemas of the affected arrays are now wrapped with withoutAdvertisedRequired (src/schema/without-advertised-required.ts), as you suggested. Since a missing field is rejected by the SDK again, the tool tests that were changed to expect the callback to reject it are back to how they are on main.

I also checked the pipe against Copilot Studio: kintone-deploy-app with the pipe is called as expected.

nameless-mc
nameless-mc previously approved these changes Oct 14, 2026
Comment thread src/tools/kintone/app/deploy-app.ts Outdated
.optional()
.describe("The expected revision number"),
}),
withoutAdvertisedRequired(

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.

review

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(

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.

review

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.

@shabaraba
shabaraba merged commit 254a3ce into main Oct 16, 2026
15 checks passed
@shabaraba
shabaraba deleted the fix/copilot-studio-array-items-required branch October 16, 2026 00:31
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