Skip to content
Open
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
2 changes: 1 addition & 1 deletion docs-site/docs/api-reference/admin.md
Original file line number Diff line number Diff line change
Expand Up @@ -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", "application/jsonl", "text/markdown"]
}
```

Expand Down
2 changes: 1 addition & 1 deletion docs-site/docs/api-reference/upload.md
Original file line number Diff line number Diff line change
Expand Up @@ -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", "application/jsonl", "text/markdown"]
}
}
```
Expand Down
3 changes: 3 additions & 0 deletions docs-site/docs/user-guide/admin.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ 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`)
- JSONL (`application/jsonl`)
- Markdown (`text/markdown`)

:::note
SVG files are blocked for security reasons (potential XSS via embedded scripts).
Expand Down
199 changes: 199 additions & 0 deletions mcp/src/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -631,3 +631,202 @@ export async function searchTickets(projectKey: string, query: string) {
export async function getRepositoryConfig(projectKey: string) {
return apiRequest<RepositoryConfigData>('GET', `/api/projects/${projectKey}/repository`)
}

// ============================================================================
// Upload API (multipart/form-data)
// ============================================================================

/**
* Make an authenticated multipart/form-data request to the PUNT API
*/
async function apiUploadRequest<T>(path: string, formData: FormData): Promise<ApiResponse<T>> {
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
purpose: string | null
sourceCommit: string | null
commitDirtyStatus: string | null
createdAt: string
ticketId: string
uploaderId: string | null
}

export async function uploadFiles(formData: FormData) {
return apiUploadRequest<UploadResponse>('/api/upload', formData)
}

export async function listAttachments(projectKey: string, ticketId: string) {
return apiRequest<AttachmentData[]>(
'GET',
`/api/projects/${projectKey}/tickets/${ticketId}/attachments`,
)
}

export interface LinkAttachmentsInput {
attachments: Array<{
filename: string
originalName: string
mimeType: string
size: number
url: string
purpose?: string | null
sourceCommit?: string | null
commitDirtyStatus?: string | null
}>
}

export async function linkAttachments(
projectKey: string,
ticketId: string,
data: LinkAttachmentsInput,
) {
return apiRequest<AttachmentData[]>(
'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}`,
)
}

export async function updateAttachment(
projectKey: string,
ticketId: string,
attachmentId: string,
data: {
purpose?: string | null
sourceCommit?: string | null
commitDirtyStatus?: string | null
},
) {
return apiRequest<AttachmentData>(
'PATCH',
`/api/projects/${projectKey}/tickets/${ticketId}/attachments/${attachmentId}`,
data,
)
}

export async function downloadAttachment(
projectKey: string,
ticketId: string,
attachmentId: string,
): Promise<ApiResponse<ArrayBuffer>> {
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}` }
}
}
2 changes: 2 additions & 0 deletions mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -25,6 +26,7 @@ registerMemberTools(server)
registerLabelTools(server)
registerColumnTools(server)
registerRepositoryTools(server)
registerAttachmentTools(server)

// Connect via stdio
const transport = new StdioServerTransport()
Expand Down
Loading
Loading