diff --git a/.changeset/fix-mcp-server-card.md b/.changeset/fix-mcp-server-card.md new file mode 100644 index 00000000..276096d8 --- /dev/null +++ b/.changeset/fix-mcp-server-card.md @@ -0,0 +1,6 @@ +--- +"@transloadit/mcp-server": patch +--- + +Fix MCP server card schema and add coverage for `/.well-known/mcp/server-card.json`. + diff --git a/packages/mcp-server/src/express.ts b/packages/mcp-server/src/express.ts index 24b3b186..01ce9f47 100644 --- a/packages/mcp-server/src/express.ts +++ b/packages/mcp-server/src/express.ts @@ -5,8 +5,8 @@ import type { TransloaditMcpHttpOptions } from './http.ts' import { isBasicAuthorized } from './http-helpers.ts' import { createMcpRequestHandler } from './http-request-handler.ts' import { getMetrics, getMetricsContentType } from './metrics.ts' -import { buildServerCard, serverCardPath } from './server-card.ts' import { createTransloaditMcpServer } from './server.ts' +import { buildServerCard, serverCardPath } from './server-card.ts' export type TransloaditMcpExpressOptions = TransloaditMcpHttpOptions & { path?: string @@ -38,13 +38,43 @@ export const createTransloaditMcpExpressRouter = async ( redactSecrets: [options.mcpToken, options.authKey, options.authSecret], }) - const serverCardJson = JSON.stringify(buildServerCard(routePath)) + const serverCardJson = JSON.stringify( + buildServerCard(routePath, { authKey: options.authKey, authSecret: options.authSecret }), + ) - router.get(serverCardPath, (_req, res) => { + const sendServerCard = (res: express.Response, includeBody: boolean) => { + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('Access-Control-Allow-Methods', 'GET,HEAD,OPTIONS') + res.setHeader( + 'Access-Control-Allow-Headers', + 'Authorization,Content-Type,Mcp-Session-Id,Last-Event-ID', + ) + res.setHeader('Content-Type', 'application/json; charset=utf-8') + res.setHeader('Cache-Control', 'public, max-age=3600') + res.setHeader('X-Content-Type-Options', 'nosniff') + if (includeBody) { + res.status(200).send(serverCardJson) + return + } + res.status(200).end() + } + + router.options(serverCardPath, (_req, res) => { res.setHeader('Access-Control-Allow-Origin', '*') res.setHeader('Access-Control-Allow-Methods', 'GET,HEAD,OPTIONS') - res.setHeader('Content-Type', 'application/json') - res.status(200).send(serverCardJson) + res.setHeader( + 'Access-Control-Allow-Headers', + 'Authorization,Content-Type,Mcp-Session-Id,Last-Event-ID', + ) + res.status(204).end() + }) + + router.get(serverCardPath, (_req, res) => { + sendServerCard(res, true) + }) + + router.head(serverCardPath, (_req, res) => { + sendServerCard(res, false) }) router.all(routePath, (req, res) => { diff --git a/packages/mcp-server/src/http.ts b/packages/mcp-server/src/http.ts index dfb87687..f97750b6 100644 --- a/packages/mcp-server/src/http.ts +++ b/packages/mcp-server/src/http.ts @@ -2,12 +2,17 @@ import { randomUUID } from 'node:crypto' import type { IncomingMessage, ServerResponse } from 'node:http' import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' import type { SevLogger } from '@transloadit/sev-logger' -import { isBasicAuthorized, normalizePath, parsePathname } from './http-helpers.ts' +import { + applyCorsHeaders, + isBasicAuthorized, + normalizePath, + parsePathname, +} from './http-helpers.ts' import { createMcpRequestHandler } from './http-request-handler.ts' import { getMetrics, getMetricsContentType } from './metrics.ts' -import { buildServerCard, serverCardPath } from './server-card.ts' import type { TransloaditMcpServerOptions } from './server.ts' import { createTransloaditMcpServer } from './server.ts' +import { buildServerCard, serverCardPath } from './server-card.ts' export type TransloaditMcpHttpOptions = TransloaditMcpServerOptions & { allowedOrigins?: string[] @@ -56,13 +61,18 @@ export const createTransloaditMcpHttpHandler = async ( redactSecrets: [options.mcpToken, options.authKey, options.authSecret], }) - const serverCardJson = JSON.stringify(buildServerCard(expectedPath)) + const serverCardJson = JSON.stringify( + buildServerCard(expectedPath, { authKey: options.authKey, authSecret: options.authSecret }), + ) const handler = (async (req, res) => { const pathname = normalizePath(parsePathname(req.url, expectedPath)) if (pathname === serverCardPath) { - res.setHeader('Access-Control-Allow-Origin', '*') + // Public discovery endpoint for registries; always allow CORS (optionally restricted by allowedOrigins). + if (!applyCorsHeaders(req, res, options.allowedOrigins)) { + return + } res.setHeader('Access-Control-Allow-Methods', 'GET,HEAD,OPTIONS') if (req.method === 'OPTIONS') { res.statusCode = 204 @@ -75,7 +85,9 @@ export const createTransloaditMcpHttpHandler = async ( return } res.statusCode = 200 - res.setHeader('Content-Type', 'application/json') + res.setHeader('Content-Type', 'application/json; charset=utf-8') + res.setHeader('Cache-Control', 'public, max-age=3600') + res.setHeader('X-Content-Type-Options', 'nosniff') res.end(req.method === 'HEAD' ? undefined : serverCardJson) return } diff --git a/packages/mcp-server/src/server-card.ts b/packages/mcp-server/src/server-card.ts index 5b538010..c0aa7af6 100644 --- a/packages/mcp-server/src/server-card.ts +++ b/packages/mcp-server/src/server-card.ts @@ -1,10 +1,15 @@ +import { LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/sdk/types.js' import packageJson from '../package.json' with { type: 'json' } export const serverCardPath = '/.well-known/mcp/server-card.json' -type ServerCardTool = { +type JsonSchemaObject = Record + +type ServerCardToolDefinition = { name: string + title: string description: string + inputSchema: JsonSchemaObject } type ServerCard = { @@ -16,74 +21,163 @@ type ServerCard = { documentationUrl: string iconUrl: string transport: { type: string; endpoint: string } - authentication: { required: boolean; schemes: Array<{ type: string; description: string }> } - capabilities: { tools: boolean } - tools: ServerCardTool[] + authentication?: { required: boolean; schemes: string[] } + capabilities: { tools: { listChanged: boolean } } + tools: ['dynamic'] | ServerCardToolDefinition[] } -const tools: ServerCardTool[] = [ +const tools: ServerCardToolDefinition[] = [ { name: 'transloadit_lint_assembly_instructions', + title: 'Lint Assembly Instructions', description: 'Lint Assembly Instructions without creating an Assembly. Returns structured issues.', + inputSchema: { + type: 'object', + additionalProperties: false, + required: ['instructions'], + properties: { + instructions: { type: ['object', 'array', 'string', 'number', 'boolean', 'null'] }, + strict: { type: 'boolean', description: 'Treat warnings as errors.' }, + return_fixed: { type: 'boolean', description: 'Return normalized instructions when true.' }, + }, + }, }, { name: 'transloadit_create_assembly', + title: 'Create or resume an Assembly', description: 'Create or resume an Assembly, optionally uploading files and waiting for completion.', + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { + instructions: { type: ['object', 'array', 'string', 'number', 'boolean', 'null'] }, + files: { + type: 'array', + items: { type: 'object' }, + }, + fields: { type: 'object' }, + wait_for_completion: { type: 'boolean' }, + wait_timeout_ms: { type: 'number' }, + upload_concurrency: { type: 'number' }, + upload_chunk_size: { type: 'number' }, + upload_behavior: { type: 'string', enum: ['await', 'background', 'none'] }, + expected_uploads: { type: 'number' }, + assembly_url: { type: 'string' }, + }, + }, }, { name: 'transloadit_get_assembly_status', + title: 'Get Assembly Status', description: 'Fetch the latest Assembly status by URL or ID.', + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { + assembly_url: { type: 'string' }, + assembly_id: { type: 'string' }, + }, + }, }, { name: 'transloadit_wait_for_assembly', + title: 'Wait For Assembly Completion', description: 'Polls until the Assembly completes or timeout is reached.', + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { + assembly_url: { type: 'string' }, + assembly_id: { type: 'string' }, + timeout_ms: { type: 'number' }, + poll_interval_ms: { type: 'number' }, + }, + }, }, { name: 'transloadit_list_robots', + title: 'List Robots', description: 'Returns a filtered list of robots with short summaries.', + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { + category: { type: 'string' }, + search: { type: 'string' }, + limit: { type: 'number' }, + cursor: { type: 'string' }, + }, + }, }, { name: 'transloadit_get_robot_help', + title: 'Get Robot Help', description: 'Returns a robot summary and parameter details.', + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { + robot_name: { type: 'string' }, + robot_names: { type: 'array', items: { type: 'string' } }, + }, + }, }, { name: 'transloadit_list_templates', + title: 'List Templates', description: 'List Assembly Templates (owned and/or builtin). Tip: pass include_builtin: "exclusively-latest" to list builtins only.', + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { + page: { type: 'number' }, + page_size: { type: 'number' }, + sort: { type: 'string', enum: ['id', 'name', 'created', 'modified'] }, + order: { type: 'string', enum: ['asc', 'desc'] }, + keywords: { type: 'array', items: { type: 'string' } }, + include_builtin: { + type: 'string', + enum: ['all', 'latest', 'exclusively-all', 'exclusively-latest'], + }, + include_content: { type: 'boolean' }, + }, + }, }, ] -export const buildServerCard = (endpoint: string): ServerCard => ({ - $schema: 'https://static.modelcontextprotocol.io/schemas/mcp-server-card/v1.json', - version: '1.0', - protocolVersion: '2025-03-26', - serverInfo: { - name: 'transloadit-mcp', - title: 'Transloadit MCP Server', - version: packageJson.version, - }, - description: - 'Agent-native media processing: video encoding, image manipulation, document conversion, and more via 86+ Robots.', - documentationUrl: 'https://transloadit.com/docs/topics/ai-agents/', - iconUrl: 'https://transloadit.com/favicon.ico', - transport: { - type: 'streamable-http', - endpoint, - }, - authentication: { - required: true, - schemes: [ - { - type: 'bearer', - description: - 'Transloadit API Bearer token. Self-hosted: set TRANSLOADIT_KEY and TRANSLOADIT_SECRET env vars (auto-mints tokens). Hosted: call the authenticate tool or pass a bearer token.', - }, - ], - }, - capabilities: { - tools: true, - }, - tools, -}) +export const buildServerCard = ( + endpoint: string, + options: { authKey?: string; authSecret?: string } = {}, +): ServerCard => { + const hasCredentials = Boolean(options.authKey && options.authSecret) + + return { + $schema: 'https://static.modelcontextprotocol.io/schemas/mcp-server-card/v1.json', + version: '1.0', + protocolVersion: LATEST_PROTOCOL_VERSION, + serverInfo: { + name: 'transloadit-mcp', + title: 'Transloadit MCP Server', + version: packageJson.version, + }, + description: + 'Agent-native media processing: video encoding, image manipulation, document conversion, and more via 86+ Robots.', + documentationUrl: 'https://transloadit.com/docs/topics/ai-agents/', + iconUrl: 'https://transloadit.com/favicon.ico', + transport: { + type: 'streamable-http', + endpoint, + }, + authentication: { + required: !hasCredentials, + schemes: ['bearer'], + }, + capabilities: { + tools: { listChanged: false }, + }, + tools, + } +} diff --git a/packages/mcp-server/test/e2e/server-card.test.ts b/packages/mcp-server/test/e2e/server-card.test.ts new file mode 100644 index 00000000..49e67b52 --- /dev/null +++ b/packages/mcp-server/test/e2e/server-card.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import { startHttpServer } from './http-server.ts' + +describe('server card', () => { + it('exposes a public server card at /.well-known/mcp/server-card.json', async () => { + const { url, close } = await startHttpServer() + + try { + const cardUrl = new URL(url) + cardUrl.pathname = '/.well-known/mcp/server-card.json' + + const res = await fetch(cardUrl, { headers: { Origin: 'http://example.com' } }) + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toContain('application/json') + expect(res.headers.get('access-control-allow-origin')).toBe('*') + + const body = await res.json() + + expect(body).toMatchObject({ + version: '1.0', + serverInfo: { + name: 'transloadit-mcp', + }, + transport: { + type: 'streamable-http', + endpoint: '/mcp', + }, + capabilities: { + tools: { listChanged: false }, + }, + }) + + expect(Array.isArray(body.tools)).toBe(true) + expect(body.tools.length).toBe(7) + for (const tool of body.tools as Array>) { + expect(typeof tool.name).toBe('string') + expect(typeof tool.title).toBe('string') + expect(typeof tool.description).toBe('string') + expect(typeof tool.inputSchema).toBe('object') + } + } finally { + await close() + } + }) + + it('marks authentication optional when server is configured with authKey+authSecret', async () => { + const { url, close } = await startHttpServer({ authKey: 'key', authSecret: 'secret' }) + + try { + const cardUrl = new URL(url) + cardUrl.pathname = '/.well-known/mcp/server-card.json' + + const res = await fetch(cardUrl) + expect(res.status).toBe(200) + + const body = await res.json() + expect(body.authentication?.required).toBe(false) + expect(body.authentication?.schemes).toEqual(['bearer']) + } finally { + await close() + } + }) + + it('supports HEAD and OPTIONS for discovery clients', async () => { + const { url, close } = await startHttpServer() + + try { + const cardUrl = new URL(url) + cardUrl.pathname = '/.well-known/mcp/server-card.json' + + const optionsRes = await fetch(cardUrl, { method: 'OPTIONS' }) + expect(optionsRes.status).toBe(204) + + const headRes = await fetch(cardUrl, { method: 'HEAD' }) + expect(headRes.status).toBe(200) + expect(await headRes.text()).toBe('') + } finally { + await close() + } + }) +}) diff --git a/packages/mcp-server/test/unit/server-card-express.test.ts b/packages/mcp-server/test/unit/server-card-express.test.ts new file mode 100644 index 00000000..c91bf9ae --- /dev/null +++ b/packages/mcp-server/test/unit/server-card-express.test.ts @@ -0,0 +1,61 @@ +import { createServer } from 'node:http' +import type { AddressInfo } from 'node:net' +import express from 'express' +import { afterEach, describe, expect, it } from 'vitest' +import { createTransloaditMcpExpressRouter } from '../../src/express.ts' + +type RunningServer = { close: () => Promise; baseUrl: URL } + +const start = async (): Promise => { + const app = express() + app.use( + await createTransloaditMcpExpressRouter({ + authKey: 'key', + authSecret: 'secret', + path: '/mcp', + }), + ) + + const server = createServer(app) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() as AddressInfo + const baseUrl = new URL(`http://127.0.0.1:${port}`) + + return { + baseUrl, + close: async () => { + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())) + }) + }, + } +} + +describe('server card (express router)', () => { + let running: RunningServer | undefined + + afterEach(async () => { + if (running) { + await running.close() + running = undefined + } + }) + + it('serves the server card with GET/HEAD/OPTIONS', async () => { + running = await start() + + const cardUrl = new URL('/.well-known/mcp/server-card.json', running.baseUrl) + + const optionsRes = await fetch(cardUrl, { method: 'OPTIONS' }) + expect(optionsRes.status).toBe(204) + + const headRes = await fetch(cardUrl, { method: 'HEAD' }) + expect(headRes.status).toBe(200) + expect(await headRes.text()).toBe('') + + const getRes = await fetch(cardUrl) + expect(getRes.status).toBe(200) + expect(getRes.headers.get('content-type')).toContain('application/json') + expect(getRes.headers.get('access-control-allow-origin')).toBe('*') + }) +})