Skip to content

feat(mcp): add output Zod schemas and structuredContent to all tools#132

Open
dsklyar wants to merge 5 commits into
mainfrom
mcp/output-zod-schemas
Open

feat(mcp): add output Zod schemas and structuredContent to all tools#132
dsklyar wants to merge 5 commits into
mainfrom
mcp/output-zod-schemas

Conversation

@dsklyar

@dsklyar dsklyar commented May 13, 2026

Copy link
Copy Markdown
Contributor

Related Issues

Summary

  • Adds a required outputZodSchema to every MCP tool (72 tools across 8 server packages) and surfaces the validated handler return as structuredContent on every CallToolResult. tools/list now exposes outputSchema, so MCP clients (@mastra/mcp, the official SDK, Anthropic/OpenAI tool-callers) get a typed envelope without manually JSON.parse-ing content[0].text.
  • New shared helpers in @transcend-io/mcp-server-base: envelopeSchema, listEnvelopeSchema, paginatedListSchema, successEnvelopeSchema, errorEnvelopeSchema, plus Zod mirrors of every interface in transcend.ts (AssessmentSchema, RequestSchema, DataSiloSchema, CookieSchema, UserPreferencesSchema, …) — each .passthrough() so backend additions don't fail validation during rollout.
  • Validation is non-throwing: a handler that drifts from its declared schema logs a warning to stderr and still surfaces the raw return as structuredContent. Both buildMcpServer (the protocol-facing path) and ToolRegistry.executeTool (the SDK consumer path) are updated symmetrically.

Breaking change

createListResult now nests pagination metadata (count, totalCount, hasNextPage, nextCursor, paginationNote) under data instead of the top level alongside success. This unifies the envelope shape across list, get, and mutation tools. Consumers must migrate from result.totalCount / result.data[0] to result.data.totalCount / result.data.items[0]. Accepted because the server is in beta — see the changeset for the migration note.

Test plan

  • pnpm --filter '@transcend-io/mcp*' typecheck — clean
  • pnpm --filter '@transcend-io/mcp*' test — all 60+ tests pass, including new output-schemas.test.ts and the structuredContent HTTP-transport assertion
  • pnpm exec oxlint packages/mcp — 0 warnings, 0 errors
  • pnpm check:changeset — clean
  • Smoke-test against Prometheus/Mastra: confirm structuredContent flows end-to-end and the unwrapMcpResponse helper can be removed/simplified
  • Verify tools/list JSON Schema renders correctly in Cursor / Claude Desktop tool-pickers

Made with Cursor

Every tool now declares an `outputZodSchema` alongside its input schema.
The derived JSON Schema is exposed as `outputSchema` in `tools/list`, and
every `CallToolResult` populates `structuredContent` with the validated
handler return so consumers (e.g. `@mastra/mcp`) no longer need to
manually `JSON.parse(content[0].text)` and unwrap the envelope.

Adds shared `envelopeSchema`, `listEnvelopeSchema`, `paginatedListSchema`
helpers plus Zod mirrors of every interface in `transcend.ts`. Validation
is non-throwing — a handler that drifts from its declared schema logs a
warning to stderr but still surfaces the raw return.

BREAKING CHANGE: `createListResult` now nests pagination metadata
(`count`, `totalCount`, `hasNextPage`, `nextCursor`, `paginationNote`)
under `data` instead of placing it at the top level alongside `success`,
giving every tool (list, get, mutation) a single envelope shape.
@pkg-pr-new

pkg-pr-new Bot commented May 13, 2026

Copy link
Copy Markdown

Open in StackBlitz

@transcend-io/airgap.js-types

pnpm add https://pkg.pr.new/@transcend-io/airgap.js-types@132
yarn add https://pkg.pr.new/@transcend-io/airgap.js-types@132.tgz

@transcend-io/cli

pnpm add https://pkg.pr.new/@transcend-io/cli@132
yarn add https://pkg.pr.new/@transcend-io/cli@132.tgz

@transcend-io/internationalization

pnpm add https://pkg.pr.new/@transcend-io/internationalization@132
yarn add https://pkg.pr.new/@transcend-io/internationalization@132.tgz

@transcend-io/privacy-types

pnpm add https://pkg.pr.new/@transcend-io/privacy-types@132
yarn add https://pkg.pr.new/@transcend-io/privacy-types@132.tgz

@transcend-io/sdk

pnpm add https://pkg.pr.new/@transcend-io/sdk@132
yarn add https://pkg.pr.new/@transcend-io/sdk@132.tgz

@transcend-io/type-utils

pnpm add https://pkg.pr.new/@transcend-io/type-utils@132
yarn add https://pkg.pr.new/@transcend-io/type-utils@132.tgz

@transcend-io/utils

pnpm add https://pkg.pr.new/@transcend-io/utils@132
yarn add https://pkg.pr.new/@transcend-io/utils@132.tgz

@transcend-io/mcp

pnpm add https://pkg.pr.new/@transcend-io/mcp@132
yarn add https://pkg.pr.new/@transcend-io/mcp@132.tgz

@transcend-io/mcp-server-admin

pnpm add https://pkg.pr.new/@transcend-io/mcp-server-admin@132
yarn add https://pkg.pr.new/@transcend-io/mcp-server-admin@132.tgz

@transcend-io/mcp-server-assessment

pnpm add https://pkg.pr.new/@transcend-io/mcp-server-assessment@132
yarn add https://pkg.pr.new/@transcend-io/mcp-server-assessment@132.tgz

@transcend-io/mcp-server-base

