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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fix-mcp-server-card.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@transloadit/mcp-server": patch
---

Fix MCP server card schema and add coverage for `/.well-known/mcp/server-card.json`.

40 changes: 35 additions & 5 deletions packages/mcp-server/src/express.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand Down
22 changes: 17 additions & 5 deletions packages/mcp-server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand Down
168 changes: 131 additions & 37 deletions packages/mcp-server/src/server-card.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>

type ServerCardToolDefinition = {
name: string
title: string
description: string
inputSchema: JsonSchemaObject
}

type ServerCard = {
Expand All @@ -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,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Report authentication as required when mcpToken is set

authentication.required is currently computed only from authKey/authSecret, but request authentication is actually enforced by mcpToken in createMcpRequestHandler (packages/mcp-server/src/http-request-handler.ts). In typical self-hosted setups that provide both Transloadit credentials and TRANSLOADIT_MCP_TOKEN, this emits required: false while /mcp still returns 401 without a bearer token, so discovery clients that trust the card will attempt unauthenticated connections and fail.

Useful? React with 👍 / 👎.

schemes: ['bearer'],
},
capabilities: {
tools: { listChanged: false },
},
tools,
}
}
Loading