Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions packages/mcp-common/src/json-schema-sanitize.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'

import { stripNotEmptyJsonSchema } from './json-schema-sanitize'

describe('stripNotEmptyJsonSchema', () => {
it('removes not:{} optional arms from anyOf', () => {
const input = {
properties: {
hint: {
anyOf: [{ not: {} }, { type: 'string', enum: ['wnam', 'enam'] }],
},
},
}
expect(stripNotEmptyJsonSchema(input)).toEqual({
properties: {
hint: { type: 'string', enum: ['wnam', 'enam'] },
},
})
})

it('leaves schemas without not:{} unchanged', () => {
const input = {
properties: {
page: { type: 'number' },
},
}
expect(stripNotEmptyJsonSchema(input)).toEqual(input)
})
})
42 changes: 42 additions & 0 deletions packages/mcp-common/src/json-schema-sanitize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* Remove zod-to-json-schema optional arms like `{ "not": {} }` that break strict
* LLM function-calling validators (Kimi, Gemini, OpenAI strict mode).
*/
export function stripNotEmptyJsonSchema<T>(node: T): T {
if (Array.isArray(node)) {
return node.map((item) => stripNotEmptyJsonSchema(item)) as T
}
if (node === null || typeof node !== 'object') {
return node
}

const obj = { ...(node as Record<string, unknown>) }

if (Array.isArray(obj.anyOf)) {
const kept = obj.anyOf.filter((member) => !isNotEmptySchema(member))
if (kept.length === 1) {
const inlined = stripNotEmptyJsonSchema(kept[0]) as Record<string, unknown>
const { anyOf: _removed, ...rest } = obj
return { ...inlined, ...rest } as T
}
obj.anyOf = kept.map((member) => stripNotEmptyJsonSchema(member))
}

for (const key of Object.keys(obj)) {
if (key === 'anyOf') continue
obj[key] = stripNotEmptyJsonSchema(obj[key])
}

return obj as T
}

function isNotEmptySchema(member: unknown): boolean {
if (!member || typeof member !== 'object') return false
const notValue = (member as Record<string, unknown>).not
return (
Object.keys(member as object).length === 1 &&
notValue !== null &&
typeof notValue === 'object' &&
Object.keys(notValue as object).length === 0
)
}
13 changes: 13 additions & 0 deletions packages/mcp-common/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,22 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { type ZodRawShape } from 'zod'

import { MetricsTracker, SessionStart, ToolCall } from '../../mcp-observability/src'
import * as zodJsonSchemaCompat from '@modelcontextprotocol/sdk/server/zod-json-schema-compat.js'

import { buildAccountTool } from './account-tool'
import { stripNotEmptyJsonSchema } from './json-schema-sanitize'
import { McpError } from './mcp-error'

const patchJsonSchemaCompat = zodJsonSchemaCompat as typeof zodJsonSchemaCompat & {
__cfSanitizePatched?: boolean
}
if (!patchJsonSchemaCompat.__cfSanitizePatched) {
const originalToJsonSchema = patchJsonSchemaCompat.toJsonSchemaCompat
patchJsonSchemaCompat.toJsonSchemaCompat = (schema, opts) =>
stripNotEmptyJsonSchema(originalToJsonSchema(schema, opts))
patchJsonSchemaCompat.__cfSanitizePatched = true
}

import type { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'
import type {
Expand Down
4 changes: 2 additions & 2 deletions packages/mcp-common/src/tools/d1.tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export function registerD1Tools(agent: CloudflareMcpAgent) {
'd1_databases_list',
'List all of the D1 databases in your Cloudflare account',
{
name: D1DatabaseNameParam.nullable().optional(),
name: D1DatabaseNameParam.optional(),
page: PaginationPageParam,
per_page: PaginationPerPageParam,
},
Expand Down Expand Up @@ -65,7 +65,7 @@ export function registerD1Tools(agent: CloudflareMcpAgent) {
'Create a new D1 database in your Cloudflare account',
{
name: D1DatabaseNameParam,
primary_location_hint: D1DatabasePrimaryLocationHintParam.nullable().optional(),
primary_location_hint: D1DatabasePrimaryLocationHintParam,
},
{
title: 'Create D1 database',
Expand Down
19 changes: 9 additions & 10 deletions packages/mcp-common/src/tools/hyperdrive.tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,16 +177,15 @@ export function registerHyperdriveTools(agent: CloudflareMcpAgent) {
'Edit (patch) a Hyperdrive configuration in your Cloudflare account',
{
hyperdrive_id: HyperdriveConfigIdSchema,
name: HyperdriveConfigNameSchema.optional().nullable(),
database: HyperdriveOriginDatabaseSchema.optional().nullable(),
host: HyperdriveOriginHostSchema.optional().nullable(),
port: HyperdriveOriginPortSchema.optional().nullable(),
scheme: HyperdriveOriginSchemeSchema.optional().nullable(),
user: HyperdriveOriginUserSchema.optional().nullable(),
caching_disabled: HyperdriveCachingDisabledSchema.optional().nullable(),
caching_max_age: HyperdriveCachingMaxAgeSchema.optional().nullable(),
caching_stale_while_revalidate:
HyperdriveCachingStaleWhileRevalidateSchema.optional().nullable(),
name: HyperdriveConfigNameSchema.optional(),
database: HyperdriveOriginDatabaseSchema.optional(),
host: HyperdriveOriginHostSchema.optional(),
port: HyperdriveOriginPortSchema.optional(),
scheme: HyperdriveOriginSchemeSchema.optional(),
user: HyperdriveOriginUserSchema.optional(),
caching_disabled: HyperdriveCachingDisabledSchema.optional(),
caching_max_age: HyperdriveCachingMaxAgeSchema.optional(),
caching_stale_while_revalidate: HyperdriveCachingStaleWhileRevalidateSchema.optional(),
},
{
title: 'Edit Hyperdrive config',
Expand Down
4 changes: 2 additions & 2 deletions packages/mcp-common/src/types/shared.types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { z } from 'zod'

export const PaginationPerPageParam = z.number().nullable().optional()
export const PaginationPageParam = z.number().nullable().optional()
export const PaginationPerPageParam = z.number().optional()
export const PaginationPageParam = z.number().optional()

export const PaginationLimitParam = z.number().optional()
export const PaginationOffsetParam = z.number().optional()