feat(mcp): add output Zod schemas and structuredContent to all tools#132
feat(mcp): add output Zod schemas and structuredContent to all tools#132dsklyar wants to merge 5 commits into
Conversation
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.
@transcend-io/airgap.js-types
@transcend-io/cli
@transcend-io/internationalization
@transcend-io/privacy-types
@transcend-io/sdk
@transcend-io/type-utils
@transcend-io/utils
@transcend-io/mcp
@transcend-io/mcp-server-admin
@transcend-io/mcp-server-assessment
@transcend-io/mcp-server-base
@transcend-io/mcp-server-consent
@transcend-io/mcp-server-discovery
@transcend-io/mcp-server-dsr
@transcend-io/mcp-server-inventory
@transcend-io/mcp-server-preferences
@transcend-io/mcp-server-workflows
commit: |
dawson-turechek-transcend
left a comment
There was a problem hiding this comment.
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
| 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`, | ||
| ); |
There was a problem hiding this comment.
Just curious: Why do we need this in mcp & mcp-server-base?
There was a problem hiding this comment.
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
| const issues = outputParse.error.issues | ||
| .map((i: any) => `${i.path.join('.') || 'output'}: ${i.message}`) | ||
| .join('; '); | ||
| process.stderr.write( |
There was a problem hiding this comment.
Does this get into datadog for the hosted version? How does the output work for stdio users?
There was a problem hiding this comment.
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
The concern you raise is valid and I did attempted at logging any mismatches. |
| }) | ||
| .passthrough(); | ||
|
|
||
| export const RequestSchema = z |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
Do we even need this test? Isn't this always going to be the case?
| // 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 }); | ||
| } | ||
|
|
There was a problem hiding this comment.
This could likely be extracted into two helpers that both here and ToolRegistry.executeTool use instead of duplicating logic
giacaglia
left a comment
There was a problem hiding this comment.
Looks good. Left a few comments
michaelfarrell76
left a comment
There was a problem hiding this comment.
should we add descriptions to each schema value? not sure if we did for the other schemas
…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.
Related Issues
Summary
outputZodSchemato every MCP tool (72 tools across 8 server packages) and surfaces the validated handler return asstructuredContenton everyCallToolResult.tools/listnow exposesoutputSchema, so MCP clients (@mastra/mcp, the official SDK, Anthropic/OpenAI tool-callers) get a typed envelope without manuallyJSON.parse-ingcontent[0].text.@transcend-io/mcp-server-base:envelopeSchema,listEnvelopeSchema,paginatedListSchema,successEnvelopeSchema,errorEnvelopeSchema, plus Zod mirrors of every interface intranscend.ts(AssessmentSchema,RequestSchema,DataSiloSchema,CookieSchema,UserPreferencesSchema, …) — each.passthrough()so backend additions don't fail validation during rollout.structuredContent. BothbuildMcpServer(the protocol-facing path) andToolRegistry.executeTool(the SDK consumer path) are updated symmetrically.Breaking change
createListResultnow nests pagination metadata (count,totalCount,hasNextPage,nextCursor,paginationNote) underdatainstead of the top level alongsidesuccess. This unifies the envelope shape across list, get, and mutation tools. Consumers must migrate fromresult.totalCount/result.data[0]toresult.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— cleanpnpm --filter '@transcend-io/mcp*' test— all 60+ tests pass, including newoutput-schemas.test.tsand thestructuredContentHTTP-transport assertionpnpm exec oxlint packages/mcp— 0 warnings, 0 errorspnpm check:changeset— cleanstructuredContentflows end-to-end and theunwrapMcpResponsehelper can be removed/simplifiedtools/listJSON Schema renders correctly in Cursor / Claude Desktop tool-pickersMade with Cursor