diff --git a/backend/generate_mcp_endpoints_tools/generate_mcp_tools.py b/backend/generate_mcp_endpoints_tools/generate_mcp_tools.py index b1918ae913..fb78ff1e45 100644 --- a/backend/generate_mcp_endpoints_tools/generate_mcp_tools.py +++ b/backend/generate_mcp_endpoints_tools/generate_mcp_tools.py @@ -109,6 +109,9 @@ def extract_separate_schemas(parameters: List[Dict[str, Any]], request_body: Opt if param_required: query_params_schema['required'].append(param_name) + if body_constants and not request_body: + raise ValueError("x-mcp-tool-body-constants needs a request body to set them on") + # Process request body if present if request_body: body_schema = extract_request_body_schema(request_body, spec, base_path) diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index e703550457..c988cae592 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -26102,6 +26102,10 @@ components: type: object description: JSON schema for request body nullable: true + body_constants: + type: object + description: Body fields the caller must send on top of the ones it assembles from body_schema, which does not describe them + nullable: true AIProvider: type: string diff --git a/frontend/src/lib/components/copilot/chat/api/apiTools.ts b/frontend/src/lib/components/copilot/chat/api/apiTools.ts index 1063bd97e3..386762054f 100644 --- a/frontend/src/lib/components/copilot/chat/api/apiTools.ts +++ b/frontend/src/lib/components/copilot/chat/api/apiTools.ts @@ -4,6 +4,7 @@ import { get } from 'svelte/store' import { workspaceStore } from '$lib/stores' import type { EndpointTool } from '$lib/gen/types.gen' import { McpService } from '$lib/gen/services.gen' +import { withBodyConstants } from './bodyConstants' function buildApiCallTool(endpointTool: EndpointTool): ChatCompletionFunctionTool { // Build the parameters schema for OpenAI function calling @@ -74,20 +75,17 @@ function buildApiCallTool(endpointTool: EndpointTool): ChatCompletionFunctionToo function buildToolsFromEndpoints(endpointTools: EndpointTool[]): { tools: ChatCompletionFunctionTool[] - endpointMap: Record + endpointMap: Record } { const tools: ChatCompletionFunctionTool[] = [] - const endpointMap: Record = {} + const endpointMap: Record = {} for (const endpointTool of endpointTools) { const tool = buildApiCallTool(endpointTool) tools.push(tool) // Store the endpoint info in the map - endpointMap[endpointTool.name] = { - method: endpointTool.method, - path: endpointTool.path - } + endpointMap[endpointTool.name] = endpointTool } return { tools, endpointMap } @@ -95,7 +93,7 @@ function buildToolsFromEndpoints(endpointTools: EndpointTool[]): { export function createApiTools( chatTools: ChatCompletionFunctionTool[], - endpointMap: Record = {} + endpointMap: Record = {} ): Tool<{}>[] { return chatTools.map((chatTool) => { const toolName = chatTool.function.name @@ -167,7 +165,7 @@ export function createApiTools( fetchOptions.headers = { 'Content-Type': 'application/json' } - fetchOptions.body = JSON.stringify(requestBody) + fetchOptions.body = JSON.stringify(withBodyConstants(endpoint, requestBody)) } const response = await fetch(url, fetchOptions) diff --git a/frontend/src/lib/components/copilot/chat/api/bodyConstants.ts b/frontend/src/lib/components/copilot/chat/api/bodyConstants.ts new file mode 100644 index 0000000000..0fcd2c23d4 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/api/bodyConstants.ts @@ -0,0 +1,20 @@ +import type { EndpointTool } from '$lib/gen/types.gen' + +/** + * Add the body fields an endpoint fills in for its callers rather than exposing. + * They are deliberately absent from `body_schema` — a field whose only correct + * value is fixed invites a model to place a placeholder there — so a body + * assembled from that schema alone omits them and the call reaches the API + * incomplete. Mirrors what the MCP server does for its own callers. + */ +export function withBodyConstants(endpoint: EndpointTool, body: unknown): unknown { + if ( + !endpoint.body_constants || + typeof body !== 'object' || + body === null || + Array.isArray(body) + ) { + return body + } + return { ...body, ...endpoint.body_constants } +} diff --git a/frontend/src/lib/components/copilot/chat/global/apiCatalogTools.test.ts b/frontend/src/lib/components/copilot/chat/global/apiCatalogTools.test.ts index 8158fe62b8..a124984784 100644 --- a/frontend/src/lib/components/copilot/chat/global/apiCatalogTools.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/apiCatalogTools.test.ts @@ -103,6 +103,23 @@ const CATALOG = [ type: 'object', properties: { args: { type: 'object' } } } + }, + { + name: 'runScriptByPath', + description: 'Run script by path', + instructions: '', + path: '/w/{workspace}/jobs/run/p/{path}', + method: 'POST', + path_params_schema: { + type: 'object', + properties: { workspace: { type: 'string' }, path: { type: 'string' } }, + required: ['workspace', 'path'] + }, + body_schema: { + type: 'object', + properties: { args: { type: 'object' } } + }, + body_constants: { invisible_flag: true } } ] @@ -245,6 +262,24 @@ describe('call_api_endpoint', () => { expect(result).toEqual({ success: true, data: 'job-id-1' }) }) + it('sends the body fields the endpoint keeps out of its schema', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ 'content-type': 'text/plain' }), + text: async () => 'job-id-2' + }) + vi.stubGlobal('fetch', fetchMock) + await run('call_api_endpoint', { + name: 'runScriptByPath', + params: { path: 'u/me/myscript' }, + body: { args: { n: 1 } } + }) + expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ + args: { n: 1 }, + invisible_flag: true + }) + }) + it('redirects GET endpoints to call_api_get', async () => { const result = await run('call_api_endpoint', { name: 'listWorkers' }) expect(result.error).toContain('call_api_get') diff --git a/frontend/src/lib/components/copilot/chat/global/apiCatalogTools.ts b/frontend/src/lib/components/copilot/chat/global/apiCatalogTools.ts index 5d1324ebe3..6bdcf3da78 100644 --- a/frontend/src/lib/components/copilot/chat/global/apiCatalogTools.ts +++ b/frontend/src/lib/components/copilot/chat/global/apiCatalogTools.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { McpService, type EndpointTool } from '$lib/gen' import { createToolDef, type Tool } from '../shared' +import { withBodyConstants } from '../api/bodyConstants' /** * Generic access to the backend's MCP endpoint catalog (the endpoints marked @@ -195,7 +196,7 @@ async function executeEndpoint( const fetchOptions: RequestInit = { method } if (body !== undefined && method !== 'GET') { fetchOptions.headers = { 'Content-Type': 'application/json' } - fetchOptions.body = JSON.stringify(body) + fetchOptions.body = JSON.stringify(withBodyConstants(endpoint, body)) } const response = await fetch(url, fetchOptions)