pnpm add https://pkg.pr.new/@transcend-io/mcp-server-base@132
yarn add https://pkg.pr.new/@transcend-io/mcp-server-base@132.tgz

@transcend-io/mcp-server-consent

pnpm add https://pkg.pr.new/@transcend-io/mcp-server-consent@132
yarn add https://pkg.pr.new/@transcend-io/mcp-server-consent@132.tgz

@transcend-io/mcp-server-discovery

pnpm add https://pkg.pr.new/@transcend-io/mcp-server-discovery@132
yarn add https://pkg.pr.new/@transcend-io/mcp-server-discovery@132.tgz

@transcend-io/mcp-server-dsr

pnpm add https://pkg.pr.new/@transcend-io/mcp-server-dsr@132
yarn add https://pkg.pr.new/@transcend-io/mcp-server-dsr@132.tgz

@transcend-io/mcp-server-inventory

pnpm add https://pkg.pr.new/@transcend-io/mcp-server-inventory@132
yarn add https://pkg.pr.new/@transcend-io/mcp-server-inventory@132.tgz

@transcend-io/mcp-server-preferences

pnpm add https://pkg.pr.new/@transcend-io/mcp-server-preferences@132
yarn add https://pkg.pr.new/@transcend-io/mcp-server-preferences@132.tgz

@transcend-io/mcp-server-workflows

pnpm add https://pkg.pr.new/@transcend-io/mcp-server-workflows@132
yarn add https://pkg.pr.new/@transcend-io/mcp-server-workflows@132.tgz

commit: 232bbf5

@dsklyar dsklyar enabled auto-merge May 13, 2026 04:42
@dsklyar dsklyar disabled auto-merge May 13, 2026 04:42

@dawson-turechek-transcend dawson-turechek-transcend left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This all looks good to me! I have a concern about keeping our schemas in sync with what the BE provides. It'd love to be able to detect misconnects before our customers reach out to report errors

Comment thread packages/mcp/mcp/src/registry.ts Outdated
Comment on lines +117 to +122
const issues = outputParse.error.issues
.map((i: any) => `${i.path.join('.') || 'output'}: ${i.message}`)
.join('; ');
process.stderr.write(
`Warning: outputZodSchema validation failed for "${name}": ${issues}\n`,
);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Just curious: Why do we need this in mcp & mcp-server-base?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I think there is a bit of duplication going on between ToolRegistry and buildMcpServer. It seems to me that executeTool is not used publicly but still exists in the TollRegistry e.g. mcp

Comment thread packages/mcp/mcp/src/registry.ts Outdated
const issues = outputParse.error.issues
.map((i: any) => `${i.path.join('.') || 'output'}: ${i.message}`)
.join('; ');
process.stderr.write(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Does this get into datadog for the hosted version? How does the output work for stdio users?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch! Need to use use simple logger which shud handle both http + stdio. SimpleLogger shud handle datadog logging following the pattern for the rest of the repo.

The two new warning paths bypassed `SimpleLogger` with raw
`process.stderr.write`, which would emit unstructured text and break
Datadog's JSON parsing in HTTP transport. Route them through the
existing `logger.warn(...)` instead so the warnings:

- Emit the same structured JSON as the rest of the server's logs
- Honor `SimpleLogger.setInfoToStdout` (HTTP mode) routing config
- Index `toolName` and `issues` as separate Datadog fields

Mirrors the existing pattern used for the duplicate-tool-name warning.
# Conflicts:
#	packages/mcp/mcp-server-dsr/src/tools/dsr_submit_on_behalf.ts
@dsklyar

dsklyar commented May 13, 2026

Copy link
Copy Markdown
Contributor Author

This all looks good to me! I have a concern about keeping our schemas in sync with what the BE provides. It'd love to be able to detect misconnects before our customers reach out to report errors

The concern you raise is valid and I did attempted at logging any mismatches.

@linear-code

linear-code Bot commented May 22, 2026

Copy link
Copy Markdown

ZEL-7635

})
.passthrough();

export const RequestSchema = z

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can't you make zod canonical and do for example: export type Request = z.infer<typeof RequestSchema>... This makes sure that transcend.ts and transcend.schemas.ts don't drift

// It is OK for some tools to reject permissive shapes (e.g. discriminated
// unions on a `found` literal). The point of this test is to ensure no
// tool throws when validating a properly-shaped envelope.
expect(failed.length).toBeLessThanOrEqual(allTools.length);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Do we even need this test? Isn't this always going to be the case?

Comment on lines +100 to +111
// Validate handler return against the declared outputZodSchema. Failures
// are non-fatal during rollout: log a warning but still surface the raw
// handler return as `structuredContent` so consumers don't break on
// schema drift.
const outputParse = tool.outputZodSchema.safeParse(result);
if (!outputParse.success) {
const issues = outputParse.error.issues
.map((i: any) => `${i.path.join('.') || 'output'}: ${i.message}`)
.join('; ');
logger.warn('outputZodSchema validation failed', { toolName: name, issues });
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This could likely be extracted into two helpers that both here and ToolRegistry.executeTool use instead of duplicating logic

giacaglia
giacaglia previously approved these changes May 26, 2026

@giacaglia giacaglia left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looks good. Left a few comments

@michaelfarrell76 michaelfarrell76 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

should we add descriptions to each schema value? not sure if we did for the other schemas

dsklyar added 2 commits June 1, 2026 12:28
…elope

The deep-link tests landed on main in #162 and assumed the old list-tool
envelope shape (`result.data[0].url`). This branch's breaking change to
`createListResult` nests items under `data` (`result.data.items[0].url`),
so the merged tests need to follow suit. Behavior is unchanged.
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.

5 participants