From c5ed9d696600789b3f1220ad304632cbf28f1169 Mon Sep 17 00:00:00 2001 From: Kousha Talebian Date: Fri, 16 May 2025 14:11:24 -0700 Subject: [PATCH 1/5] add support for function/asset upload --- package-lock.json | 35 ++-- package.json | 3 + packages/mcp/package.json | 2 + packages/mcp/src/server.ts | 22 +++ packages/mcp/src/tools/additionalTools.ts | 34 ++++ packages/mcp/src/tools/index.ts | 3 + packages/mcp/src/tools/uploadAsset.ts | 83 ++++++++++ packages/mcp/src/tools/uploadFunction.ts | 78 +++++++++ packages/mcp/tests/server.spec.ts | 2 + .../mcp/tests/tools/additionalTools.spec.ts | 68 ++++++++ packages/mcp/tests/tools/uploadAsset.spec.ts | 151 ++++++++++++++++++ .../mcp/tests/tools/uploadFunction.spec.ts | 127 +++++++++++++++ packages/openapi-mcp-server/package.json | 2 + packages/openapi-mcp-server/src/server.ts | 11 +- packages/openapi-mcp-server/src/types.ts | 3 +- packages/openapi-mcp-server/src/utils/http.ts | 46 +++++- .../openapi-mcp-server/tests/server.spec.ts | 26 +-- .../tests/utils/http.spec.ts | 150 +++++++++++++++++ 18 files changed, 815 insertions(+), 31 deletions(-) create mode 100644 packages/mcp/src/tools/additionalTools.ts create mode 100644 packages/mcp/src/tools/index.ts create mode 100644 packages/mcp/src/tools/uploadAsset.ts create mode 100644 packages/mcp/src/tools/uploadFunction.ts create mode 100644 packages/mcp/tests/tools/additionalTools.spec.ts create mode 100644 packages/mcp/tests/tools/uploadAsset.spec.ts create mode 100644 packages/mcp/tests/tools/uploadFunction.spec.ts diff --git a/package-lock.json b/package-lock.json index 6291311..0628dec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,9 @@ "packages/openapi-mcp-server", "packages/mcp" ], + "dependencies": { + "form-data": "^4.0.2" + }, "devDependencies": { "@changesets/cli": "^2.28.1", "eslint-plugin-prettier": "^5.4.0", @@ -21,7 +24,7 @@ }, "engines": { "node": ">=20.0.0", - "npm": ">=11.0.0" + "npm": ">=10.0.0" } }, "node_modules/@apidevtools/json-schema-ref-parser": { @@ -1457,6 +1460,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/form-data": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@types/form-data/-/form-data-2.2.1.tgz", + "integrity": "sha512-JAMFhOaHIciYVh8fb5/83nmuO/AHwmto+Hq7a9y8FzLDcC1KCU344XDOMEmahnrTFlHjgh4L0WJFczNIX2GxnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2087,7 +2100,6 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, "license": "MIT" }, "node_modules/atomic-sleep": { @@ -2448,7 +2460,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -2684,7 +2695,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -2924,7 +2934,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -3972,7 +3981,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", - "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -3988,7 +3996,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -3998,7 +4005,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -4403,7 +4409,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -8568,12 +8573,13 @@ }, "packages/mcp": { "name": "@twilio-alpha/mcp", - "version": "0.2.3", + "version": "0.4.0", "license": "MIT", "dependencies": { "@apidevtools/swagger-parser": "^10.1.1", "@modelcontextprotocol/sdk": "^1.7.0", - "@twilio-alpha/openapi-mcp-server": "0.2.3", + "@twilio-alpha/openapi-mcp-server": "0.4.0", + "form-data": "^4.0.2", "inquirer": "^12.5.0", "minimist": "^1.2.8", "openapi-types": "^12.1.3" @@ -8582,6 +8588,7 @@ "twilio-mcp-server": "build/index.js" }, "devDependencies": { + "@types/form-data": "^2.2.1", "@types/minimist": "^1.2.5", "@types/node": "^22.13.10", "@typescript-eslint/eslint-plugin": "^7.18.0", @@ -9206,11 +9213,12 @@ }, "packages/openapi-mcp-server": { "name": "@twilio-alpha/openapi-mcp-server", - "version": "0.2.3", + "version": "0.4.0", "license": "MIT", "dependencies": { "@apidevtools/swagger-parser": "^10.1.1", "@modelcontextprotocol/sdk": "^1.7.0", + "form-data": "^4.0.2", "minimist": "^1.2.8", "node-fetch": "^2.7.0", "openapi-types": "^12.1.3", @@ -9223,6 +9231,7 @@ "openapi-mcp-server": "build/simple.js" }, "devDependencies": { + "@types/form-data": "^2.2.1", "@types/minimist": "^1.2.5", "@types/node": "^22.13.10", "@types/node-fetch": "^2.6.12", @@ -9242,7 +9251,7 @@ }, "engines": { "node": ">=20.0.0", - "npm": ">=11.0.0" + "npm": ">=10.0.0" } }, "packages/openapi-mcp-server/node_modules/@types/node": { diff --git a/package.json b/package.json index 0d2985f..ec967cb 100644 --- a/package.json +++ b/package.json @@ -37,5 +37,8 @@ "engines": { "node": ">=20.0.0", "npm": ">=10.0.0" + }, + "dependencies": { + "form-data": "^4.0.2" } } diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 30fd423..769265f 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -34,11 +34,13 @@ "@apidevtools/swagger-parser": "^10.1.1", "@modelcontextprotocol/sdk": "^1.7.0", "@twilio-alpha/openapi-mcp-server": "0.4.0", + "form-data": "^4.0.2", "inquirer": "^12.5.0", "minimist": "^1.2.8", "openapi-types": "^12.1.3" }, "devDependencies": { + "@types/form-data": "^2.2.1", "@types/minimist": "^1.2.5", "@types/node": "^22.13.10", "@typescript-eslint/eslint-plugin": "^7.18.0", diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 9de106c..2e3098f 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -13,6 +13,7 @@ import { import { Credentials } from '@app/types'; import { toolRequiresAccountSid } from '@app/utils'; +import { loadAdditionalTools, uploadFunction, uploadAsset } from '@app/tools'; type Configuration = { server: { @@ -104,6 +105,21 @@ export default class TwilioOpenAPIMCPServer extends OpenAPIMCPServer { throw new Error(`Resource ${name} not found`); } + protected async makeRequest( + id: string, + api: API, + body?: Record, + ) { + if (id === uploadFunction.name && body) { + return uploadFunction.uploadFunctionExecution(body, this.http); + } + if (id === uploadAsset.name && body) { + return uploadAsset.uploadAssetExecution(body, this.http); + } + + return super.makeRequest(id, api, body); + } + /** * Loads resources for the server * @returns @@ -128,5 +144,11 @@ export default class TwilioOpenAPIMCPServer extends OpenAPIMCPServer { this.tools.set(id, updatedTool); } } + + const additionalTools = loadAdditionalTools(this.configuration?.filters); + for (const [id, { tool, api }] of additionalTools) { + this.tools.set(id, tool); + this.apis.set(id, api); + } } } diff --git a/packages/mcp/src/tools/additionalTools.ts b/packages/mcp/src/tools/additionalTools.ts new file mode 100644 index 0000000..641b59a --- /dev/null +++ b/packages/mcp/src/tools/additionalTools.ts @@ -0,0 +1,34 @@ +import { Tool } from '@modelcontextprotocol/sdk/types'; +import { API, ToolFilters } from '@twilio-alpha/openapi-mcp-server'; +import { uploadFunctionDefinition, uploadFunctionAPI } from './uploadFunction'; +import { uploadAssetDefinition, uploadAssetAPI } from './uploadAsset'; + +type Additional = { + tool: Tool; + api: API; +}; +export default function loadAdditionalTools( + filters?: ToolFilters, +): Map { + const tools: Map = new Map(); + + const shouldIncludeServerless = + !filters || + (filters.services && + filters.services.some((s) => s.includes('serverless'))) || + (filters.tags && filters.tags.some((t) => t.includes('Serverless'))); + + if (shouldIncludeServerless) { + tools.set(uploadFunctionDefinition.name, { + tool: uploadFunctionDefinition, + api: uploadFunctionAPI, + }); + + tools.set(uploadAssetDefinition.name, { + tool: uploadAssetDefinition, + api: uploadAssetAPI, + }); + } + + return tools; +} diff --git a/packages/mcp/src/tools/index.ts b/packages/mcp/src/tools/index.ts new file mode 100644 index 0000000..e300b25 --- /dev/null +++ b/packages/mcp/src/tools/index.ts @@ -0,0 +1,3 @@ +export { default as loadAdditionalTools } from './additionalTools'; +export * as uploadFunction from './uploadFunction'; +export * as uploadAsset from './uploadAsset'; diff --git a/packages/mcp/src/tools/uploadAsset.ts b/packages/mcp/src/tools/uploadAsset.ts new file mode 100644 index 0000000..33ceb16 --- /dev/null +++ b/packages/mcp/src/tools/uploadAsset.ts @@ -0,0 +1,83 @@ +import FormData from 'form-data'; +import { Tool } from '@modelcontextprotocol/sdk/types'; +import { Http } from '@twilio-alpha/openapi-mcp-server/build/utils'; +import { HttpResponse } from '@twilio-alpha/openapi-mcp-server/build/utils/http'; +import { API } from '@twilio-alpha/openapi-mcp-server'; + +export const name = 'TwilioServerlessV1--UploadServerlessAsset'; + +export const uploadAssetExecution = async ( + params: Record, + http: Http, +): Promise> => { + const { serviceSid, assetSid, path, visibility, content, contentType } = + params; + + try { + const serviceUrl = `https://serverless-upload.twilio.com/v1/Services/${serviceSid}`; + const uploadUrl = `${serviceUrl}/Assets/${assetSid}/Versions`; + + const form = new FormData(); + form.append('Path', path); + form.append('Visibility', visibility); + const buffer = Buffer.from(content, 'utf-8'); + form.append('Content', buffer, { + filename: 'asset', + contentType, + }); + + return await http.upload<{ sid: string }>(uploadUrl, form); + } catch (error) { + return { + ok: false, + statusCode: 500, + // @ts-ignore + error, + }; + } +}; + +export const uploadAssetAPI: API = { + method: 'POST', + contentType: 'multipart/form-data', + path: 'fake', +}; + +export const uploadAssetDefinition: Tool = { + name, + description: + 'Upload a JavaScript file as a Twilio Serverless Asset. This creates a new version of the asset that can be deployed.', + inputSchema: { + type: 'object', + properties: { + serviceSid: { + type: 'string', + description: + 'The SID of the Twilio Serverless Service where the asset will be uploaded', + }, + assetSid: { + type: 'string', + description: 'The SID of the Asset to create a new version for', + }, + path: { + type: 'string', + description: 'The HTTP path used to invoke the asset (e.g., "/thanos")', + }, + visibility: { + type: 'string', + description: + 'The visibility of the asset, typically "public" or "private"', + }, + content: { + type: 'string', + description: 'The content of the Asset', + }, + contentType: { + type: 'string', + description: + 'The content type of the Asset being uploaded. This must match the actual content of the file.', + }, + }, + required: ['serviceSid', 'assetSid', 'path', 'visibility', 'content'], + }, +}; diff --git a/packages/mcp/src/tools/uploadFunction.ts b/packages/mcp/src/tools/uploadFunction.ts new file mode 100644 index 0000000..88ecf31 --- /dev/null +++ b/packages/mcp/src/tools/uploadFunction.ts @@ -0,0 +1,78 @@ +import FormData from 'form-data'; +import { Tool } from '@modelcontextprotocol/sdk/types'; +import { Http } from '@twilio-alpha/openapi-mcp-server/build/utils'; +import { HttpResponse } from '@twilio-alpha/openapi-mcp-server/build/utils/http'; +import { API } from '@twilio-alpha/openapi-mcp-server'; + +export const name = 'TwilioServerlessV1--UploadServerlessFunction'; + +export const uploadFunctionExecution = async ( + params: Record, + http: Http, +): Promise> => { + const { serviceSid, functionSid, path, visibility, content } = params; + + try { + const serviceUrl = `https://serverless-upload.twilio.com/v1/Services/${serviceSid}`; + const uploadUrl = `${serviceUrl}/Functions/${functionSid}/Versions`; + + const form = new FormData(); + form.append('Path', path); + form.append('Visibility', visibility); + const buffer = Buffer.from(content, 'utf-8'); + form.append('Content', buffer, { + filename: 'function.js', + contentType: 'application/javascript', + }); + + return await http.upload<{ sid: string }>(uploadUrl, form); + } catch (error) { + return { + ok: false, + statusCode: 500, + // @ts-ignore + error, + }; + } +}; + +export const uploadFunctionAPI: API = { + method: 'POST', + contentType: 'multipart/form-data', + path: 'fake', +}; + +export const uploadFunctionDefinition: Tool = { + name, + description: + 'Upload a JavaScript file as a Twilio Serverless Function. This creates a new version of the function that can be deployed.', + inputSchema: { + type: 'object', + properties: { + serviceSid: { + type: 'string', + description: + 'The SID of the Twilio Serverless Service where the function will be uploaded', + }, + functionSid: { + type: 'string', + description: 'The SID of the Function to create a new version for', + }, + path: { + type: 'string', + description: + 'The HTTP path used to invoke the function (e.g., "/thanos")', + }, + visibility: { + type: 'string', + description: + 'The visibility of the function, typically "public" or "private"', + }, + content: { + type: 'string', + description: 'The JavaScript code content for the function', + }, + }, + required: ['serviceSid', 'functionSid', 'path', 'visibility', 'content'], + }, +}; diff --git a/packages/mcp/tests/server.spec.ts b/packages/mcp/tests/server.spec.ts index d9379fc..472715e 100644 --- a/packages/mcp/tests/server.spec.ts +++ b/packages/mcp/tests/server.spec.ts @@ -208,6 +208,7 @@ describe('TwilioOpenAPIMCPServer', () => { // Mock resources array server.resources = []; server.tools = new Map(); + server.apis = new Map(); // Call method await server.loadCapabilities(); @@ -254,6 +255,7 @@ describe('TwilioOpenAPIMCPServer', () => { ['tool1', mockTool1], ['tool2', mockTool2], ]); + server.apis = new Map(); await server.loadCapabilities(); diff --git a/packages/mcp/tests/tools/additionalTools.spec.ts b/packages/mcp/tests/tools/additionalTools.spec.ts new file mode 100644 index 0000000..5431120 --- /dev/null +++ b/packages/mcp/tests/tools/additionalTools.spec.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; +import loadAdditionalTools from '../../src/tools/additionalTools'; +import { uploadFunctionDefinition } from '../../src/tools/uploadFunction'; +import { uploadAssetDefinition } from '../../src/tools/uploadAsset'; + +describe('additionalTools', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should include serverless tools when no filters are provided', () => { + const tools = loadAdditionalTools(); + + expect(tools.size).toBe(2); + expect(tools.has(uploadFunctionDefinition.name)).toBe(true); + expect(tools.has(uploadAssetDefinition.name)).toBe(true); + }); + + it('should include serverless tools when services filter includes "serverless"', () => { + const tools = loadAdditionalTools({ + services: ['twilio_api_v2010', 'twilio_serverless_v1'], + tags: [], + }); + + expect(tools.size).toBe(2); + expect(tools.has(uploadFunctionDefinition.name)).toBe(true); + expect(tools.has(uploadAssetDefinition.name)).toBe(true); + }); + + it('should include serverless tools when tags filter includes "Serverless"', () => { + const tools = loadAdditionalTools({ + services: [], + tags: ['Messaging', 'Serverless'], + }); + + expect(tools.size).toBe(2); + expect(tools.has(uploadFunctionDefinition.name)).toBe(true); + expect(tools.has(uploadAssetDefinition.name)).toBe(true); + }); + + it('should not include serverless tools when filters do not include serverless', () => { + const tools = loadAdditionalTools({ + services: ['twilio_api_v2010'], + tags: ['Voice', 'Messaging'], + }); + + expect(tools.size).toBe(0); + }); + + it('should return tool definitions with both tool and API properties', () => { + const tools = loadAdditionalTools(); + + const uploadFunctionTool = tools.get(uploadFunctionDefinition.name); + const uploadAssetTool = tools.get(uploadAssetDefinition.name); + + expect(uploadFunctionTool).toBeDefined(); + expect(uploadFunctionTool?.tool).toBe(uploadFunctionDefinition); + expect(uploadFunctionTool?.api).toBeDefined(); + + expect(uploadAssetTool).toBeDefined(); + expect(uploadAssetTool?.tool).toBe(uploadAssetDefinition); + expect(uploadAssetTool?.api).toBeDefined(); + }); +}); diff --git a/packages/mcp/tests/tools/uploadAsset.spec.ts b/packages/mcp/tests/tools/uploadAsset.spec.ts new file mode 100644 index 0000000..ee2f909 --- /dev/null +++ b/packages/mcp/tests/tools/uploadAsset.spec.ts @@ -0,0 +1,151 @@ +import FormData from 'form-data'; +import { describe, expect, it, beforeEach, vi, Mock } from 'vitest'; +import { Http } from '@twilio-alpha/openapi-mcp-server/build/utils'; +import { + uploadAssetExecution, + uploadAssetDefinition, + uploadAssetAPI, + name, +} from '../../src/tools/uploadAsset'; + +// Mock FormData +vi.mock('form-data', () => { + const MockFormData = vi.fn(); + MockFormData.prototype.append = vi.fn(); + return { default: MockFormData }; +}); + +describe('uploadAsset', () => { + let mockHttp: Http; + + beforeEach(() => { + vi.clearAllMocks(); + + // Set up mock Http + mockHttp = { + upload: vi.fn().mockResolvedValue({ + ok: true, + statusCode: 200, + data: { sid: 'FN12345678901234567890123456789012' }, + }), + } as unknown as Http; + }); + + describe('uploadAssetExecution', () => { + it('should correctly format and upload the asset content', async () => { + const params = { + serviceSid: 'ZS12345678901234567890123456789012', + assetSid: 'FN12345678901234567890123456789012', + path: '/styles.css', + visibility: 'public', + content: 'body { font-family: sans-serif; }', + contentType: 'text/css', + }; + + await uploadAssetExecution(params, mockHttp); + + // Check FormData was constructed + expect(FormData).toHaveBeenCalledTimes(1); + + // Check form.append was called correctly + const formInstance = (FormData as unknown as Mock).mock.instances[0]; + expect(formInstance.append).toHaveBeenCalledWith('Path', params.path); + expect(formInstance.append).toHaveBeenCalledWith( + 'Visibility', + params.visibility, + ); + + // Third call should have been for the content buffer + expect(formInstance.append).toHaveBeenCalledTimes(3); + + // Check that upload was called with the correct URL + expect(mockHttp.upload).toHaveBeenCalledTimes(1); + expect(mockHttp.upload).toHaveBeenCalledWith( + `https://serverless-upload.twilio.com/v1/Services/${params.serviceSid}/Assets/${params.assetSid}/Versions`, + expect.any(FormData), + ); + }); + + it('should handle errors during upload', async () => { + const params = { + serviceSid: 'ZS12345678901234567890123456789012', + assetSid: 'FN12345678901234567890123456789012', + path: '/styles.css', + visibility: 'public', + content: 'body { font-family: sans-serif; }', + contentType: 'text/css', + }; + + const error = new Error('Upload failed'); + (mockHttp.upload as Mock).mockRejectedValue(error); + + const result = await uploadAssetExecution(params, mockHttp); + + expect(result).toEqual({ + ok: false, + statusCode: 500, + error, + }); + }); + + it('should use the provided content type in the form data', async () => { + const params = { + serviceSid: 'ZS12345678901234567890123456789012', + assetSid: 'FN12345678901234567890123456789012', + path: '/data.json', + visibility: 'public', + content: '{"key": "value"}', + contentType: 'application/json', + }; + + await uploadAssetExecution(params, mockHttp); + + // Check the content was added with the correct content type + const formInstance = (FormData as unknown as Mock).mock.instances[0]; + const contentAppendCall = formInstance.append.mock.calls[2]; + + expect(contentAppendCall[0]).toBe('Content'); + expect(contentAppendCall[2]).toEqual({ + filename: 'asset', + contentType: 'application/json', + }); + }); + }); + + describe('uploadAssetDefinition', () => { + it('should have correct name', () => { + expect(name).toBe('TwilioServerlessV1--UploadServerlessAsset'); + expect(uploadAssetDefinition.name).toBe(name); + }); + + it('should have a description', () => { + expect(uploadAssetDefinition.description).toBeTruthy(); + }); + + it('should have a valid inputSchema', () => { + const schema = uploadAssetDefinition.inputSchema; + expect(schema.type).toBe('object'); + expect(schema.properties).toHaveProperty('serviceSid'); + expect(schema.properties).toHaveProperty('assetSid'); + expect(schema.properties).toHaveProperty('path'); + expect(schema.properties).toHaveProperty('visibility'); + expect(schema.properties).toHaveProperty('content'); + expect(schema.properties).toHaveProperty('contentType'); + + // Check required fields + expect(schema.required).toContain('serviceSid'); + expect(schema.required).toContain('assetSid'); + expect(schema.required).toContain('path'); + expect(schema.required).toContain('visibility'); + expect(schema.required).toContain('content'); + }); + }); + + describe('uploadAssetAPI', () => { + it('should have correct API configuration', () => { + expect(uploadAssetAPI.method).toBe('POST'); + expect(uploadAssetAPI.contentType).toBe('multipart/form-data'); + expect(uploadAssetAPI.path).toBe('fake'); + }); + }); +}); diff --git a/packages/mcp/tests/tools/uploadFunction.spec.ts b/packages/mcp/tests/tools/uploadFunction.spec.ts new file mode 100644 index 0000000..ca24108 --- /dev/null +++ b/packages/mcp/tests/tools/uploadFunction.spec.ts @@ -0,0 +1,127 @@ +import FormData from 'form-data'; +import { describe, expect, it, beforeEach, vi, Mock } from 'vitest'; +import { Http } from '@twilio-alpha/openapi-mcp-server/build/utils'; +import { + uploadFunctionExecution, + uploadFunctionDefinition, + uploadFunctionAPI, + name, +} from '../../src/tools/uploadFunction'; + +// Mock FormData +vi.mock('form-data', () => { + const MockFormData = vi.fn(); + MockFormData.prototype.append = vi.fn(); + return { default: MockFormData }; +}); + +describe('uploadFunction', () => { + let mockHttp: Http; + + beforeEach(() => { + vi.clearAllMocks(); + + // Set up mock Http + mockHttp = { + upload: vi.fn().mockResolvedValue({ + ok: true, + statusCode: 200, + data: { sid: 'FN12345678901234567890123456789012' }, + }), + } as unknown as Http; + }); + + describe('uploadFunctionExecution', () => { + it('should correctly format and upload the function content', async () => { + const params = { + serviceSid: 'ZS12345678901234567890123456789012', + functionSid: 'FN12345678901234567890123456789012', + path: '/hello-world', + visibility: 'public', + content: + 'exports.handler = function(context, event, callback) { callback(null, "Hello World"); };', + }; + + await uploadFunctionExecution(params, mockHttp); + + // Check FormData was constructed + expect(FormData).toHaveBeenCalledTimes(1); + + // Check form.append was called correctly + const formInstance = (FormData as unknown as Mock).mock.instances[0]; + expect(formInstance.append).toHaveBeenCalledWith('Path', params.path); + expect(formInstance.append).toHaveBeenCalledWith( + 'Visibility', + params.visibility, + ); + + // Third call should have been for the content buffer + expect(formInstance.append).toHaveBeenCalledTimes(3); + + // Check that upload was called with the correct URL + expect(mockHttp.upload).toHaveBeenCalledTimes(1); + expect(mockHttp.upload).toHaveBeenCalledWith( + `https://serverless-upload.twilio.com/v1/Services/${params.serviceSid}/Functions/${params.functionSid}/Versions`, + expect.any(FormData), + ); + }); + + it('should handle errors during upload', async () => { + const params = { + serviceSid: 'ZS12345678901234567890123456789012', + functionSid: 'FN12345678901234567890123456789012', + path: '/hello-world', + visibility: 'public', + content: + 'exports.handler = function(context, event, callback) { callback(null, "Hello World"); };', + }; + + const error = new Error('Upload failed'); + (mockHttp.upload as Mock).mockRejectedValue(error); + + const result = await uploadFunctionExecution(params, mockHttp); + + expect(result).toEqual({ + ok: false, + statusCode: 500, + error, + }); + }); + }); + + describe('uploadFunctionDefinition', () => { + it('should have correct name', () => { + expect(name).toBe('TwilioServerlessV1--UploadServerlessFunction'); + expect(uploadFunctionDefinition.name).toBe(name); + }); + + it('should have a description', () => { + expect(uploadFunctionDefinition.description).toBeTruthy(); + }); + + it('should have a valid inputSchema', () => { + const schema = uploadFunctionDefinition.inputSchema; + expect(schema.type).toBe('object'); + expect(schema.properties).toHaveProperty('serviceSid'); + expect(schema.properties).toHaveProperty('functionSid'); + expect(schema.properties).toHaveProperty('path'); + expect(schema.properties).toHaveProperty('visibility'); + expect(schema.properties).toHaveProperty('content'); + + // Check required fields + expect(schema.required).toContain('serviceSid'); + expect(schema.required).toContain('functionSid'); + expect(schema.required).toContain('path'); + expect(schema.required).toContain('visibility'); + expect(schema.required).toContain('content'); + }); + }); + + describe('uploadFunctionAPI', () => { + it('should have correct API configuration', () => { + expect(uploadFunctionAPI.method).toBe('POST'); + expect(uploadFunctionAPI.contentType).toBe('multipart/form-data'); + expect(uploadFunctionAPI.path).toBe('fake'); + }); + }); +}); diff --git a/packages/openapi-mcp-server/package.json b/packages/openapi-mcp-server/package.json index 549c99d..558c2e7 100644 --- a/packages/openapi-mcp-server/package.json +++ b/packages/openapi-mcp-server/package.json @@ -30,6 +30,7 @@ "dependencies": { "@apidevtools/swagger-parser": "^10.1.1", "@modelcontextprotocol/sdk": "^1.7.0", + "form-data": "^4.0.2", "minimist": "^1.2.8", "node-fetch": "^2.7.0", "openapi-types": "^12.1.3", @@ -39,6 +40,7 @@ "undici": "^7.9.0" }, "devDependencies": { + "@types/form-data": "^2.2.1", "@types/minimist": "^1.2.5", "@types/node": "^22.13.10", "@types/node-fetch": "^2.6.12", diff --git a/packages/openapi-mcp-server/src/server.ts b/packages/openapi-mcp-server/src/server.ts index 27ff645..34aaa65 100644 --- a/packages/openapi-mcp-server/src/server.ts +++ b/packages/openapi-mcp-server/src/server.ts @@ -59,7 +59,7 @@ export default class OpenAPIMCPServer { protected readonly logger; - private http: Http; + protected readonly http: Http; constructor(config: OpenAPIMCPServerConfiguration) { this.configuration = config; @@ -85,11 +85,16 @@ export default class OpenAPIMCPServer { /** * Make a request to the API + * @param id * @param api * @param body * @private */ - protected async makeRequest(api: API, body?: Record) { + protected async makeRequest( + id: string, + api: API, + body?: Record, + ) { const url = interpolateUrl(api.path, body); const headers = { 'Content-Type': api.contentType, @@ -260,7 +265,7 @@ export default class OpenAPIMCPServer { const rawBody = request.params.arguments ?? {}; const body = this.callToolBody(tool, api, rawBody); - const httpResponse = await this.makeRequest(api, body); + const httpResponse = await this.makeRequest(id, api, body); if (!httpResponse.ok) { this.logger.error({ message: 'failed to make request', diff --git a/packages/openapi-mcp-server/src/types.ts b/packages/openapi-mcp-server/src/types.ts index 3c4618d..0b93585 100644 --- a/packages/openapi-mcp-server/src/types.ts +++ b/packages/openapi-mcp-server/src/types.ts @@ -1,7 +1,8 @@ export type HttpMethod = 'GET' | 'DELETE' | 'POST' | 'PUT'; export type ContentType = | 'application/json' - | 'application/x-www-form-urlencoded'; + | 'application/x-www-form-urlencoded' + | 'multipart/form-data'; export type API = { method: HttpMethod; diff --git a/packages/openapi-mcp-server/src/utils/http.ts b/packages/openapi-mcp-server/src/utils/http.ts index 01e88c6..4cbcd95 100644 --- a/packages/openapi-mcp-server/src/utils/http.ts +++ b/packages/openapi-mcp-server/src/utils/http.ts @@ -1,4 +1,5 @@ import fetch, { Response } from 'node-fetch'; +import FormData from 'form-data'; import qs from 'qs'; import { HttpMethod } from '@app/types'; @@ -133,7 +134,17 @@ export default class Http { }; } - if (['POST', 'PUT'].includes(request.method) && request.body) { + // Handle FormData objects specially + if (request.body && '__formData' in request.body) { + // @ts-ignore + if (options.headers['Content-Type']) { + // @ts-ignore + delete options.headers['Content-Type']; + } + // @ts-ignore + // eslint-disable-next-line no-underscore-dangle + options.body = request.body.__formData; + } else if (['POST', 'PUT'].includes(request.method) && request.body) { options.body = Http.getBody(request.body, request.headers); } @@ -245,6 +256,34 @@ export default class Http { }); } + /** + * Makes a multipart form data upload request + * @param url the url to make the request to + * @param formData the FormData instance to upload + * @param options additional options for the request + */ + public async upload( + url: string, + formData: FormData, + options?: RequestOption, + ): Promise> { + const formHeaders = formData.getHeaders ? formData.getHeaders() : {}; + + const headers = { + ...(options?.headers || {}), + ...formHeaders, + }; + + return this.make({ + url, + method: 'POST', + headers, + // Use a special symbol or property to indicate this is a FormData object + // rather than a regular body that needs serialization + body: { __formData: formData }, + }); + } + /** * Returns the body of the request * @param body @@ -257,7 +296,10 @@ export default class Http { ): string { const contentType = headers?.['Content-Type'] as string; if (contentType === 'application/x-www-form-urlencoded') { - return qs.stringify(body); + return qs.stringify(body, { + arrayFormat: 'repeat', + indices: false, + }); } return JSON.stringify(body); diff --git a/packages/openapi-mcp-server/tests/server.spec.ts b/packages/openapi-mcp-server/tests/server.spec.ts index 11bba86..ad42f30 100644 --- a/packages/openapi-mcp-server/tests/server.spec.ts +++ b/packages/openapi-mcp-server/tests/server.spec.ts @@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; import { loadTools, readSpecs } from '@app/utils'; import OpenAPIMCPServer from '../src/server'; +import { API } from '@app/types'; vi.mock('@modelcontextprotocol/sdk/server/index.js', () => { return { @@ -153,8 +154,8 @@ describe('OpenAPIMCPServer', () => { await this.load(); } - public testMakeRequest(api: any) { - return this.makeRequest(api); + public testMakeRequest(id: string, api: any) { + return this.makeRequest(id, api); } } @@ -170,7 +171,7 @@ describe('OpenAPIMCPServer', () => { testServer.http.get = vi.fn().mockResolvedValue(mockHttpResponse); - const result = await testServer.testMakeRequest({ + const result = await testServer.testMakeRequest('test-id', { path: '/api/test', method: 'GET', contentType: 'application/json', @@ -184,8 +185,8 @@ describe('OpenAPIMCPServer', () => { it('should make POST requests via the http util', async () => { class TestServer extends OpenAPIMCPServer { - public testMakeRequest(api: any, body?: any) { - return this.makeRequest(api, body); + public testMakeRequest(id: string, api: API, body?: any) { + return this.makeRequest(id, api, body); } } @@ -203,6 +204,7 @@ describe('OpenAPIMCPServer', () => { testServer.http.post = vi.fn().mockResolvedValue(mockHttpResponse); const result = await testServer.testMakeRequest( + 'test-id', { path: '/api/test', method: 'POST', contentType: 'application/json' }, mockBody, ); @@ -215,8 +217,8 @@ describe('OpenAPIMCPServer', () => { it('should make DELETE requests via the http util', async () => { class TestServer extends OpenAPIMCPServer { - public testMakeRequest(api: any, body?: any) { - return this.makeRequest(api, body); + public testMakeRequest(id: string, api: API, body?: any) { + return this.makeRequest(id, api, body); } } @@ -231,7 +233,7 @@ describe('OpenAPIMCPServer', () => { testServer.http.delete = vi.fn().mockResolvedValue(mockHttpResponse); - const result = await testServer.testMakeRequest({ + const result = await testServer.testMakeRequest('test-id', { path: '/api/test', method: 'DELETE', contentType: 'application/json', @@ -245,15 +247,15 @@ describe('OpenAPIMCPServer', () => { it('should throw an error for unsupported HTTP methods', async () => { class TestServer extends OpenAPIMCPServer { - public testMakeRequest(api: any, body?: any) { - return this.makeRequest(api, body); + public testMakeRequest(id: string, api: API, body?: any) { + return this.makeRequest(id, api, body); } } const testServer = new TestServer(mockConfig); await expect( - testServer.testMakeRequest({ + testServer.testMakeRequest('test-id', { path: '/api/test', method: 'PATCH' as any, contentType: 'application/json', @@ -265,7 +267,7 @@ describe('OpenAPIMCPServer', () => { class CustomServer extends OpenAPIMCPServer { protected callToolBody( tool: Tool, - api: any, + api: API, body: Record, ) { return { ...body, extra: 'value' }; diff --git a/packages/openapi-mcp-server/tests/utils/http.spec.ts b/packages/openapi-mcp-server/tests/utils/http.spec.ts index a54c208..df9e1d4 100644 --- a/packages/openapi-mcp-server/tests/utils/http.spec.ts +++ b/packages/openapi-mcp-server/tests/utils/http.spec.ts @@ -424,4 +424,154 @@ describe('Http', () => { ).toBe('/api/v1/resource/{id}'); }); }); + + describe('upload requests', () => { + it('should make a successful file upload request', async () => { + const FormData = require('form-data'); + const formData = new FormData(); + formData.append('file', 'test file content', 'test.txt'); + formData.append('description', 'Test file upload'); + + // Mock FormData's getHeaders method + formData.getHeaders = vi.fn().mockReturnValue({ + 'content-type': 'multipart/form-data; boundary=---boundary', + }); + + const responseData = { fileId: '12345', success: true }; + const mockResponseObj = mockResponse(201, responseData); + + (fetch as unknown as Mock).mockResolvedValueOnce(mockResponseObj); + + const result = await http.upload( + 'https://api.example.com/upload', + formData, + ); + + expect(fetch).toHaveBeenCalledWith( + 'https://api.example.com/upload', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'content-type': 'multipart/form-data; boundary=---boundary', + Authorization: 'Basic dGVzdC11c2VybmFtZTp0ZXN0LXBhc3N3b3Jk', + }), + body: formData, + }), + ); + + expect(result).toEqual({ + ok: true, + statusCode: 201, + data: responseData, + response: mockResponseObj, + }); + }); + + it('should handle failed upload requests', async () => { + const FormData = require('form-data'); + const formData = new FormData(); + formData.append('file', 'invalid file content', 'invalid.txt'); + + // Mock FormData's getHeaders method + formData.getHeaders = vi.fn().mockReturnValue({ + 'content-type': 'multipart/form-data; boundary=---boundary', + }); + + const errorData = { error: 'File upload failed' }; + const mockResponseObj = mockResponse(400, errorData, false); + + (fetch as unknown as Mock).mockResolvedValueOnce(mockResponseObj); + + const result = await http.upload( + 'https://api.example.com/upload', + formData, + ); + + expect(result).toEqual({ + ok: false, + statusCode: 400, + error: new Error(JSON.stringify(errorData)), + response: mockResponseObj, + }); + }); + + it('should upload with custom headers', async () => { + const FormData = require('form-data'); + const formData = new FormData(); + formData.append('file', 'test content', 'test.txt'); + + // Mock FormData's getHeaders method + formData.getHeaders = vi.fn().mockReturnValue({ + 'content-type': 'multipart/form-data; boundary=---boundary', + }); + + const responseData = { fileId: '12345', success: true }; + const mockResponseObj = mockResponse(200, responseData); + + (fetch as unknown as Mock).mockResolvedValueOnce(mockResponseObj); + + const result = await http.upload( + 'https://api.example.com/upload', + formData, + { headers: { 'X-Custom-Header': 'custom-value' } } + ); + + expect(fetch).toHaveBeenCalledWith( + 'https://api.example.com/upload', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'content-type': 'multipart/form-data; boundary=---boundary', + 'X-Custom-Header': 'custom-value', + Authorization: 'Basic dGVzdC11c2VybmFtZTp0ZXN0LXBhc3N3b3Jk', + }), + body: formData, + }), + ); + + expect(result).toEqual({ + ok: true, + statusCode: 200, + data: responseData, + response: mockResponseObj, + }); + }); + + it('should handle FormData without getHeaders method', async () => { + const FormData = require('form-data'); + const formData = new FormData(); + formData.append('file', 'test content', 'test.txt'); + + // Simulate FormData without getHeaders method + formData.getHeaders = undefined; + + const responseData = { fileId: '12345', success: true }; + const mockResponseObj = mockResponse(200, responseData); + + (fetch as unknown as Mock).mockResolvedValueOnce(mockResponseObj); + + const result = await http.upload( + 'https://api.example.com/upload', + formData, + ); + + expect(fetch).toHaveBeenCalledWith( + 'https://api.example.com/upload', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Basic dGVzdC11c2VybmFtZTp0ZXN0LXBhc3N3b3Jk', + }), + body: formData, + }), + ); + + expect(result).toEqual({ + ok: true, + statusCode: 200, + data: responseData, + response: mockResponseObj, + }); + }); + }); }); From 908ad22e249d1306151c62b0cf1134714d11bbba Mon Sep 17 00:00:00 2001 From: Kousha Talebian Date: Fri, 16 May 2025 14:12:23 -0700 Subject: [PATCH 2/5] add support for function/asset upload --- package-lock.json | 3 --- package.json | 3 --- 2 files changed, 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0628dec..4b2888e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,9 +12,6 @@ "packages/openapi-mcp-server", "packages/mcp" ], - "dependencies": { - "form-data": "^4.0.2" - }, "devDependencies": { "@changesets/cli": "^2.28.1", "eslint-plugin-prettier": "^5.4.0", diff --git a/package.json b/package.json index ec967cb..0d2985f 100644 --- a/package.json +++ b/package.json @@ -37,8 +37,5 @@ "engines": { "node": ">=20.0.0", "npm": ">=10.0.0" - }, - "dependencies": { - "form-data": "^4.0.2" } } From 7e8159666dfd064f941350bd1953d7da254d50f1 Mon Sep 17 00:00:00 2001 From: Kousha Talebian Date: Fri, 16 May 2025 14:14:57 -0700 Subject: [PATCH 3/5] lint --- packages/openapi-mcp-server/tests/server.spec.ts | 2 +- packages/openapi-mcp-server/tests/utils/http.spec.ts | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/openapi-mcp-server/tests/server.spec.ts b/packages/openapi-mcp-server/tests/server.spec.ts index ad42f30..60db4eb 100644 --- a/packages/openapi-mcp-server/tests/server.spec.ts +++ b/packages/openapi-mcp-server/tests/server.spec.ts @@ -5,8 +5,8 @@ import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; import { loadTools, readSpecs } from '@app/utils'; -import OpenAPIMCPServer from '../src/server'; import { API } from '@app/types'; +import OpenAPIMCPServer from '../src/server'; vi.mock('@modelcontextprotocol/sdk/server/index.js', () => { return { diff --git a/packages/openapi-mcp-server/tests/utils/http.spec.ts b/packages/openapi-mcp-server/tests/utils/http.spec.ts index df9e1d4..6512f65 100644 --- a/packages/openapi-mcp-server/tests/utils/http.spec.ts +++ b/packages/openapi-mcp-server/tests/utils/http.spec.ts @@ -1,5 +1,6 @@ import fetch, { Response } from 'node-fetch'; import { beforeEach, describe, expect, it, Mock, test, vi } from 'vitest'; +import FormData from 'form-data'; import Http, { Authorization, interpolateUrl } from '@app/utils/http'; @@ -427,7 +428,6 @@ describe('Http', () => { describe('upload requests', () => { it('should make a successful file upload request', async () => { - const FormData = require('form-data'); const formData = new FormData(); formData.append('file', 'test file content', 'test.txt'); formData.append('description', 'Test file upload'); @@ -468,7 +468,6 @@ describe('Http', () => { }); it('should handle failed upload requests', async () => { - const FormData = require('form-data'); const formData = new FormData(); formData.append('file', 'invalid file content', 'invalid.txt'); @@ -496,7 +495,6 @@ describe('Http', () => { }); it('should upload with custom headers', async () => { - const FormData = require('form-data'); const formData = new FormData(); formData.append('file', 'test content', 'test.txt'); @@ -513,7 +511,7 @@ describe('Http', () => { const result = await http.upload( 'https://api.example.com/upload', formData, - { headers: { 'X-Custom-Header': 'custom-value' } } + { headers: { 'X-Custom-Header': 'custom-value' } }, ); expect(fetch).toHaveBeenCalledWith( @@ -538,7 +536,6 @@ describe('Http', () => { }); it('should handle FormData without getHeaders method', async () => { - const FormData = require('form-data'); const formData = new FormData(); formData.append('file', 'test content', 'test.txt'); From 7496c9beb71376d9931e3a6c98ae415c2fc6cfb4 Mon Sep 17 00:00:00 2001 From: Kousha Talebian Date: Fri, 16 May 2025 14:16:16 -0700 Subject: [PATCH 4/5] add content-type as required --- packages/mcp/src/tools/uploadAsset.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/mcp/src/tools/uploadAsset.ts b/packages/mcp/src/tools/uploadAsset.ts index 33ceb16..36d48fa 100644 --- a/packages/mcp/src/tools/uploadAsset.ts +++ b/packages/mcp/src/tools/uploadAsset.ts @@ -78,6 +78,13 @@ export const uploadAssetDefinition: Tool = { 'The content type of the Asset being uploaded. This must match the actual content of the file.', }, }, - required: ['serviceSid', 'assetSid', 'path', 'visibility', 'content'], + required: [ + 'serviceSid', + 'assetSid', + 'path', + 'visibility', + 'content', + 'contentType', + ], }, }; From 6568886271455846e1e30225d0608f99cefdc5d1 Mon Sep 17 00:00:00 2001 From: Kousha Talebian Date: Fri, 16 May 2025 14:27:29 -0700 Subject: [PATCH 5/5] address pr comments --- packages/mcp/src/tools/uploadAsset.ts | 3 ++- packages/mcp/src/tools/uploadFunction.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/mcp/src/tools/uploadAsset.ts b/packages/mcp/src/tools/uploadAsset.ts index 36d48fa..d8cb217 100644 --- a/packages/mcp/src/tools/uploadAsset.ts +++ b/packages/mcp/src/tools/uploadAsset.ts @@ -37,6 +37,7 @@ export const uploadAssetExecution = async ( } }; +// these are unused - it's a stub export const uploadAssetAPI: API = { method: 'POST', contentType: 'multipart/form-data', @@ -46,7 +47,7 @@ export const uploadAssetAPI: API = { export const uploadAssetDefinition: Tool = { name, description: - 'Upload a JavaScript file as a Twilio Serverless Asset. This creates a new version of the asset that can be deployed.', + "Create a new Asset resource. Assets are static files like HTML, CSS, images, or client-side JavaScript files that can be referenced by your Serverless Functions or served directly to clients. You must create a Service before creating Assets. After creating an Asset, you'll need to create Asset Versions to add the actual content.", inputSchema: { type: 'object', properties: { diff --git a/packages/mcp/src/tools/uploadFunction.ts b/packages/mcp/src/tools/uploadFunction.ts index 88ecf31..afbb6bc 100644 --- a/packages/mcp/src/tools/uploadFunction.ts +++ b/packages/mcp/src/tools/uploadFunction.ts @@ -36,6 +36,7 @@ export const uploadFunctionExecution = async ( } }; +// these are unused - it's a stub export const uploadFunctionAPI: API = { method: 'POST', contentType: 'multipart/form-data',