From e989470870c4815fd3f88aa12b13607388fe7e73 Mon Sep 17 00:00:00 2001 From: Some sucker that doesnt know how to be private Date: Sat, 28 Feb 2026 12:37:40 -0600 Subject: [PATCH 1/6] feat(mcp): add file attachment tools (add, list, remove) Add three new MCP tools for managing ticket attachments: - add_attachment: upload a local file and attach it to a ticket - list_attachments: list all attachments on a ticket - remove_attachment: remove an attachment by ID or filename Adds multipart/form-data support to the MCP API client for file uploads. Co-Authored-By: Claude Opus 4.6 --- mcp/src/api-client.ts | 128 ++++++++++++++++++ mcp/src/index.ts | 2 + mcp/src/tools/attachments.ts | 247 +++++++++++++++++++++++++++++++++++ mcp/src/utils.ts | 50 +++++++ 4 files changed, 427 insertions(+) create mode 100644 mcp/src/tools/attachments.ts diff --git a/mcp/src/api-client.ts b/mcp/src/api-client.ts index 470ad3d0..2fe965c6 100644 --- a/mcp/src/api-client.ts +++ b/mcp/src/api-client.ts @@ -631,3 +631,131 @@ export async function searchTickets(projectKey: string, query: string) { export async function getRepositoryConfig(projectKey: string) { return apiRequest('GET', `/api/projects/${projectKey}/repository`) } + +// ============================================================================ +// Upload API (multipart/form-data) +// ============================================================================ + +/** + * Make an authenticated multipart/form-data request to the PUNT API + */ +async function apiUploadRequest(path: string, formData: FormData): Promise> { + const apiKey = resolveApiKey() + if (!apiKey) { + const credPath = getCredentialsFilePath() + return { + error: + 'MCP credentials not configured. Either:\n' + + `1. Create credentials file at ${credPath}\n` + + '2. Set PUNT_API_KEY and PUNT_API_URL environment variables\n' + + 'See: https://github.com/your-org/punt#mcp-server for setup instructions', + } + } + + const baseUrl = resolveApiUrl() + const url = `${baseUrl}${path}` + + try { + const response = await fetch(url, { + method: 'POST', + headers: { + // Do NOT set Content-Type - fetch auto-sets multipart boundary + 'X-MCP-API-Key': apiKey, + }, + body: formData, + }) + + if (!response.ok) { + const text = await response.text() + let errorMessage: string + try { + const errorJson = JSON.parse(text) + errorMessage = errorJson.error || errorJson.message || text + } catch { + errorMessage = text || `HTTP ${response.status}` + } + return { error: errorMessage } + } + + const text = await response.text() + if (!text) { + return { data: undefined as T } + } + + const data = JSON.parse(text) as T + return { data } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + return { error: `Upload request failed: ${message}` } + } +} + +// ============================================================================ +// Attachments API +// ============================================================================ + +export interface UploadedFileData { + id: string + filename: string + originalName: string + mimetype: string + size: number + url: string + category: string +} + +export interface UploadResponse { + success: boolean + files: UploadedFileData[] +} + +export interface AttachmentData { + id: string + filename: string + mimeType: string + size: number + url: string + createdAt: string + ticketId: string + uploaderId: string | null +} + +export async function uploadFiles(formData: FormData) { + return apiUploadRequest('/api/upload', formData) +} + +export async function listAttachments(projectKey: string, ticketId: string) { + return apiRequest( + 'GET', + `/api/projects/${projectKey}/tickets/${ticketId}/attachments`, + ) +} + +export interface LinkAttachmentsInput { + attachments: Array<{ + filename: string + originalName: string + mimeType: string + size: number + url: string + }> +} + +export async function linkAttachments( + projectKey: string, + ticketId: string, + data: LinkAttachmentsInput, +) { + return apiRequest( + 'POST', + `/api/projects/${projectKey}/tickets/${ticketId}/attachments`, + data, + ) +} + +export async function deleteAttachment(projectKey: string, ticketId: string, attachmentId: string) { + return apiRequest<{ success: boolean }>( + 'DELETE', + `/api/projects/${projectKey}/tickets/${ticketId}/attachments/${attachmentId}`, + ) +} diff --git a/mcp/src/index.ts b/mcp/src/index.ts index c332cc5f..56067ec6 100644 --- a/mcp/src/index.ts +++ b/mcp/src/index.ts @@ -4,6 +4,7 @@ import './env.js' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { registerAttachmentTools } from './tools/attachments.js' import { registerColumnTools } from './tools/columns.js' import { registerLabelTools } from './tools/labels.js' import { registerMemberTools } from './tools/members.js' @@ -25,6 +26,7 @@ registerMemberTools(server) registerLabelTools(server) registerColumnTools(server) registerRepositoryTools(server) +registerAttachmentTools(server) // Connect via stdio const transport = new StdioServerTransport() diff --git a/mcp/src/tools/attachments.ts b/mcp/src/tools/attachments.ts new file mode 100644 index 00000000..642e3e31 --- /dev/null +++ b/mcp/src/tools/attachments.ts @@ -0,0 +1,247 @@ +import { readFile, stat } from 'node:fs/promises' +import { basename, extname } from 'node:path' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { z } from 'zod' +import { + deleteAttachment, + linkAttachments, + listAttachments, + listTickets, + uploadFiles, +} from '../api-client.js' +import { + errorResponse, + escapeMarkdown, + formatAttachmentList, + formatFileSize, + parseTicketKey, + textResponse, +} from '../utils.js' + +/** + * Map of file extensions to MIME types. + * Matches the allowed types in the PUNT upload route. + * The server performs magic-byte validation, so this is a best-effort hint. + */ +const EXTENSION_TO_MIME: Record = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.mp4': 'video/mp4', + '.webm': 'video/webm', + '.ogg': 'video/ogg', + '.mov': 'video/quicktime', + '.pdf': 'application/pdf', + '.doc': 'application/msword', + '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.xls': 'application/vnd.ms-excel', + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + '.txt': 'text/plain', + '.csv': 'text/csv', +} + +/** + * Detect MIME type from file extension. + * Returns null if the extension is not in the allowed list. + */ +function detectMimeType(filePath: string): string | null { + const ext = extname(filePath).toLowerCase() + return EXTENSION_TO_MIME[ext] ?? null +} + +export function registerAttachmentTools(server: McpServer) { + server.tool( + 'add_attachment', + 'Upload a file from a local path and attach it to a ticket', + { + key: z.string().describe('Ticket key (e.g., PUNT-42)'), + filePath: z.string().describe('Absolute path to the local file to upload'), + }, + async ({ key, filePath }) => { + const parsed = parseTicketKey(key) + if (!parsed) { + return errorResponse(`Invalid ticket key format: ${key}. Expected format: PROJECT-123`) + } + + // Detect MIME type from extension + const mimeType = detectMimeType(filePath) + if (!mimeType) { + const ext = extname(filePath).toLowerCase() + const supportedExts = Object.keys(EXTENSION_TO_MIME).join(', ') + return errorResponse( + `Unsupported file type: ${ext || '(no extension)'}. Supported: ${supportedExts}`, + ) + } + + // Read the file + let fileBuffer: Uint8Array + let fileSize: number + try { + const fileStat = await stat(filePath) + if (!fileStat.isFile()) { + return errorResponse(`Not a file: ${filePath}`) + } + fileSize = fileStat.size + fileBuffer = new Uint8Array(await readFile(filePath)) + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error' + return errorResponse(`Cannot read file: ${message}`) + } + + // Resolve ticket ID + const ticketsResult = await listTickets(parsed.projectKey) + if (ticketsResult.error) { + return errorResponse(ticketsResult.error) + } + + const ticket = ticketsResult.data?.find((t) => t.number === parsed.number) + if (!ticket) { + return errorResponse(`Ticket not found: ${key}`) + } + + // Step 1: Upload the file via multipart/form-data + const fileName = basename(filePath) + const blob = new Blob([fileBuffer.buffer as ArrayBuffer], { type: mimeType }) + const file = new File([blob], fileName, { type: mimeType }) + const formData = new FormData() + formData.append('files', file) + + const uploadResult = await uploadFiles(formData) + if (uploadResult.error) { + return errorResponse(`Upload failed: ${uploadResult.error}`) + } + + const uploadedFiles = uploadResult.data?.files + if (!uploadedFiles || uploadedFiles.length === 0) { + return errorResponse('Upload succeeded but no file data was returned') + } + + const uploaded = uploadedFiles[0] + + // Step 2: Link the uploaded file to the ticket + const linkResult = await linkAttachments(parsed.projectKey, ticket.id, { + attachments: [ + { + filename: uploaded.filename, + originalName: uploaded.originalName, + mimeType: uploaded.mimetype, + size: uploaded.size, + url: uploaded.url, + }, + ], + }) + + if (linkResult.error) { + return errorResponse(`Failed to link attachment to ticket: ${linkResult.error}`) + } + + const ticketKey = `${parsed.projectKey.toUpperCase()}-${parsed.number}` + return textResponse( + `Attached **${escapeMarkdown(fileName)}** (${formatFileSize(fileSize)}, ${mimeType}) to **${ticketKey}**`, + ) + }, + ) + + server.tool( + 'list_attachments', + 'List all attachments for a ticket', + { + key: z.string().describe('Ticket key (e.g., PUNT-42)'), + }, + async ({ key }) => { + const parsed = parseTicketKey(key) + if (!parsed) { + return errorResponse(`Invalid ticket key format: ${key}. Expected format: PROJECT-123`) + } + + // Resolve ticket ID + const ticketsResult = await listTickets(parsed.projectKey) + if (ticketsResult.error) { + return errorResponse(ticketsResult.error) + } + + const ticket = ticketsResult.data?.find((t) => t.number === parsed.number) + if (!ticket) { + return errorResponse(`Ticket not found: ${key}`) + } + + const result = await listAttachments(parsed.projectKey, ticket.id) + if (result.error) { + return errorResponse(result.error) + } + + const ticketKey = `${parsed.projectKey.toUpperCase()}-${parsed.number}` + return textResponse(formatAttachmentList(result.data ?? [], ticketKey)) + }, + ) + + server.tool( + 'remove_attachment', + 'Remove an attachment from a ticket', + { + key: z.string().describe('Ticket key (e.g., PUNT-42)'), + attachmentId: z + .string() + .optional() + .describe('Attachment ID to remove (from list_attachments)'), + filename: z + .string() + .optional() + .describe('Filename to search for (if attachmentId not provided)'), + }, + async ({ key, attachmentId, filename }) => { + const parsed = parseTicketKey(key) + if (!parsed) { + return errorResponse(`Invalid ticket key format: ${key}. Expected format: PROJECT-123`) + } + + if (!attachmentId && !filename) { + return errorResponse('Either attachmentId or filename must be provided') + } + + // Resolve ticket ID + const ticketsResult = await listTickets(parsed.projectKey) + if (ticketsResult.error) { + return errorResponse(ticketsResult.error) + } + + const ticket = ticketsResult.data?.find((t) => t.number === parsed.number) + if (!ticket) { + return errorResponse(`Ticket not found: ${key}`) + } + + // If filename provided instead of ID, look up the attachment + let targetId = attachmentId + let targetFilename = filename + + if (!targetId) { + const listResult = await listAttachments(parsed.projectKey, ticket.id) + if (listResult.error) { + return errorResponse(listResult.error) + } + + const attachments = listResult.data ?? [] + const searchTerm = filename?.toLowerCase() ?? '' + const match = attachments.find((a) => a.filename.toLowerCase().includes(searchTerm)) + + if (!match) { + return errorResponse(`No attachment found matching filename: ${filename}`) + } + + targetId = match.id + targetFilename = match.filename + } + + const result = await deleteAttachment(parsed.projectKey, ticket.id, targetId as string) + if (result.error) { + return errorResponse(result.error) + } + + const ticketKey = `${parsed.projectKey.toUpperCase()}-${parsed.number}` + const displayName = targetFilename ? escapeMarkdown(targetFilename) : targetId + return textResponse(`Removed attachment **${displayName}** from **${ticketKey}**`) + }, + ) +} diff --git a/mcp/src/utils.ts b/mcp/src/utils.ts index 80fe9afb..0489227d 100644 --- a/mcp/src/utils.ts +++ b/mcp/src/utils.ts @@ -376,6 +376,56 @@ export function formatSprintList( return lines.join('\n') } +/** + * Format a file size in bytes to a human-readable string + */ +export function formatFileSize(bytes: number): string { + if (bytes === 0) return '0 B' + const units = ['B', 'KB', 'MB', 'GB'] + const k = 1024 + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), units.length - 1) + const value = bytes / k ** i + return `${value < 10 && i > 0 ? value.toFixed(1) : Math.round(value)} ${units[i]}` +} + +/** + * Format a list of attachments as a markdown table + */ +export function formatAttachmentList( + attachments: Array<{ + id: string + filename: string + mimeType: string + size: number + createdAt: string + }>, + ticketKey: string, +): string { + if (attachments.length === 0) { + return `No attachments on ${ticketKey}.` + } + + const lines: string[] = [] + lines.push(`## Attachments on ${ticketKey}`) + lines.push('') + lines.push('| ID | Filename | Type | Size | Uploaded |') + lines.push('|----|----------|------|------|----------|') + + for (const att of attachments) { + const filename = safeTableCell(att.filename, 40) + const size = formatFileSize(att.size) + const uploaded = formatDate(att.createdAt) + lines.push( + `| ${att.id} | ${filename} | ${escapeTableCell(att.mimeType)} | ${size} | ${uploaded} |`, + ) + } + + lines.push('') + lines.push(`Total: ${attachments.length} attachment(s)`) + + return lines.join('\n') +} + /** * Create a text response for MCP */ From b4f976e1914765b47b5d12bea9918cfeb0cc0d4d Mon Sep 17 00:00:00 2001 From: Some sucker that doesnt know how to be private Date: Sat, 28 Feb 2026 12:40:11 -0600 Subject: [PATCH 2/6] feat(upload): add JSON as allowed attachment type Add application/json to allowed document types across the upload pipeline, admin settings, client defaults, schema defaults, MCP extension map, fuzz tests, and docs. Co-Authored-By: Claude Opus 4.6 --- docs-site/docs/api-reference/admin.md | 2 +- docs-site/docs/api-reference/upload.md | 2 +- docs-site/docs/user-guide/admin.md | 1 + mcp/src/tools/attachments.ts | 1 + prisma/schema.prisma | 2 +- src/__tests__/fuzz/arbitraries/file-upload.ts | 2 ++ src/__tests__/fuzz/security/file-upload.fuzz.test.ts | 1 + src/app/api/upload/route.ts | 2 ++ src/components/admin/settings-form.tsx | 1 + src/components/tickets/file-upload.tsx | 1 + src/hooks/queries/use-system-settings.ts | 1 + src/lib/system-settings.ts | 1 + 12 files changed, 14 insertions(+), 3 deletions(-) diff --git a/docs-site/docs/api-reference/admin.md b/docs-site/docs/api-reference/admin.md index b6fb6071..f6e32217 100644 --- a/docs-site/docs/api-reference/admin.md +++ b/docs-site/docs/api-reference/admin.md @@ -149,7 +149,7 @@ GET /api/admin/settings "maxAttachmentsPerTicket": 20, "allowedImageTypes": ["image/jpeg", "image/png", "image/gif", "image/webp"], "allowedVideoTypes": ["video/mp4", "video/webm", "video/ogg", "video/quicktime"], - "allowedDocumentTypes": ["application/pdf", "text/plain", "text/csv"] + "allowedDocumentTypes": ["application/pdf", "text/plain", "text/csv", "application/json"] } ``` diff --git a/docs-site/docs/api-reference/upload.md b/docs-site/docs/api-reference/upload.md index b989b718..2d705b55 100644 --- a/docs-site/docs/api-reference/upload.md +++ b/docs-site/docs/api-reference/upload.md @@ -26,7 +26,7 @@ GET /api/upload "allowedTypes": { "image": ["image/jpeg", "image/png", "image/gif", "image/webp"], "video": ["video/mp4", "video/webm", "video/ogg", "video/quicktime"], - "document": ["application/pdf", "text/plain", "text/csv"] + "document": ["application/pdf", "text/plain", "text/csv", "application/json"] } } ``` diff --git a/docs-site/docs/user-guide/admin.md b/docs-site/docs/user-guide/admin.md index 2e1e9666..7bfcad42 100644 --- a/docs-site/docs/user-guide/admin.md +++ b/docs-site/docs/user-guide/admin.md @@ -127,6 +127,7 @@ Control which file types can be uploaded: - Excel (`application/vnd.ms-excel`, `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`) - Text (`text/plain`) - CSV (`text/csv`) +- JSON (`application/json`) :::note SVG files are blocked for security reasons (potential XSS via embedded scripts). diff --git a/mcp/src/tools/attachments.ts b/mcp/src/tools/attachments.ts index 642e3e31..18bd2605 100644 --- a/mcp/src/tools/attachments.ts +++ b/mcp/src/tools/attachments.ts @@ -40,6 +40,7 @@ const EXTENSION_TO_MIME: Record = { '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', '.txt': 'text/plain', '.csv': 'text/csv', + '.json': 'application/json', } /** diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 91cfea11..790cefca 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -607,7 +607,7 @@ model SystemSettings { // Allowed MIME types (native JSON arrays) allowedImageTypes Json @default("[\"image/jpeg\",\"image/png\",\"image/gif\",\"image/webp\"]") allowedVideoTypes Json @default("[\"video/mp4\",\"video/webm\",\"video/ogg\",\"video/quicktime\"]") - allowedDocumentTypes Json @default("[\"application/pdf\",\"application/msword\",\"application/vnd.openxmlformats-officedocument.wordprocessingml.document\",\"application/vnd.ms-excel\",\"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\",\"text/plain\",\"text/csv\"]") + allowedDocumentTypes Json @default("[\"application/pdf\",\"application/msword\",\"application/vnd.openxmlformats-officedocument.wordprocessingml.document\",\"application/vnd.ms-excel\",\"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\",\"text/plain\",\"text/csv\",\"application/json\"]") // Email provider configuration emailEnabled Boolean @default(false) diff --git a/src/__tests__/fuzz/arbitraries/file-upload.ts b/src/__tests__/fuzz/arbitraries/file-upload.ts index 36b8beb5..58b87223 100644 --- a/src/__tests__/fuzz/arbitraries/file-upload.ts +++ b/src/__tests__/fuzz/arbitraries/file-upload.ts @@ -34,6 +34,7 @@ export const allowedDocumentTypes = fc.constantFrom( 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'text/plain', 'text/csv', + 'application/json', ) /** @@ -160,5 +161,6 @@ export const uploadSettings = fc.record({ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'text/plain', 'text/csv', + 'application/json', ] as string[]), }) diff --git a/src/__tests__/fuzz/security/file-upload.fuzz.test.ts b/src/__tests__/fuzz/security/file-upload.fuzz.test.ts index 8168754e..6b3eb28b 100644 --- a/src/__tests__/fuzz/security/file-upload.fuzz.test.ts +++ b/src/__tests__/fuzz/security/file-upload.fuzz.test.ts @@ -41,6 +41,7 @@ function createMockSettings(overrides: Partial = {}): SystemSett 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'text/plain', 'text/csv', + 'application/json', ], showAddColumnButton: true, canonicalRepoUrl: null, diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index 38c2dada..d4547f7b 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -27,6 +27,7 @@ const SAFE_EXTENSIONS: Record = { 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx', 'text/plain': 'txt', 'text/csv': 'csv', + 'application/json': 'json', } function generateFilename(originalName: string, mimeType: string): string { @@ -193,6 +194,7 @@ export async function GET() { 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'text/plain', 'text/csv', + 'application/json', ], maxSizes: { image: 10 * 1024 * 1024, diff --git a/src/components/admin/settings-form.tsx b/src/components/admin/settings-form.tsx index 74d4cdbf..088c3872 100644 --- a/src/components/admin/settings-form.tsx +++ b/src/components/admin/settings-form.tsx @@ -40,6 +40,7 @@ const ALL_DOCUMENT_TYPES = [ }, { value: 'text/plain', label: 'Plain Text (TXT)' }, { value: 'text/csv', label: 'CSV' }, + { value: 'application/json', label: 'JSON' }, ] export function SettingsForm() { diff --git a/src/components/tickets/file-upload.tsx b/src/components/tickets/file-upload.tsx index f5e1a8b9..345e5e87 100644 --- a/src/components/tickets/file-upload.tsx +++ b/src/components/tickets/file-upload.tsx @@ -64,6 +64,7 @@ function mimeTypesToExtensions(types: string[]): string[] { 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx', 'text/plain': '.txt', 'text/csv': '.csv', + 'application/json': '.json', } return types.map((type) => mimeToExt[type]).filter(Boolean) } diff --git a/src/hooks/queries/use-system-settings.ts b/src/hooks/queries/use-system-settings.ts index 76502f8d..add481f8 100644 --- a/src/hooks/queries/use-system-settings.ts +++ b/src/hooks/queries/use-system-settings.ts @@ -35,6 +35,7 @@ const DEMO_SYSTEM_SETTINGS: CombinedSystemSettings = { 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'text/plain', 'text/csv', + 'application/json', ], // Branding settings appName: 'PUNT', diff --git a/src/lib/system-settings.ts b/src/lib/system-settings.ts index 1e050dd0..6a0e79fb 100644 --- a/src/lib/system-settings.ts +++ b/src/lib/system-settings.ts @@ -27,6 +27,7 @@ const DEFAULT_SETTINGS = { 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'text/plain', 'text/csv', + 'application/json', ], } From b0aa8398fcd6bbf326b0b54e508d8d008209f7d1 Mon Sep 17 00:00:00 2001 From: Some sucker that doesnt know how to be private Date: Sat, 28 Feb 2026 12:42:46 -0600 Subject: [PATCH 3/6] feat(upload): add JSONL as allowed attachment type Add application/jsonl to allowed document types across the upload pipeline, admin settings, client defaults, schema defaults, MCP extension map, fuzz tests, and docs. Co-Authored-By: Claude Opus 4.6 --- docs-site/docs/api-reference/admin.md | 2 +- docs-site/docs/api-reference/upload.md | 2 +- docs-site/docs/user-guide/admin.md | 1 + mcp/src/tools/attachments.ts | 1 + prisma/schema.prisma | 2 +- src/__tests__/fuzz/arbitraries/file-upload.ts | 2 ++ src/__tests__/fuzz/security/file-upload.fuzz.test.ts | 1 + src/app/api/upload/route.ts | 2 ++ src/components/admin/settings-form.tsx | 1 + src/components/tickets/file-upload.tsx | 1 + src/hooks/queries/use-system-settings.ts | 1 + src/lib/system-settings.ts | 1 + 12 files changed, 14 insertions(+), 3 deletions(-) diff --git a/docs-site/docs/api-reference/admin.md b/docs-site/docs/api-reference/admin.md index f6e32217..ace9fadc 100644 --- a/docs-site/docs/api-reference/admin.md +++ b/docs-site/docs/api-reference/admin.md @@ -149,7 +149,7 @@ GET /api/admin/settings "maxAttachmentsPerTicket": 20, "allowedImageTypes": ["image/jpeg", "image/png", "image/gif", "image/webp"], "allowedVideoTypes": ["video/mp4", "video/webm", "video/ogg", "video/quicktime"], - "allowedDocumentTypes": ["application/pdf", "text/plain", "text/csv", "application/json"] + "allowedDocumentTypes": ["application/pdf", "text/plain", "text/csv", "application/json", "application/jsonl"] } ``` diff --git a/docs-site/docs/api-reference/upload.md b/docs-site/docs/api-reference/upload.md index 2d705b55..ae0d9075 100644 --- a/docs-site/docs/api-reference/upload.md +++ b/docs-site/docs/api-reference/upload.md @@ -26,7 +26,7 @@ GET /api/upload "allowedTypes": { "image": ["image/jpeg", "image/png", "image/gif", "image/webp"], "video": ["video/mp4", "video/webm", "video/ogg", "video/quicktime"], - "document": ["application/pdf", "text/plain", "text/csv", "application/json"] + "document": ["application/pdf", "text/plain", "text/csv", "application/json", "application/jsonl"] } } ``` diff --git a/docs-site/docs/user-guide/admin.md b/docs-site/docs/user-guide/admin.md index 7bfcad42..d17a64d5 100644 --- a/docs-site/docs/user-guide/admin.md +++ b/docs-site/docs/user-guide/admin.md @@ -128,6 +128,7 @@ Control which file types can be uploaded: - Text (`text/plain`) - CSV (`text/csv`) - JSON (`application/json`) +- JSONL (`application/jsonl`) :::note SVG files are blocked for security reasons (potential XSS via embedded scripts). diff --git a/mcp/src/tools/attachments.ts b/mcp/src/tools/attachments.ts index 18bd2605..d8754fbe 100644 --- a/mcp/src/tools/attachments.ts +++ b/mcp/src/tools/attachments.ts @@ -41,6 +41,7 @@ const EXTENSION_TO_MIME: Record = { '.txt': 'text/plain', '.csv': 'text/csv', '.json': 'application/json', + '.jsonl': 'application/jsonl', } /** diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 790cefca..87e977c5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -607,7 +607,7 @@ model SystemSettings { // Allowed MIME types (native JSON arrays) allowedImageTypes Json @default("[\"image/jpeg\",\"image/png\",\"image/gif\",\"image/webp\"]") allowedVideoTypes Json @default("[\"video/mp4\",\"video/webm\",\"video/ogg\",\"video/quicktime\"]") - allowedDocumentTypes Json @default("[\"application/pdf\",\"application/msword\",\"application/vnd.openxmlformats-officedocument.wordprocessingml.document\",\"application/vnd.ms-excel\",\"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\",\"text/plain\",\"text/csv\",\"application/json\"]") + allowedDocumentTypes Json @default("[\"application/pdf\",\"application/msword\",\"application/vnd.openxmlformats-officedocument.wordprocessingml.document\",\"application/vnd.ms-excel\",\"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\",\"text/plain\",\"text/csv\",\"application/json\",\"application/jsonl\"]") // Email provider configuration emailEnabled Boolean @default(false) diff --git a/src/__tests__/fuzz/arbitraries/file-upload.ts b/src/__tests__/fuzz/arbitraries/file-upload.ts index 58b87223..b6e8c2b7 100644 --- a/src/__tests__/fuzz/arbitraries/file-upload.ts +++ b/src/__tests__/fuzz/arbitraries/file-upload.ts @@ -35,6 +35,7 @@ export const allowedDocumentTypes = fc.constantFrom( 'text/plain', 'text/csv', 'application/json', + 'application/jsonl', ) /** @@ -162,5 +163,6 @@ export const uploadSettings = fc.record({ 'text/plain', 'text/csv', 'application/json', + 'application/jsonl', ] as string[]), }) diff --git a/src/__tests__/fuzz/security/file-upload.fuzz.test.ts b/src/__tests__/fuzz/security/file-upload.fuzz.test.ts index 6b3eb28b..135afd7b 100644 --- a/src/__tests__/fuzz/security/file-upload.fuzz.test.ts +++ b/src/__tests__/fuzz/security/file-upload.fuzz.test.ts @@ -42,6 +42,7 @@ function createMockSettings(overrides: Partial = {}): SystemSett 'text/plain', 'text/csv', 'application/json', + 'application/jsonl', ], showAddColumnButton: true, canonicalRepoUrl: null, diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index d4547f7b..0f814f91 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -28,6 +28,7 @@ const SAFE_EXTENSIONS: Record = { 'text/plain': 'txt', 'text/csv': 'csv', 'application/json': 'json', + 'application/jsonl': 'jsonl', } function generateFilename(originalName: string, mimeType: string): string { @@ -195,6 +196,7 @@ export async function GET() { 'text/plain', 'text/csv', 'application/json', + 'application/jsonl', ], maxSizes: { image: 10 * 1024 * 1024, diff --git a/src/components/admin/settings-form.tsx b/src/components/admin/settings-form.tsx index 088c3872..1528a8b0 100644 --- a/src/components/admin/settings-form.tsx +++ b/src/components/admin/settings-form.tsx @@ -41,6 +41,7 @@ const ALL_DOCUMENT_TYPES = [ { value: 'text/plain', label: 'Plain Text (TXT)' }, { value: 'text/csv', label: 'CSV' }, { value: 'application/json', label: 'JSON' }, + { value: 'application/jsonl', label: 'JSONL' }, ] export function SettingsForm() { diff --git a/src/components/tickets/file-upload.tsx b/src/components/tickets/file-upload.tsx index 345e5e87..547fcc9f 100644 --- a/src/components/tickets/file-upload.tsx +++ b/src/components/tickets/file-upload.tsx @@ -65,6 +65,7 @@ function mimeTypesToExtensions(types: string[]): string[] { 'text/plain': '.txt', 'text/csv': '.csv', 'application/json': '.json', + 'application/jsonl': '.jsonl', } return types.map((type) => mimeToExt[type]).filter(Boolean) } diff --git a/src/hooks/queries/use-system-settings.ts b/src/hooks/queries/use-system-settings.ts index add481f8..21d0118c 100644 --- a/src/hooks/queries/use-system-settings.ts +++ b/src/hooks/queries/use-system-settings.ts @@ -36,6 +36,7 @@ const DEMO_SYSTEM_SETTINGS: CombinedSystemSettings = { 'text/plain', 'text/csv', 'application/json', + 'application/jsonl', ], // Branding settings appName: 'PUNT', diff --git a/src/lib/system-settings.ts b/src/lib/system-settings.ts index 6a0e79fb..d3345caf 100644 --- a/src/lib/system-settings.ts +++ b/src/lib/system-settings.ts @@ -28,6 +28,7 @@ const DEFAULT_SETTINGS = { 'text/plain', 'text/csv', 'application/json', + 'application/jsonl', ], } From e0b7701c2150c180d2ed1dd3b8d62abb478620b9 Mon Sep 17 00:00:00 2001 From: Some sucker that doesnt know how to be private Date: Sat, 28 Feb 2026 12:54:38 -0600 Subject: [PATCH 4/6] feat(upload): add Markdown as allowed attachment type Add text/markdown to allowed document types across the upload pipeline, admin settings, client defaults, schema defaults, MCP extension map, fuzz tests, and docs. Co-Authored-By: Claude Opus 4.6 --- docs-site/docs/api-reference/admin.md | 2 +- docs-site/docs/api-reference/upload.md | 2 +- docs-site/docs/user-guide/admin.md | 1 + mcp/src/tools/attachments.ts | 1 + prisma/schema.prisma | 2 +- src/__tests__/fuzz/arbitraries/file-upload.ts | 2 ++ src/__tests__/fuzz/security/file-upload.fuzz.test.ts | 1 + src/app/api/upload/route.ts | 2 ++ src/components/admin/settings-form.tsx | 1 + src/components/tickets/file-upload.tsx | 1 + src/hooks/queries/use-system-settings.ts | 1 + src/lib/system-settings.ts | 1 + 12 files changed, 14 insertions(+), 3 deletions(-) diff --git a/docs-site/docs/api-reference/admin.md b/docs-site/docs/api-reference/admin.md index ace9fadc..3629a82d 100644 --- a/docs-site/docs/api-reference/admin.md +++ b/docs-site/docs/api-reference/admin.md @@ -149,7 +149,7 @@ GET /api/admin/settings "maxAttachmentsPerTicket": 20, "allowedImageTypes": ["image/jpeg", "image/png", "image/gif", "image/webp"], "allowedVideoTypes": ["video/mp4", "video/webm", "video/ogg", "video/quicktime"], - "allowedDocumentTypes": ["application/pdf", "text/plain", "text/csv", "application/json", "application/jsonl"] + "allowedDocumentTypes": ["application/pdf", "text/plain", "text/csv", "application/json", "application/jsonl", "text/markdown"] } ``` diff --git a/docs-site/docs/api-reference/upload.md b/docs-site/docs/api-reference/upload.md index ae0d9075..c130de0b 100644 --- a/docs-site/docs/api-reference/upload.md +++ b/docs-site/docs/api-reference/upload.md @@ -26,7 +26,7 @@ GET /api/upload "allowedTypes": { "image": ["image/jpeg", "image/png", "image/gif", "image/webp"], "video": ["video/mp4", "video/webm", "video/ogg", "video/quicktime"], - "document": ["application/pdf", "text/plain", "text/csv", "application/json", "application/jsonl"] + "document": ["application/pdf", "text/plain", "text/csv", "application/json", "application/jsonl", "text/markdown"] } } ``` diff --git a/docs-site/docs/user-guide/admin.md b/docs-site/docs/user-guide/admin.md index d17a64d5..3d13b3b0 100644 --- a/docs-site/docs/user-guide/admin.md +++ b/docs-site/docs/user-guide/admin.md @@ -129,6 +129,7 @@ Control which file types can be uploaded: - CSV (`text/csv`) - JSON (`application/json`) - JSONL (`application/jsonl`) +- Markdown (`text/markdown`) :::note SVG files are blocked for security reasons (potential XSS via embedded scripts). diff --git a/mcp/src/tools/attachments.ts b/mcp/src/tools/attachments.ts index d8754fbe..91e2d738 100644 --- a/mcp/src/tools/attachments.ts +++ b/mcp/src/tools/attachments.ts @@ -42,6 +42,7 @@ const EXTENSION_TO_MIME: Record = { '.csv': 'text/csv', '.json': 'application/json', '.jsonl': 'application/jsonl', + '.md': 'text/markdown', } /** diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 87e977c5..bf3fe6de 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -607,7 +607,7 @@ model SystemSettings { // Allowed MIME types (native JSON arrays) allowedImageTypes Json @default("[\"image/jpeg\",\"image/png\",\"image/gif\",\"image/webp\"]") allowedVideoTypes Json @default("[\"video/mp4\",\"video/webm\",\"video/ogg\",\"video/quicktime\"]") - allowedDocumentTypes Json @default("[\"application/pdf\",\"application/msword\",\"application/vnd.openxmlformats-officedocument.wordprocessingml.document\",\"application/vnd.ms-excel\",\"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\",\"text/plain\",\"text/csv\",\"application/json\",\"application/jsonl\"]") + allowedDocumentTypes Json @default("[\"application/pdf\",\"application/msword\",\"application/vnd.openxmlformats-officedocument.wordprocessingml.document\",\"application/vnd.ms-excel\",\"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\",\"text/plain\",\"text/csv\",\"application/json\",\"application/jsonl\",\"text/markdown\"]") // Email provider configuration emailEnabled Boolean @default(false) diff --git a/src/__tests__/fuzz/arbitraries/file-upload.ts b/src/__tests__/fuzz/arbitraries/file-upload.ts index b6e8c2b7..06081886 100644 --- a/src/__tests__/fuzz/arbitraries/file-upload.ts +++ b/src/__tests__/fuzz/arbitraries/file-upload.ts @@ -36,6 +36,7 @@ export const allowedDocumentTypes = fc.constantFrom( 'text/csv', 'application/json', 'application/jsonl', + 'text/markdown', ) /** @@ -164,5 +165,6 @@ export const uploadSettings = fc.record({ 'text/csv', 'application/json', 'application/jsonl', + 'text/markdown', ] as string[]), }) diff --git a/src/__tests__/fuzz/security/file-upload.fuzz.test.ts b/src/__tests__/fuzz/security/file-upload.fuzz.test.ts index 135afd7b..5615142a 100644 --- a/src/__tests__/fuzz/security/file-upload.fuzz.test.ts +++ b/src/__tests__/fuzz/security/file-upload.fuzz.test.ts @@ -43,6 +43,7 @@ function createMockSettings(overrides: Partial = {}): SystemSett 'text/csv', 'application/json', 'application/jsonl', + 'text/markdown', ], showAddColumnButton: true, canonicalRepoUrl: null, diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index 0f814f91..f13cf61f 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -29,6 +29,7 @@ const SAFE_EXTENSIONS: Record = { 'text/csv': 'csv', 'application/json': 'json', 'application/jsonl': 'jsonl', + 'text/markdown': 'md', } function generateFilename(originalName: string, mimeType: string): string { @@ -197,6 +198,7 @@ export async function GET() { 'text/csv', 'application/json', 'application/jsonl', + 'text/markdown', ], maxSizes: { image: 10 * 1024 * 1024, diff --git a/src/components/admin/settings-form.tsx b/src/components/admin/settings-form.tsx index 1528a8b0..7de98671 100644 --- a/src/components/admin/settings-form.tsx +++ b/src/components/admin/settings-form.tsx @@ -42,6 +42,7 @@ const ALL_DOCUMENT_TYPES = [ { value: 'text/csv', label: 'CSV' }, { value: 'application/json', label: 'JSON' }, { value: 'application/jsonl', label: 'JSONL' }, + { value: 'text/markdown', label: 'Markdown' }, ] export function SettingsForm() { diff --git a/src/components/tickets/file-upload.tsx b/src/components/tickets/file-upload.tsx index 547fcc9f..dea1ae12 100644 --- a/src/components/tickets/file-upload.tsx +++ b/src/components/tickets/file-upload.tsx @@ -66,6 +66,7 @@ function mimeTypesToExtensions(types: string[]): string[] { 'text/csv': '.csv', 'application/json': '.json', 'application/jsonl': '.jsonl', + 'text/markdown': '.md', } return types.map((type) => mimeToExt[type]).filter(Boolean) } diff --git a/src/hooks/queries/use-system-settings.ts b/src/hooks/queries/use-system-settings.ts index 21d0118c..111c669e 100644 --- a/src/hooks/queries/use-system-settings.ts +++ b/src/hooks/queries/use-system-settings.ts @@ -37,6 +37,7 @@ const DEMO_SYSTEM_SETTINGS: CombinedSystemSettings = { 'text/csv', 'application/json', 'application/jsonl', + 'text/markdown', ], // Branding settings appName: 'PUNT', diff --git a/src/lib/system-settings.ts b/src/lib/system-settings.ts index d3345caf..439f66ce 100644 --- a/src/lib/system-settings.ts +++ b/src/lib/system-settings.ts @@ -29,6 +29,7 @@ const DEFAULT_SETTINGS = { 'text/csv', 'application/json', 'application/jsonl', + 'text/markdown', ], } From 1b92d1e0209e10390b2465c43ef08dbaac509802 Mon Sep 17 00:00:00 2001 From: Some sucker that doesnt know how to be private Date: Sat, 28 Feb 2026 14:07:49 -0600 Subject: [PATCH 5/6] feat(mcp): add download_attachment tool Add ability to download ticket attachments to local filesystem via MCP. Includes a new streaming API endpoint and MCP tool that supports lookup by attachment ID or filename. Co-Authored-By: Claude Opus 4.6 --- mcp/src/api-client.ts | 48 ++++++++++ mcp/src/tools/attachments.ts | 94 ++++++++++++++++++- .../[attachmentId]/download/route.ts | 83 ++++++++++++++++ 3 files changed, 223 insertions(+), 2 deletions(-) create mode 100644 src/app/api/projects/[projectId]/tickets/[ticketId]/attachments/[attachmentId]/download/route.ts diff --git a/mcp/src/api-client.ts b/mcp/src/api-client.ts index 2fe965c6..85198bf3 100644 --- a/mcp/src/api-client.ts +++ b/mcp/src/api-client.ts @@ -759,3 +759,51 @@ export async function deleteAttachment(projectKey: string, ticketId: string, att `/api/projects/${projectKey}/tickets/${ticketId}/attachments/${attachmentId}`, ) } + +export async function downloadAttachment( + projectKey: string, + ticketId: string, + attachmentId: string, +): Promise> { + const apiKey = resolveApiKey() + if (!apiKey) { + const credPath = getCredentialsFilePath() + return { + error: + 'MCP credentials not configured. Either:\n' + + `1. Create credentials file at ${credPath}\n` + + '2. Set PUNT_API_KEY and PUNT_API_URL environment variables\n' + + 'See: https://github.com/your-org/punt#mcp-server for setup instructions', + } + } + + const baseUrl = resolveApiUrl() + const url = `${baseUrl}/api/projects/${projectKey}/tickets/${ticketId}/attachments/${attachmentId}/download` + + try { + const response = await fetch(url, { + method: 'GET', + headers: { + 'X-MCP-API-Key': apiKey, + }, + }) + + if (!response.ok) { + const text = await response.text() + let errorMessage: string + try { + const errorJson = JSON.parse(text) + errorMessage = errorJson.error || errorJson.message || text + } catch { + errorMessage = text || `HTTP ${response.status}` + } + return { error: errorMessage } + } + + const data = await response.arrayBuffer() + return { data } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + return { error: `API request failed: ${message}` } + } +} diff --git a/mcp/src/tools/attachments.ts b/mcp/src/tools/attachments.ts index 91e2d738..9920aba9 100644 --- a/mcp/src/tools/attachments.ts +++ b/mcp/src/tools/attachments.ts @@ -1,9 +1,10 @@ -import { readFile, stat } from 'node:fs/promises' -import { basename, extname } from 'node:path' +import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' +import { basename, dirname, extname, resolve } from 'node:path' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { z } from 'zod' import { deleteAttachment, + downloadAttachment, linkAttachments, listAttachments, listTickets, @@ -247,4 +248,93 @@ export function registerAttachmentTools(server: McpServer) { return textResponse(`Removed attachment **${displayName}** from **${ticketKey}**`) }, ) + + server.tool( + 'download_attachment', + 'Download an attachment from a ticket to a local file path', + { + key: z.string().describe('Ticket key (e.g., PUNT-42)'), + attachmentId: z + .string() + .optional() + .describe('Attachment ID to download (from list_attachments)'), + filename: z + .string() + .optional() + .describe('Filename to search for (if attachmentId not provided)'), + outputPath: z.string().describe('Absolute path where the file should be saved'), + }, + async ({ key, attachmentId, filename, outputPath }) => { + const parsed = parseTicketKey(key) + if (!parsed) { + return errorResponse(`Invalid ticket key format: ${key}. Expected format: PROJECT-123`) + } + + if (!attachmentId && !filename) { + return errorResponse('Either attachmentId or filename must be provided') + } + + // Resolve ticket ID + const ticketsResult = await listTickets(parsed.projectKey) + if (ticketsResult.error) { + return errorResponse(ticketsResult.error) + } + + const ticket = ticketsResult.data?.find((t) => t.number === parsed.number) + if (!ticket) { + return errorResponse(`Ticket not found: ${key}`) + } + + // Find the attachment (always need metadata for the response) + const listResult = await listAttachments(parsed.projectKey, ticket.id) + if (listResult.error) { + return errorResponse(listResult.error) + } + + const attachments = listResult.data ?? [] + let targetAttachment: (typeof attachments)[number] | undefined + + if (attachmentId) { + targetAttachment = attachments.find((a) => a.id === attachmentId) + if (!targetAttachment) { + return errorResponse(`Attachment not found with ID: ${attachmentId}`) + } + } else { + const searchTerm = filename?.toLowerCase() ?? '' + targetAttachment = attachments.find((a) => a.filename.toLowerCase().includes(searchTerm)) + if (!targetAttachment) { + return errorResponse(`No attachment found matching filename: ${filename}`) + } + } + + // Download the file content + const downloadResult = await downloadAttachment( + parsed.projectKey, + ticket.id, + targetAttachment.id, + ) + if (downloadResult.error) { + return errorResponse(`Download failed: ${downloadResult.error}`) + } + + if (!downloadResult.data) { + return errorResponse('Download succeeded but no data was returned') + } + + // Write to output path + const resolvedPath = resolve(outputPath) + try { + await mkdir(dirname(resolvedPath), { recursive: true }) + await writeFile(resolvedPath, Buffer.from(downloadResult.data)) + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error' + return errorResponse(`Failed to write file: ${message}`) + } + + const ticketKey = `${parsed.projectKey.toUpperCase()}-${parsed.number}` + return textResponse( + `Downloaded **${escapeMarkdown(targetAttachment.filename)}** (${formatFileSize(targetAttachment.size)}) from **${ticketKey}** to \`${escapeMarkdown(resolvedPath)}\``, + ) + }, + ) } diff --git a/src/app/api/projects/[projectId]/tickets/[ticketId]/attachments/[attachmentId]/download/route.ts b/src/app/api/projects/[projectId]/tickets/[ticketId]/attachments/[attachmentId]/download/route.ts new file mode 100644 index 00000000..e81dd1b0 --- /dev/null +++ b/src/app/api/projects/[projectId]/tickets/[ticketId]/attachments/[attachmentId]/download/route.ts @@ -0,0 +1,83 @@ +import { createReadStream } from 'node:fs' +import { stat } from 'node:fs/promises' +import { join } from 'node:path' +import { handleApiError, notFoundError } from '@/lib/api-utils' +import { requireAuth, requireMembership, requireProjectByKey } from '@/lib/auth-helpers' +import { db } from '@/lib/db' + +/** + * GET /api/projects/[projectId]/tickets/[ticketId]/attachments/[attachmentId]/download + * Stream the attachment file content. + * Requires project membership. + */ +export async function GET( + _request: Request, + { params }: { params: Promise<{ projectId: string; ticketId: string; attachmentId: string }> }, +) { + try { + const user = await requireAuth() + const { projectId: projectKey, ticketId, attachmentId } = await params + const projectId = await requireProjectByKey(projectKey) + + await requireMembership(user.id, projectId) + + // Verify ticket exists and belongs to project + const ticket = await db.ticket.findFirst({ + where: { id: ticketId, projectId }, + select: { id: true }, + }) + + if (!ticket) { + return notFoundError('Ticket') + } + + // Find the attachment + const attachment = await db.attachment.findFirst({ + where: { id: attachmentId, ticketId }, + select: { id: true, filename: true, mimeType: true, size: true, url: true }, + }) + + if (!attachment) { + return notFoundError('Attachment') + } + + // Resolve file path from URL (e.g., "/uploads/file.png" → "public/uploads/file.png") + const filePath = join(process.cwd(), 'public', attachment.url) + + // Verify file exists on disk + try { + await stat(filePath) + } catch { + return notFoundError('Attachment file') + } + + // Stream the file + const stream = createReadStream(filePath) + const readableStream = new ReadableStream({ + start(controller) { + stream.on('data', (chunk: Buffer) => { + controller.enqueue(new Uint8Array(chunk)) + }) + stream.on('end', () => { + controller.close() + }) + stream.on('error', (err) => { + controller.error(err) + }) + }, + cancel() { + stream.destroy() + }, + }) + + return new Response(readableStream, { + headers: { + 'Content-Type': attachment.mimeType, + 'Content-Length': String(attachment.size), + 'Content-Disposition': `attachment; filename="${attachment.filename}"`, + }, + }) + } catch (error) { + return handleApiError(error, 'download attachment') + } +} From cb2e9b7969daba73963d87b465b016506630ab35 Mon Sep 17 00:00:00 2001 From: Some sucker that doesnt know how to be private Date: Sat, 28 Feb 2026 15:54:44 -0600 Subject: [PATCH 6/6] feat(attachments): add purpose, sourceCommit, and commitDirtyStatus metadata Add semantic metadata fields to attachments so AI agents can tag what an attachment represents (plan, transcript, screenshot, etc.) and which codebase version was being referenced. - Add AttachmentPurpose enum and three nullable fields to Prisma schema - Update POST/PATCH API routes, export schema, and MCP tools - Add update_attachment MCP tool for editing metadata on existing attachments - Display purpose badges and commit hashes in attachment list UI Co-Authored-By: Claude Opus 4.6 --- mcp/src/api-client.ts | 23 ++++ mcp/src/tools/attachments.ts | 105 +++++++++++++++++- mcp/src/utils.ts | 13 ++- prisma/schema.prisma | 23 +++- .../attachments/[attachmentId]/route.ts | 80 ++++++++++++- .../tickets/[ticketId]/attachments/route.ts | 14 +++ src/components/tickets/attachment-list.tsx | 69 ++++++++++-- src/components/tickets/file-upload.tsx | 3 + .../tickets/ticket-detail-drawer.tsx | 9 ++ src/hooks/queries/use-attachments.ts | 3 + src/lib/prisma-selects.ts | 3 + src/lib/schemas/database-export.ts | 6 + src/stores/undo-store.ts | 3 + src/types/index.ts | 6 + 14 files changed, 338 insertions(+), 22 deletions(-) diff --git a/mcp/src/api-client.ts b/mcp/src/api-client.ts index 85198bf3..08c39071 100644 --- a/mcp/src/api-client.ts +++ b/mcp/src/api-client.ts @@ -715,6 +715,9 @@ export interface AttachmentData { mimeType: string size: number url: string + purpose: string | null + sourceCommit: string | null + commitDirtyStatus: string | null createdAt: string ticketId: string uploaderId: string | null @@ -738,6 +741,9 @@ export interface LinkAttachmentsInput { mimeType: string size: number url: string + purpose?: string | null + sourceCommit?: string | null + commitDirtyStatus?: string | null }> } @@ -760,6 +766,23 @@ export async function deleteAttachment(projectKey: string, ticketId: string, att ) } +export async function updateAttachment( + projectKey: string, + ticketId: string, + attachmentId: string, + data: { + purpose?: string | null + sourceCommit?: string | null + commitDirtyStatus?: string | null + }, +) { + return apiRequest( + 'PATCH', + `/api/projects/${projectKey}/tickets/${ticketId}/attachments/${attachmentId}`, + data, + ) +} + export async function downloadAttachment( projectKey: string, ticketId: string, diff --git a/mcp/src/tools/attachments.ts b/mcp/src/tools/attachments.ts index 9920aba9..74abe32d 100644 --- a/mcp/src/tools/attachments.ts +++ b/mcp/src/tools/attachments.ts @@ -8,6 +8,7 @@ import { linkAttachments, listAttachments, listTickets, + updateAttachment, uploadFiles, } from '../api-client.js' import { @@ -55,6 +56,14 @@ function detectMimeType(filePath: string): string | null { return EXTENSION_TO_MIME[ext] ?? null } +const ATTACHMENT_PURPOSE_ENUM = z.enum([ + 'plan', + 'session_transcript', + 'screenshot', + 'reference', + 'other', +]) + export function registerAttachmentTools(server: McpServer) { server.tool( 'add_attachment', @@ -62,8 +71,19 @@ export function registerAttachmentTools(server: McpServer) { { key: z.string().describe('Ticket key (e.g., PUNT-42)'), filePath: z.string().describe('Absolute path to the local file to upload'), + purpose: ATTACHMENT_PURPOSE_ENUM.optional().describe( + 'Purpose of this attachment (plan, session_transcript, screenshot, reference, other)', + ), + sourceCommit: z + .string() + .optional() + .describe('Git commit hash the client had checked out when creating this attachment'), + commitDirtyStatus: z + .string() + .optional() + .describe('Output of git status if the working tree was dirty (null/omit if clean)'), }, - async ({ key, filePath }) => { + async ({ key, filePath, purpose, sourceCommit, commitDirtyStatus }) => { const parsed = parseTicketKey(key) if (!parsed) { return errorResponse(`Invalid ticket key format: ${key}. Expected format: PROJECT-123`) @@ -133,6 +153,9 @@ export function registerAttachmentTools(server: McpServer) { mimeType: uploaded.mimetype, size: uploaded.size, url: uploaded.url, + purpose: purpose ?? null, + sourceCommit: sourceCommit ?? null, + commitDirtyStatus: commitDirtyStatus ?? null, }, ], }) @@ -142,9 +165,15 @@ export function registerAttachmentTools(server: McpServer) { } const ticketKey = `${parsed.projectKey.toUpperCase()}-${parsed.number}` - return textResponse( + const parts = [ `Attached **${escapeMarkdown(fileName)}** (${formatFileSize(fileSize)}, ${mimeType}) to **${ticketKey}**`, - ) + ] + if (purpose) parts.push(`Purpose: ${purpose}`) + if (sourceCommit) { + const shortHash = sourceCommit.slice(0, 7) + parts.push(`Commit: ${shortHash}${commitDirtyStatus ? ' (dirty)' : ''}`) + } + return textResponse(parts.join('\n')) }, ) @@ -337,4 +366,74 @@ export function registerAttachmentTools(server: McpServer) { ) }, ) + + server.tool( + 'update_attachment', + 'Update the purpose or source commit metadata of an attachment on a ticket', + { + key: z.string().describe('Ticket key (e.g., PUNT-42)'), + attachmentId: z.string().describe('Attachment ID (from list_attachments)'), + purpose: ATTACHMENT_PURPOSE_ENUM.nullable() + .optional() + .describe('New purpose (null to clear)'), + sourceCommit: z.string().nullable().optional().describe('Git commit hash (null to clear)'), + commitDirtyStatus: z + .string() + .nullable() + .optional() + .describe('Git dirty status output (null to clear)'), + }, + async ({ key, attachmentId, purpose, sourceCommit, commitDirtyStatus }) => { + const parsed = parseTicketKey(key) + if (!parsed) { + return errorResponse(`Invalid ticket key format: ${key}. Expected format: PROJECT-123`) + } + + // Resolve ticket ID + const ticketsResult = await listTickets(parsed.projectKey) + if (ticketsResult.error) { + return errorResponse(ticketsResult.error) + } + + const ticket = ticketsResult.data?.find((t) => t.number === parsed.number) + if (!ticket) { + return errorResponse(`Ticket not found: ${key}`) + } + + const data: Record = {} + if (purpose !== undefined) data.purpose = purpose + if (sourceCommit !== undefined) data.sourceCommit = sourceCommit + if (commitDirtyStatus !== undefined) data.commitDirtyStatus = commitDirtyStatus + + if (Object.keys(data).length === 0) { + return errorResponse( + 'No fields to update. Provide purpose, sourceCommit, or commitDirtyStatus.', + ) + } + + const result = await updateAttachment( + parsed.projectKey, + ticket.id, + attachmentId, + data as { + purpose?: string | null + sourceCommit?: string | null + commitDirtyStatus?: string | null + }, + ) + if (result.error) { + return errorResponse(result.error) + } + + const ticketKey = `${parsed.projectKey.toUpperCase()}-${parsed.number}` + const updated = result.data + const parts = [`Updated attachment on **${ticketKey}**`] + if (updated?.purpose) parts.push(`Purpose: ${updated.purpose}`) + if (updated?.sourceCommit) { + const shortHash = updated.sourceCommit.slice(0, 7) + parts.push(`Commit: ${shortHash}${updated.commitDirtyStatus ? ' (dirty)' : ''}`) + } + return textResponse(parts.join('\n')) + }, + ) } diff --git a/mcp/src/utils.ts b/mcp/src/utils.ts index 0489227d..11a279bd 100644 --- a/mcp/src/utils.ts +++ b/mcp/src/utils.ts @@ -397,6 +397,9 @@ export function formatAttachmentList( filename: string mimeType: string size: number + purpose?: string | null + sourceCommit?: string | null + commitDirtyStatus?: string | null createdAt: string }>, ticketKey: string, @@ -408,15 +411,19 @@ export function formatAttachmentList( const lines: string[] = [] lines.push(`## Attachments on ${ticketKey}`) lines.push('') - lines.push('| ID | Filename | Type | Size | Uploaded |') - lines.push('|----|----------|------|------|----------|') + lines.push('| ID | Filename | Type | Size | Purpose | Commit | Uploaded |') + lines.push('|----|----------|------|------|---------|--------|----------|') for (const att of attachments) { const filename = safeTableCell(att.filename, 40) const size = formatFileSize(att.size) + const purpose = att.purpose ?? '-' + const commit = att.sourceCommit + ? `${att.sourceCommit.slice(0, 7)}${att.commitDirtyStatus ? '\\*' : ''}` + : '-' const uploaded = formatDate(att.createdAt) lines.push( - `| ${att.id} | ${filename} | ${escapeTableCell(att.mimeType)} | ${size} | ${uploaded} |`, + `| ${att.id} | ${filename} | ${escapeTableCell(att.mimeType)} | ${size} | ${purpose} | ${commit} | ${uploaded} |`, ) } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bf3fe6de..c3875a9c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -69,6 +69,14 @@ enum LinkType { is_duplicated_by } +enum AttachmentPurpose { + plan + session_transcript + screenshot + reference + other +} + // ============================================================================ // Models // ============================================================================ @@ -528,12 +536,15 @@ model Comment { } model Attachment { - id String @id @default(cuid()) - filename String - mimeType String - size Int // File size in bytes - url String // Storage path or URL - createdAt DateTime @default(now()) + id String @id @default(cuid()) + filename String + mimeType String + size Int // File size in bytes + url String // Storage path or URL + purpose AttachmentPurpose? + sourceCommit String? // Git commit hash at time of attachment + commitDirtyStatus String? // null if clean; git status output if dirty + createdAt DateTime @default(now()) ticketId String ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade) diff --git a/src/app/api/projects/[projectId]/tickets/[ticketId]/attachments/[attachmentId]/route.ts b/src/app/api/projects/[projectId]/tickets/[ticketId]/attachments/[attachmentId]/route.ts index ddef420c..ed582c03 100644 --- a/src/app/api/projects/[projectId]/tickets/[ticketId]/attachments/[attachmentId]/route.ts +++ b/src/app/api/projects/[projectId]/tickets/[ticketId]/attachments/[attachmentId]/route.ts @@ -1,9 +1,87 @@ import { NextResponse } from 'next/server' -import { handleApiError, notFoundError } from '@/lib/api-utils' +import { z } from 'zod' +import { handleApiError, notFoundError, validationError } from '@/lib/api-utils' import { requireAttachmentPermission, requireAuth, requireProjectByKey } from '@/lib/auth-helpers' import { db } from '@/lib/db' import { projectEvents } from '@/lib/events' +const updateAttachmentSchema = z.object({ + purpose: z + .enum(['plan', 'session_transcript', 'screenshot', 'reference', 'other']) + .nullable() + .optional(), + sourceCommit: z.string().max(40).nullable().optional(), + commitDirtyStatus: z.string().nullable().optional(), +}) + +/** + * PATCH /api/projects/[projectId]/tickets/[ticketId]/attachments/[attachmentId] + * Update attachment metadata (purpose, sourceCommit, commitDirtyStatus). + * Requires ownership or attachments.manage_any permission. + */ +export async function PATCH( + request: Request, + { params }: { params: Promise<{ projectId: string; ticketId: string; attachmentId: string }> }, +) { + try { + const user = await requireAuth() + const { projectId: projectKey, ticketId, attachmentId } = await params + const projectId = await requireProjectByKey(projectKey) + + const ticket = await db.ticket.findFirst({ + where: { id: ticketId, projectId }, + select: { id: true }, + }) + + if (!ticket) { + return notFoundError('Ticket') + } + + const attachment = await db.attachment.findFirst({ + where: { id: attachmentId, ticketId }, + select: { id: true, uploaderId: true }, + }) + + if (!attachment) { + return notFoundError('Attachment') + } + + await requireAttachmentPermission(user.id, projectId, attachment.uploaderId, 'delete') + + const body = await request.json() + const parsed = updateAttachmentSchema.safeParse(body) + + if (!parsed.success) { + return validationError(parsed) + } + + const data: Record = {} + if (parsed.data.purpose !== undefined) data.purpose = parsed.data.purpose + if (parsed.data.sourceCommit !== undefined) data.sourceCommit = parsed.data.sourceCommit + if (parsed.data.commitDirtyStatus !== undefined) + data.commitDirtyStatus = parsed.data.commitDirtyStatus + + const updated = await db.attachment.update({ + where: { id: attachmentId }, + data, + }) + + const tabId = request.headers.get('X-Tab-Id') || undefined + projectEvents.emitTicketEvent({ + type: 'ticket.updated', + projectId, + ticketId, + userId: user.id, + tabId, + timestamp: Date.now(), + }) + + return NextResponse.json(updated) + } catch (error) { + return handleApiError(error, 'update attachment') + } +} + /** * DELETE /api/projects/[projectId]/tickets/[ticketId]/attachments/[attachmentId] * Remove an attachment record from the database. diff --git a/src/app/api/projects/[projectId]/tickets/[ticketId]/attachments/route.ts b/src/app/api/projects/[projectId]/tickets/[ticketId]/attachments/route.ts index d90b6d74..5e3b22b1 100644 --- a/src/app/api/projects/[projectId]/tickets/[ticketId]/attachments/route.ts +++ b/src/app/api/projects/[projectId]/tickets/[ticketId]/attachments/route.ts @@ -6,6 +6,14 @@ import { db } from '@/lib/db' import { projectEvents } from '@/lib/events' import { getSystemSettings } from '@/lib/system-settings' +const attachmentPurposeEnum = z.enum([ + 'plan', + 'session_transcript', + 'screenshot', + 'reference', + 'other', +]) + const addAttachmentsSchema = z.object({ attachments: z.array( z.object({ @@ -14,6 +22,9 @@ const addAttachmentsSchema = z.object({ mimeType: z.string().min(1), size: z.number().int().positive(), url: z.string().min(1), + purpose: attachmentPurposeEnum.nullish(), + sourceCommit: z.string().max(40).nullish(), + commitDirtyStatus: z.string().nullish(), }), ), }) @@ -113,6 +124,9 @@ export async function POST( mimeType: attachment.mimeType, size: attachment.size, url: attachment.url, + purpose: attachment.purpose ?? undefined, + sourceCommit: attachment.sourceCommit ?? undefined, + commitDirtyStatus: attachment.commitDirtyStatus ?? undefined, uploaderId: user.id, })), }) diff --git a/src/components/tickets/attachment-list.tsx b/src/components/tickets/attachment-list.tsx index 5ce65c93..5a05551d 100644 --- a/src/components/tickets/attachment-list.tsx +++ b/src/components/tickets/attachment-list.tsx @@ -57,6 +57,23 @@ function getFileIcon(category: 'image' | 'video' | 'document') { } } +const PURPOSE_LABELS: Record = { + plan: 'Plan', + session_transcript: 'Transcript', + screenshot: 'Screenshot', + reference: 'Reference', + other: 'Other', +} + +function formatPurpose(purpose: string): string { + return PURPOSE_LABELS[purpose] ?? purpose +} + +function formatCommitBadge(sourceCommit: string, commitDirtyStatus?: string | null): string { + const shortHash = sourceCommit.slice(0, 7) + return commitDirtyStatus ? `${shortHash}*` : shortHash +} + // Check if a file can be previewed in the modal function canPreview(file: UploadedFile): boolean { return ( @@ -204,13 +221,30 @@ export function AttachmentList({
-

- {file.originalName} -

-

{formatFileSize(file.size)}

+
+

+ {file.originalName} +

+ {file.purpose && ( + + {formatPurpose(file.purpose)} + + )} +
+
+

{formatFileSize(file.size)}

+ {file.sourceCommit && ( + + {formatCommitBadge(file.sourceCommit, file.commitDirtyStatus)} + + )} +
@@ -320,8 +354,25 @@ export function AttachmentList({ {/* File info */}
-

{file.originalName}

-

{formatFileSize(file.size)}

+
+

{file.originalName}

+ {file.purpose && ( + + {formatPurpose(file.purpose)} + + )} +
+
+

{formatFileSize(file.size)}

+ {file.sourceCommit && ( + + {formatCommitBadge(file.sourceCommit, file.commitDirtyStatus)} + + )} +
{/* Actions */} diff --git a/src/components/tickets/file-upload.tsx b/src/components/tickets/file-upload.tsx index dea1ae12..ddd521ca 100644 --- a/src/components/tickets/file-upload.tsx +++ b/src/components/tickets/file-upload.tsx @@ -14,6 +14,9 @@ export interface UploadedFile { size: number url: string category: 'image' | 'video' | 'document' + purpose?: string | null + sourceCommit?: string | null + commitDirtyStatus?: string | null } interface FileUploadProps { diff --git a/src/components/tickets/ticket-detail-drawer.tsx b/src/components/tickets/ticket-detail-drawer.tsx index af0fc75c..cd6c9a5b 100644 --- a/src/components/tickets/ticket-detail-drawer.tsx +++ b/src/components/tickets/ticket-detail-drawer.tsx @@ -333,6 +333,9 @@ export function TicketDetailDrawer({ ticket, projectKey, onClose }: TicketDetail size: a.size, url: a.url, category: getMimeTypeCategory(a.mimeType), + purpose: a.purpose ?? null, + sourceCommit: a.sourceCommit ?? null, + commitDirtyStatus: a.commitDirtyStatus ?? null, })), ) // Reset attachments collapsed state based on preference @@ -1544,6 +1547,9 @@ export function TicketDetailDrawer({ ticket, projectKey, onClose }: TicketDetail size: a.size, url: a.url, category: getMimeTypeCategory(a.mimetype), + purpose: a.purpose ?? null, + sourceCommit: a.sourceCommit ?? null, + commitDirtyStatus: a.commitDirtyStatus ?? null, }))} onRemove={(fileId) => { const removed = tempAttachments.find((a) => a.id === fileId) @@ -1631,6 +1637,9 @@ export function TicketDetailDrawer({ ticket, projectKey, onClose }: TicketDetail size: a.size, url: a.url, category: getMimeTypeCategory(a.mimeType), + purpose: a.purpose ?? null, + sourceCommit: a.sourceCommit ?? null, + commitDirtyStatus: a.commitDirtyStatus ?? null, })) // Update local state with correct server IDs setTempAttachments((prev) => { diff --git a/src/hooks/queries/use-attachments.ts b/src/hooks/queries/use-attachments.ts index 1a45df85..9038d9d6 100644 --- a/src/hooks/queries/use-attachments.ts +++ b/src/hooks/queries/use-attachments.ts @@ -62,6 +62,9 @@ export interface AddAttachmentParams { mimeType: string size: number url: string + purpose?: string | null + sourceCommit?: string | null + commitDirtyStatus?: string | null } /** diff --git a/src/lib/prisma-selects.ts b/src/lib/prisma-selects.ts index 906ef40c..b8f74db1 100644 --- a/src/lib/prisma-selects.ts +++ b/src/lib/prisma-selects.ts @@ -68,6 +68,9 @@ export const ATTACHMENT_SELECT = { mimeType: true, size: true, url: true, + purpose: true, + sourceCommit: true, + commitDirtyStatus: true, createdAt: true, } as const diff --git a/src/lib/schemas/database-export.ts b/src/lib/schemas/database-export.ts index eab4a464..a1cddd4e 100644 --- a/src/lib/schemas/database-export.ts +++ b/src/lib/schemas/database-export.ts @@ -249,6 +249,12 @@ export const AttachmentSchema = z.object({ mimeType: z.string(), size: z.number(), url: z.string(), + purpose: z + .enum(['plan', 'session_transcript', 'screenshot', 'reference', 'other']) + .nullable() + .optional(), + sourceCommit: z.string().nullable().optional(), + commitDirtyStatus: z.string().nullable().optional(), createdAt: z.string().datetime(), ticketId: z.string(), uploaderId: z.string().nullable(), diff --git a/src/stores/undo-store.ts b/src/stores/undo-store.ts index 0bcba6be..db9f70cb 100644 --- a/src/stores/undo-store.ts +++ b/src/stores/undo-store.ts @@ -38,6 +38,9 @@ interface AttachmentInfo { mimetype: string size: number url: string + purpose?: string | null + sourceCommit?: string | null + commitDirtyStatus?: string | null } interface AttachmentAction { diff --git a/src/types/index.ts b/src/types/index.ts index 10795263..4c3dcfd6 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -202,6 +202,9 @@ export interface AttachmentInfo { mimeType: string size: number url: string + purpose: string | null + sourceCommit: string | null + commitDirtyStatus: string | null createdAt: Date } @@ -306,6 +309,9 @@ export interface UploadedFileInfo { size: number url: string category: 'image' | 'video' | 'document' + purpose?: string | null + sourceCommit?: string | null + commitDirtyStatus?: string | null } // Form data for creating/editing tickets