From 1884597812264307424e3ca5bd6ca29bbebe0043 Mon Sep 17 00:00:00 2001 From: Travis Fischer Date: Wed, 11 Jun 2025 12:53:23 +0700 Subject: [PATCH] feat: add support for ToolConfig.additionalProperties --- apps/e2e/bin/deploy-fixtures.ts | 4 +- .../src/__snapshots__/http-e2e.test.ts.snap | 14 ++++ apps/e2e/src/http-e2e.test.ts | 12 ++-- apps/e2e/src/http-fixtures.ts | 49 +++++++++++++ apps/gateway/src/app.ts | 3 +- ...eate-http-request-for-openapi-operation.ts | 66 +++++++++++++----- apps/gateway/src/lib/get-request-cache-key.ts | 39 ++++++----- .../src/lib/get-tool-args-from-request.ts | 2 +- .../src/lib/resolve-origin-tool-call.ts | 17 ++++- apps/gateway/src/lib/utils.test.ts | 1 + apps/gateway/src/lib/utils.ts | 12 +++- .../everything-openapi/agentic.config.ts | 4 ++ .../routes/strict-additional-properties.ts | 37 ++++++++++ .../valid/everything-openapi/src/server.ts | 2 + .../get-tools-from-openapi-spec.test.ts.snap | 47 +++++++++++++ .../validate-openapi-spec.test.ts.snap | 68 ++++++++++++++++++- .../load-agentic-config.test.ts.snap | 22 +++--- packages/types/src/openapi.d.ts | 5 ++ packages/types/src/tools.ts | 15 +++- 19 files changed, 360 insertions(+), 59 deletions(-) create mode 100644 packages/fixtures/valid/everything-openapi/src/routes/strict-additional-properties.ts diff --git a/apps/e2e/bin/deploy-fixtures.ts b/apps/e2e/bin/deploy-fixtures.ts index 816473e2..12afcc8f 100644 --- a/apps/e2e/bin/deploy-fixtures.ts +++ b/apps/e2e/bin/deploy-fixtures.ts @@ -16,8 +16,8 @@ const fixtures = [ // 'pricing-3-plans', // 'pricing-monthly-annual', // 'pricing-custom-0', - // 'basic-openapi', - // 'basic-mcp', + 'basic-openapi', + 'basic-mcp', 'everything-openapi' ] diff --git a/apps/e2e/src/__snapshots__/http-e2e.test.ts.snap b/apps/e2e/src/__snapshots__/http-e2e.test.ts.snap index 2f15a83c..4934b017 100644 --- a/apps/e2e/src/__snapshots__/http-e2e.test.ts.snap +++ b/apps/e2e/src/__snapshots__/http-e2e.test.ts.snap @@ -221,3 +221,17 @@ et est aut quod aut provident voluptas autem voluptas", "userId": 1, } `; + +exports[`OpenAPI kitchen sink pure tool > 7.0: POST @dev/test-everything-openapi@390e70bf/pure 1`] = ` +{ + "foo": "bar", + "nala": "kitten", +} +`; + +exports[`OpenAPI kitchen sink pure tool > 7.1: POST @dev/test-everything-openapi@390e70bf/pure 1`] = ` +{ + "foo": "bar", + "nala": "kitten", +} +`; diff --git a/apps/e2e/src/http-e2e.test.ts b/apps/e2e/src/http-e2e.test.ts index 44d303c1..9526fb5a 100644 --- a/apps/e2e/src/http-e2e.test.ts +++ b/apps/e2e/src/http-e2e.test.ts @@ -74,12 +74,6 @@ for (const [i, fixtureSuite] of fixtureSuites.entries()) { ) expect(type).toBe(expectedContentType) - if (expectedHeaders) { - for (const [key, value] of Object.entries(expectedHeaders)) { - expect(res.headers.get(key)).toBe(value) - } - } - let body: any if (type.includes('json')) { @@ -107,6 +101,12 @@ for (const [i, fixtureSuite] of fixtureSuites.entries()) { expect(body).toMatchSnapshot() } + if (expectedHeaders) { + for (const [key, value] of Object.entries(expectedHeaders)) { + expect(res.headers.get(key)).toBe(value) + } + } + if (compareResponseBodies && status >= 200 && status < 300) { if (!fixtureResponseBody) { fixtureResponseBody = body diff --git a/apps/e2e/src/http-fixtures.ts b/apps/e2e/src/http-fixtures.ts index 2a63bb4d..8d6c63a7 100644 --- a/apps/e2e/src/http-fixtures.ts +++ b/apps/e2e/src/http-fixtures.ts @@ -475,5 +475,54 @@ export const fixtureSuites: E2ETestFixtureSuite[] = [ } } ] + }, + { + title: 'OpenAPI kitchen sink pure tool', + sequential: true, + compareResponseBodies: true, + fixtures: [ + { + path: '@dev/test-everything-openapi@390e70bf/pure', + request: { + method: 'POST', + json: { + nala: 'kitten', + foo: 'bar' + } + }, + response: { + headers: { + 'cache-control': + 'public, max-age=31560000, s-maxage=31560000, stale-while-revalidate=3600' + }, + body: { + nala: 'kitten', + foo: 'bar' + } + } + }, + { + // second request should hit the cache + path: '@dev/test-everything-openapi@390e70bf/pure', + request: { + method: 'POST', + json: { + nala: 'kitten', + foo: 'bar' + } + }, + response: { + headers: { + 'cf-cache-status': 'HIT', + 'cache-control': + 'public, max-age=31560000, s-maxage=31560000, stale-while-revalidate=3600' + }, + body: { + nala: 'kitten', + foo: 'bar' + } + } + } + ] } ] diff --git a/apps/gateway/src/app.ts b/apps/gateway/src/app.ts index 599d58a2..52c47b52 100644 --- a/apps/gateway/src/app.ts +++ b/apps/gateway/src/app.ts @@ -53,13 +53,14 @@ app.use(responseTime) app.all(async (ctx) => { const waitUntil = ctx.executionCtx.waitUntil.bind(ctx.executionCtx) + const isCachingEnabled = isRequestPubliclyCacheable(ctx.req.raw) ctx.set('cache', caches.default) ctx.set( 'client', createAgenticClient({ env: ctx.env, cache: caches.default, - isCachingEnabled: isRequestPubliclyCacheable(ctx.req.raw), + isCachingEnabled, waitUntil }) ) diff --git a/apps/gateway/src/lib/create-http-request-for-openapi-operation.ts b/apps/gateway/src/lib/create-http-request-for-openapi-operation.ts index 499942b5..f9831df6 100644 --- a/apps/gateway/src/lib/create-http-request-for-openapi-operation.ts +++ b/apps/gateway/src/lib/create-http-request-for-openapi-operation.ts @@ -24,6 +24,10 @@ export async function createHttpRequestForOpenAPIOperation({ `Internal logic error for origin adapter type "${deployment.originAdapter.type}"` ) + const { method } = operation + const methodHasBody = + method === 'post' || method === 'put' || method === 'patch' + // TODO: Make this more efficient by changing the `parameterSources` data structure const params = Object.entries(operation.parameterSources) const bodyParams = params.filter(([_key, source]) => source === 'body') @@ -40,6 +44,20 @@ export async function createHttpRequestForOpenAPIOperation({ 'Cookie parameters for OpenAPI operations are not yet supported. If you need cookie parameter support, please contact support@agentic.so.' ) + // TODO: Make this more efficient... + const extraArgs = Object.keys(toolCallArgs).filter((key) => { + if (bodyParams.some(([paramKey]) => paramKey === key)) return false + if (formDataParams.some(([paramKey]) => paramKey === key)) return false + if (headerParams.some(([paramKey]) => paramKey === key)) return false + if (queryParams.some(([paramKey]) => paramKey === key)) return false + if (pathParams.some(([paramKey]) => paramKey === key)) return false + if (cookieParams.some(([paramKey]) => paramKey === key)) return false + return true + }) + const extraArgsEntries = extraArgs + .map((key) => [key, toolCallArgs[key]]) + .filter(([, value]) => value !== undefined) + const headers: Record = {} if (request) { // TODO: do we want to expose these? especially authorization? @@ -59,31 +77,40 @@ export async function createHttpRequestForOpenAPIOperation({ } let body: string | undefined - if (bodyParams.length > 0) { - body = JSON.stringify( - Object.fromEntries( + if (methodHasBody) { + if (bodyParams.length > 0 || !formDataParams.length) { + const bodyJson = Object.fromEntries( bodyParams .map(([key]) => [key, toolCallArgs[key]]) + .concat(extraArgsEntries) // Prune undefined values. We know these aren't required fields, // because the incoming request params have already been validated // against the tool's input schema. .filter(([, value]) => value !== undefined) ) - ) - headers['content-type'] ??= 'application/json' - } else if (formDataParams.length > 0) { - // TODO: Double-check FormData usage. - const formData = new FormData() - for (const [key] of formDataParams) { - const value = toolCallArgs[key] - if (value !== undefined) { - formData.append(key, value) + body = JSON.stringify(bodyJson) + headers['content-type'] = 'application/json' + headers['content-length'] = body.length.toString() + } else if (formDataParams.length > 0) { + // TODO: Double-check FormData usage. + const bodyFormData = new FormData() + + for (const [key] of formDataParams) { + const value = toolCallArgs[key] + if (value !== undefined) { + bodyFormData.append(key, value) + } } - } - body = formData.toString() - headers['content-type'] ??= 'application/x-www-form-urlencoded' + for (const [key, value] of extraArgsEntries) { + bodyFormData.append(key, value) + } + + body = bodyFormData.toString() + headers['content-type'] = 'application/x-www-form-urlencoded' + headers['content-length'] = body.length.toString() + } } let path = operation.path @@ -112,13 +139,20 @@ export async function createHttpRequestForOpenAPIOperation({ for (const [key] of queryParams) { query.set(key, toolCallArgs[key] as string) } + + if (!methodHasBody) { + for (const [key, value] of extraArgsEntries) { + query.set(key, value) + } + } + const queryString = query.toString() const originRequestUrl = `${deployment.originUrl}${path}${ queryString ? `?${queryString}` : '' }` return new Request(originRequestUrl, { - method: operation.method, + method: method.toUpperCase(), body, headers }) diff --git a/apps/gateway/src/lib/get-request-cache-key.ts b/apps/gateway/src/lib/get-request-cache-key.ts index 8733fe2a..161addd7 100644 --- a/apps/gateway/src/lib/get-request-cache-key.ts +++ b/apps/gateway/src/lib/get-request-cache-key.ts @@ -15,26 +15,32 @@ export async function getRequestCacheKey( return } - if (request.method === 'POST' || request.method === 'PUT') { + if ( + request.method === 'POST' || + request.method === 'PUT' || + request.method === 'PATCH' + ) { const contentLength = Number.parseInt( request.headers.get('content-length') ?? '0' ) - if (contentLength && contentLength < MAX_POST_BODY_SIZE_BYTES) { + if (contentLength < MAX_POST_BODY_SIZE_BYTES) { const { type } = contentType.safeParse( request.headers.get('content-type') || 'application/octet-stream' ) - let hash: string + let hash = '___AGENTIC_CACHE_KEY_EMPTY_BODY___' - if (type.includes('json')) { - const bodyJson: any = await request.clone().json() - hash = hashObject(bodyJson) - } else if (type.includes('text/')) { - const bodyString = await request.clone().text() - hash = await sha256(bodyString) - } else { - const bodyBuffer = await request.clone().arrayBuffer() - hash = await sha256(bodyBuffer) + if (contentLength > 0) { + if (type.includes('json')) { + const bodyJson: any = await request.clone().json() + hash = hashObject(bodyJson) + } else if (type.includes('text/')) { + const bodyString = await request.clone().text() + hash = await sha256(bodyString) + } else { + const bodyBuffer = await request.clone().arrayBuffer() + hash = await sha256(bodyBuffer) + } } const cacheUrl = new URL(request.url) @@ -56,8 +62,6 @@ export async function getRequestCacheKey( return newReq } - - return } else if (request.method === 'GET' || request.method === 'HEAD') { const url = request.url const normalizedUrl = normalizeUrl(url) @@ -80,11 +84,14 @@ export async function getRequestCacheKey( request.url, err ) - return } } -const requestHeaderWhitelist = new Set(['cache-control', 'mcp-session-id']) +const requestHeaderWhitelist = new Set([ + 'cache-control', + 'content-type', + 'mcp-session-id' +]) function normalizeRequestHeaders(request: Request) { const headers = Object.fromEntries(request.headers.entries()) diff --git a/apps/gateway/src/lib/get-tool-args-from-request.ts b/apps/gateway/src/lib/get-tool-args-from-request.ts index 6eebb395..4327ebcb 100644 --- a/apps/gateway/src/lib/get-tool-args-from-request.ts +++ b/apps/gateway/src/lib/get-tool-args-from-request.ts @@ -34,7 +34,7 @@ export async function getToolArgsFromRequest( data: incomingRequestArgsRaw, errorPrefix: `Invalid request parameters for tool "${tool.name}"`, coerce: true, - strictAdditionalProperties: true + strictAdditionalProperties: false }) return incomingRequestArgs diff --git a/apps/gateway/src/lib/resolve-origin-tool-call.ts b/apps/gateway/src/lib/resolve-origin-tool-call.ts index 6f2dd6e5..e3088ee1 100644 --- a/apps/gateway/src/lib/resolve-origin-tool-call.ts +++ b/apps/gateway/src/lib/resolve-origin-tool-call.ts @@ -26,7 +26,10 @@ import { fetchCache } from './fetch-cache' import { getRequestCacheKey } from './get-request-cache-key' import { enforceRateLimit } from './rate-limits/enforce-rate-limit' import { updateOriginRequest } from './update-origin-request' -import { isCacheControlPubliclyCacheable } from './utils' +import { + isCacheControlPubliclyCacheable, + isResponsePubliclyCacheable +} from './utils' export async function resolveOriginToolCall({ tool, @@ -173,7 +176,7 @@ export async function resolveOriginToolCall({ schema: tool.inputSchema, data: args, errorPrefix: `Invalid request parameters for tool "${tool.name}"`, - strictAdditionalProperties: true + strictAdditionalProperties: false }) const originStartTimeMs = Date.now() @@ -196,9 +199,17 @@ export async function resolveOriginToolCall({ // TODO: transform origin 5XX errors to 502 errors... const originResponse = await fetchCache({ cacheKey, - fetchResponse: () => fetch(originRequest), + fetchResponse: async () => { + let response = await fetch(originRequest) + if (cacheControl && isResponsePubliclyCacheable(response)) { + response = new Response(response.body, response) + response.headers.set('cache-control', cacheControl) + } + return response + }, waitUntil }) + // const originResponse = await fetch(originRequest) const cacheStatus = (originResponse.headers.get('cf-cache-status') as CacheStatus) ?? diff --git a/apps/gateway/src/lib/utils.test.ts b/apps/gateway/src/lib/utils.test.ts index 5b15c8d2..9e79945d 100644 --- a/apps/gateway/src/lib/utils.test.ts +++ b/apps/gateway/src/lib/utils.test.ts @@ -64,6 +64,7 @@ test('isCacheControlPubliclyCacheable false', () => { expect(isCacheControlPubliclyCacheable('no-store')).toBe(false) expect(isCacheControlPubliclyCacheable('no-cache')).toBe(false) expect(isCacheControlPubliclyCacheable('private')).toBe(false) + expect(isCacheControlPubliclyCacheable('max-age=0')).toBe(false) expect(isCacheControlPubliclyCacheable('private, max-age=3600')).toBe(false) expect(isCacheControlPubliclyCacheable('private, s-maxage=3600')).toBe(false) expect( diff --git a/apps/gateway/src/lib/utils.ts b/apps/gateway/src/lib/utils.ts index 65d0c707..7510c665 100644 --- a/apps/gateway/src/lib/utils.ts +++ b/apps/gateway/src/lib/utils.ts @@ -7,6 +7,15 @@ export function isRequestPubliclyCacheable(request: Request): boolean { return isCacheControlPubliclyCacheable(request.headers.get('cache-control')) } +export function isResponsePubliclyCacheable(response: Response): boolean { + const pragma = response.headers.get('pragma') + if (pragma === 'no-cache') { + return false + } + + return isCacheControlPubliclyCacheable(response.headers.get('cache-control')) +} + export function isCacheControlPubliclyCacheable( cacheControl?: string | null ): boolean { @@ -19,7 +28,8 @@ export function isCacheControlPubliclyCacheable( if ( directives.has('no-store') || directives.has('no-cache') || - directives.has('private') + directives.has('private') || + directives.has('max-age=0') ) { return false } diff --git a/packages/fixtures/valid/everything-openapi/agentic.config.ts b/packages/fixtures/valid/everything-openapi/agentic.config.ts index 237b0df5..b5bd4b80 100644 --- a/packages/fixtures/valid/everything-openapi/agentic.config.ts +++ b/packages/fixtures/valid/everything-openapi/agentic.config.ts @@ -65,6 +65,10 @@ export default defineConfig({ { name: 'disabled_rate_limit_tool', rateLimit: null + }, + { + name: 'strict_additional_properties', + additionalProperties: false } ] }) diff --git a/packages/fixtures/valid/everything-openapi/src/routes/strict-additional-properties.ts b/packages/fixtures/valid/everything-openapi/src/routes/strict-additional-properties.ts new file mode 100644 index 00000000..28456883 --- /dev/null +++ b/packages/fixtures/valid/everything-openapi/src/routes/strict-additional-properties.ts @@ -0,0 +1,37 @@ +import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi' + +const route = createRoute({ + description: 'Echoes the request body only allowing a single "foo" field.', + operationId: 'strictAdditionalProperties', + method: 'post', + path: '/strict-additional-properties', + request: { + body: { + content: { + 'application/json': { + schema: z.object({ + foo: z.string() + }) + } + } + } + }, + responses: { + 200: { + description: 'Echoed request body', + content: { + 'application/json': { + schema: z.object({ + foo: z.string() + }) + } + } + } + } +}) + +export function registerStrictAdditionalProperties(app: OpenAPIHono) { + return app.openapi(route, async (c) => { + return c.json(c.req.valid('json')) + }) +} diff --git a/packages/fixtures/valid/everything-openapi/src/server.ts b/packages/fixtures/valid/everything-openapi/src/server.ts index 9ded7cf2..cb130c07 100644 --- a/packages/fixtures/valid/everything-openapi/src/server.ts +++ b/packages/fixtures/valid/everything-openapi/src/server.ts @@ -14,6 +14,7 @@ import { registerHealthCheck } from './routes/health-check' import { registerNoCacheCacheControlTool } from './routes/no-cache-cache-control-tool' import { registerNoStoreCacheControlTool } from './routes/no-store-cache-control-tool' import { registerPure } from './routes/pure' +import { registerStrictAdditionalProperties } from './routes/strict-additional-properties' import { registerUnpureMarkedPure } from './routes/unpure-marked-pure' export const app = new OpenAPIHono() @@ -32,6 +33,7 @@ registerNoStoreCacheControlTool(app) registerNoCacheCacheControlTool(app) registerCustomRateLimitTool(app) registerDisabledRateLimitTool(app) +registerStrictAdditionalProperties(app) app.doc31('/docs', { openapi: '3.1.0', diff --git a/packages/openapi-utils/src/__snapshots__/get-tools-from-openapi-spec.test.ts.snap b/packages/openapi-utils/src/__snapshots__/get-tools-from-openapi-spec.test.ts.snap index 567fa4f5..f3e50301 100644 --- a/packages/openapi-utils/src/__snapshots__/get-tools-from-openapi-spec.test.ts.snap +++ b/packages/openapi-utils/src/__snapshots__/get-tools-from-openapi-spec.test.ts.snap @@ -1672,6 +1672,13 @@ exports[`getToolsFromOpenAPISpec > remote spec https://agentic-platform-fixtures "path": "/health", "tags": undefined, }, + "no_cache_cache_control_tool": { + "method": "post", + "operationId": "noCacheCacheControlTool", + "parameterSources": {}, + "path": "/no-cache-cache-control-tool", + "tags": undefined, + }, "no_store_cache_control_tool": { "method": "post", "operationId": "noStoreCacheControlTool", @@ -1686,6 +1693,13 @@ exports[`getToolsFromOpenAPISpec > remote spec https://agentic-platform-fixtures "path": "/pure", "tags": undefined, }, + "unpure_marked_pure": { + "method": "post", + "operationId": "unpure_marked_pure", + "parameterSources": {}, + "path": "/unpure-marked-pure", + "tags": undefined, + }, }, "tools": [ { @@ -1809,6 +1823,26 @@ exports[`getToolsFromOpenAPISpec > remote spec https://agentic-platform-fixtures "type": "object", }, }, + { + "description": "Unpure tool marked pure", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object", + }, + "name": "unpure_marked_pure", + "outputSchema": { + "properties": { + "now": { + "type": "number", + }, + }, + "required": [ + "now", + ], + "type": "object", + }, + }, { "description": "Custom cache control tool", "inputSchema": { @@ -1835,6 +1869,19 @@ exports[`getToolsFromOpenAPISpec > remote spec https://agentic-platform-fixtures "type": "object", }, }, + { + "description": "No cache cache control tool", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object", + }, + "name": "no_cache_cache_control_tool", + "outputSchema": { + "properties": {}, + "type": "object", + }, + }, { "description": "Custom rate limit tool", "inputSchema": { diff --git a/packages/openapi-utils/src/__snapshots__/validate-openapi-spec.test.ts.snap b/packages/openapi-utils/src/__snapshots__/validate-openapi-spec.test.ts.snap index 5c160d17..d0d89470 100644 --- a/packages/openapi-utils/src/__snapshots__/validate-openapi-spec.test.ts.snap +++ b/packages/openapi-utils/src/__snapshots__/validate-openapi-spec.test.ts.snap @@ -22533,7 +22533,8 @@ exports[`validateOpenAPISpec > remote spec https://agentic-platform-fixtures-eve }, }, "info": { - "title": "OpenAPI server to test everything", + "description": "OpenAPI kitchen sink server meant for testing Agentic's origin OpenAPI adapter and ToolConfig features.", + "title": "OpenAPI server everything", "version": "0.1.0", }, "openapi": "3.1.0", @@ -22732,6 +22733,35 @@ exports[`validateOpenAPISpec > remote spec https://agentic-platform-fixtures-eve }, }, }, + "/no-cache-cache-control-tool": { + "post": { + "description": "No cache cache control tool", + "operationId": "noCacheCacheControlTool", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": {}, + "type": "object", + }, + }, + }, + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": {}, + "type": "object", + }, + }, + }, + "description": "Echoed request body", + }, + }, + }, + }, "/no-store-cache-control-tool": { "post": { "description": "No store cache control tool", @@ -22790,6 +22820,42 @@ exports[`validateOpenAPISpec > remote spec https://agentic-platform-fixtures-eve }, }, }, + "/unpure-marked-pure": { + "post": { + "description": "Unpure tool marked pure", + "operationId": "unpure_marked_pure", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": {}, + "type": "object", + }, + }, + }, + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "now": { + "type": "number", + }, + }, + "required": [ + "now", + ], + "type": "object", + }, + }, + }, + "description": "Echoed request body with current timestamp to not be pure", + }, + }, + }, + }, "/users/{userId}": { "get": { "description": "Gets a user", diff --git a/packages/platform/src/__snapshots__/load-agentic-config.test.ts.snap b/packages/platform/src/__snapshots__/load-agentic-config.test.ts.snap index 0ba666ab..8f982010 100644 --- a/packages/platform/src/__snapshots__/load-agentic-config.test.ts.snap +++ b/packages/platform/src/__snapshots__/load-agentic-config.test.ts.snap @@ -135,7 +135,7 @@ exports[`loadAgenticConfig > everything-openapi 1`] = ` "name": "test-everything-openapi", "originAdapter": { "location": "external", - "spec": "{"openapi":"3.1.0","info":{"title":"OpenAPI server to test everything","version":"0.1.0"},"components":{"schemas":{"User":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"email":{"type":"string"}},"required":["id","name","email"]}},"parameters":{}},"paths":{"/health":{"get":{"description":"Check if the server is healthy","operationId":"healthCheck","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string"}},"required":["status"]}}}}}}},"/users/{userId}":{"get":{"description":"Gets a user","tags":["users"],"operationId":"getUser","parameters":[{"schema":{"type":"string"},"required":true,"description":"User ID","name":"userId","in":"path"}],"responses":{"200":{"description":"A user object","content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}}}}}}},"/disabled-tool":{"get":{"description":"Disabled tool","operationId":"disabledTool","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string"}},"required":["status"]}}}}}}},"/disabled-for-free-plan-tool":{"get":{"description":"Disabled for free plan tool","operationId":"disabledForFreePlanTool","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string"}},"required":["status"]}}}}}}},"/echo":{"post":{"description":"Echoes the request body","operationId":"echo","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{}}}}},"responses":{"200":{"description":"Echoed request body","content":{"application/json":{"schema":{"type":"object","properties":{}}}}}}}},"/pure":{"post":{"description":"Pure tool","operationId":"pure","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{}}}}},"responses":{"200":{"description":"Echoed request body","content":{"application/json":{"schema":{"type":"object","properties":{}}}}}}}},"/custom-cache-control-tool":{"post":{"description":"Custom cache control tool","operationId":"customCacheControlTool","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{}}}}},"responses":{"200":{"description":"Echoed request body","content":{"application/json":{"schema":{"type":"object","properties":{}}}}}}}},"/no-store-cache-control-tool":{"post":{"description":"No store cache control tool","operationId":"noStoreCacheControlTool","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{}}}}},"responses":{"200":{"description":"Echoed request body","content":{"application/json":{"schema":{"type":"object","properties":{}}}}}}}},"/custom-rate-limit-tool":{"post":{"description":"Custom rate limit tool","operationId":"customRateLimitTool","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{}}}}},"responses":{"200":{"description":"Echoed request body","content":{"application/json":{"schema":{"type":"object","properties":{}}}}}}}},"/disabled-rate-limit-tool":{"post":{"description":"Disabled rate limit tool","operationId":"disabledRateLimitTool","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{}}}}},"responses":{"200":{"description":"Echoed request body","content":{"application/json":{"schema":{"type":"object","properties":{}}}}}}}}},"webhooks":{}}", + "spec": "{"openapi":"3.1.0","info":{"title":"OpenAPI server everything","description":"OpenAPI kitchen sink server meant for testing Agentic's origin OpenAPI adapter and ToolConfig features.","version":"0.1.0"},"components":{"schemas":{"User":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"email":{"type":"string"}},"required":["id","name","email"]}},"parameters":{}},"paths":{"/health":{"get":{"description":"Check if the server is healthy","operationId":"healthCheck","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string"}},"required":["status"]}}}}}}},"/users/{userId}":{"get":{"description":"Gets a user","tags":["users"],"operationId":"getUser","parameters":[{"schema":{"type":"string"},"required":true,"description":"User ID","name":"userId","in":"path"}],"responses":{"200":{"description":"A user object","content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}}}}}}},"/disabled-tool":{"get":{"description":"Disabled tool","operationId":"disabledTool","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string"}},"required":["status"]}}}}}}},"/disabled-for-free-plan-tool":{"get":{"description":"Disabled for free plan tool","operationId":"disabledForFreePlanTool","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string"}},"required":["status"]}}}}}}},"/echo":{"post":{"description":"Echoes the request body","operationId":"echo","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{}}}}},"responses":{"200":{"description":"Echoed request body","content":{"application/json":{"schema":{"type":"object","properties":{}}}}}}}},"/pure":{"post":{"description":"Pure tool","operationId":"pure","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{}}}}},"responses":{"200":{"description":"Echoed request body","content":{"application/json":{"schema":{"type":"object","properties":{}}}}}}}},"/unpure-marked-pure":{"post":{"description":"Unpure tool marked pure","operationId":"unpure_marked_pure","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{}}}}},"responses":{"200":{"description":"Echoed request body with current timestamp to not be pure","content":{"application/json":{"schema":{"type":"object","properties":{"now":{"type":"number"}},"required":["now"]}}}}}}},"/custom-cache-control-tool":{"post":{"description":"Custom cache control tool","operationId":"customCacheControlTool","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{}}}}},"responses":{"200":{"description":"Echoed request body","content":{"application/json":{"schema":{"type":"object","properties":{}}}}}}}},"/no-store-cache-control-tool":{"post":{"description":"No store cache control tool","operationId":"noStoreCacheControlTool","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{}}}}},"responses":{"200":{"description":"Echoed request body","content":{"application/json":{"schema":{"type":"object","properties":{}}}}}}}},"/no-cache-cache-control-tool":{"post":{"description":"No cache cache control tool","operationId":"noCacheCacheControlTool","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{}}}}},"responses":{"200":{"description":"Echoed request body","content":{"application/json":{"schema":{"type":"object","properties":{}}}}}}}},"/custom-rate-limit-tool":{"post":{"description":"Custom rate limit tool","operationId":"customRateLimitTool","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{}}}}},"responses":{"200":{"description":"Echoed request body","content":{"application/json":{"schema":{"type":"object","properties":{}}}}}}}},"/disabled-rate-limit-tool":{"post":{"description":"Disabled rate limit tool","operationId":"disabledRateLimitTool","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{}}}}},"responses":{"200":{"description":"Echoed request body","content":{"application/json":{"schema":{"type":"object","properties":{}}}}}}}}},"webhooks":{}}", "type": "openapi", }, "originUrl": "https://agentic-platform-fixtures-everything.onrender.com", @@ -158,7 +158,7 @@ exports[`loadAgenticConfig > everything-openapi 1`] = ` "toolConfigs": [ { "enabled": true, - "name": "getUser", + "name": "get_user", "pricingPlanOverridesMap": { "free": { "enabled": true, @@ -171,13 +171,13 @@ exports[`loadAgenticConfig > everything-openapi 1`] = ` }, { "enabled": false, - "name": "disabledTool", + "name": "disabled_tool", "pure": false, "reportUsage": true, }, { "enabled": true, - "name": "disabledForFreePlanTool", + "name": "disabled_for_free_plan_tool", "pricingPlanOverridesMap": { "free": { "enabled": false, @@ -188,40 +188,40 @@ exports[`loadAgenticConfig > everything-openapi 1`] = ` }, { "enabled": true, - "name": "pureTool", + "name": "pure", "pure": true, "reportUsage": true, }, { "enabled": true, - "name": "unpureToolMarkedPure", + "name": "unpure_marked_pure", "pure": true, "reportUsage": true, }, { "cacheControl": "public, max-age=7200, s-maxage=7200, stale-while-revalidate=3600", "enabled": true, - "name": "customCacheControlTool", + "name": "custom_cache_control_tool", "pure": false, "reportUsage": true, }, { "cacheControl": "no-cache", "enabled": true, - "name": "noCacheCacheControlTool", + "name": "no_cache_cache_control_tool", "pure": false, "reportUsage": true, }, { "cacheControl": "no-store", "enabled": true, - "name": "noStoreCacheControlTool", + "name": "no_store_cache_control_tool", "pure": false, "reportUsage": true, }, { "enabled": true, - "name": "customRateLimitTool", + "name": "custom_rate_limit_tool", "pure": false, "rateLimit": { "async": true, @@ -232,7 +232,7 @@ exports[`loadAgenticConfig > everything-openapi 1`] = ` }, { "enabled": true, - "name": "disabledRateLimitTool", + "name": "disabled_rate_limit_tool", "pure": false, "rateLimit": null, "reportUsage": true, diff --git a/packages/types/src/openapi.d.ts b/packages/types/src/openapi.d.ts index c8125dc0..5c52abbd 100644 --- a/packages/types/src/openapi.d.ts +++ b/packages/types/src/openapi.d.ts @@ -460,6 +460,9 @@ export interface components { [key: string]: unknown; }; required?: string[]; + additionalProperties?: boolean | { + [key: string]: unknown; + }; }; Tool: { /** @description Agentic tool name */ @@ -502,6 +505,8 @@ export interface components { /** @default true */ reportUsage: boolean; rateLimit?: components["schemas"]["RateLimit"] | null; + /** @default true */ + additionalProperties: boolean; /** @description Allows you to override this tool's behavior or disable it entirely for different pricing plans. This is a map of PricingPlan slug to PricingPlanToolOverrides for that plan. */ pricingPlanOverridesMap?: { [key: string]: components["schemas"]["PricingPlanToolOverride"]; diff --git a/packages/types/src/tools.ts b/packages/types/src/tools.ts index 67723c66..91d4904d 100644 --- a/packages/types/src/tools.ts +++ b/packages/types/src/tools.ts @@ -36,7 +36,10 @@ export const jsonSchemaObjectSchema = z type: z.literal('object'), // TODO: improve this schema properties: z.record(z.string(), z.any()).optional(), - required: z.array(z.string()).optional() + required: z.array(z.string()).optional(), + additionalProperties: z + .union([z.boolean(), z.record(z.string(), z.any())]) + .optional() }) .passthrough() .openapi('JsonSchemaObject') @@ -157,6 +160,16 @@ export const toolConfigSchema = z */ rateLimit: z.union([rateLimitSchema, z.null()]).optional(), + /** + * Whether to allow additional properties in the tool's input schema. + * + * The default MCP spec allows additional properties. Set this to `false` if + * you want your tool to be more strict. + * + * @default true + */ + additionalProperties: z.boolean().optional().default(true), + /** * Allows you to override this tool's behavior or disable it entirely for * different pricing plans.