feat: project files MCP tools — list, get, download, upload, update description, delete

This commit is contained in:
Markov 2026-02-25 10:40:40 +01:00
parent 5e60e34c3f
commit e4e604b049
3 changed files with 158 additions and 8 deletions

112
src/tools/files.ts Normal file
View File

@ -0,0 +1,112 @@
import { Type } from '@sinclair/typebox';
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
import type { ToolContext } from './types.js';
const ListFilesParams = Type.Object({
project_slug: Type.String({ description: 'Project slug' }),
search: Type.Optional(Type.String({ description: 'Search by filename' })),
});
const GetFileParams = Type.Object({
project_slug: Type.String({ description: 'Project slug' }),
file_id: Type.String({ description: 'File UUID' }),
});
const DownloadFileParams = Type.Object({
project_slug: Type.String({ description: 'Project slug' }),
file_id: Type.String({ description: 'File UUID' }),
});
const UploadFileParams = Type.Object({
project_slug: Type.String({ description: 'Project slug' }),
filename: Type.String({ description: 'File name (e.g. "spec.md")' }),
content: Type.String({ description: 'File content as base64 string' }),
description: Type.Optional(Type.String({ description: 'File description' })),
});
const UpdateFileParams = Type.Object({
project_slug: Type.String({ description: 'Project slug' }),
file_id: Type.String({ description: 'File UUID' }),
description: Type.String({ description: 'New description' }),
});
const DeleteFileParams = Type.Object({
project_slug: Type.String({ description: 'Project slug' }),
file_id: Type.String({ description: 'File UUID' }),
});
function ok(text: string) {
return { content: [{ type: 'text' as const, text }], details: {} };
}
export function createFileTools(ctx: ToolContext): ToolDefinition<any>[] {
return [
{
name: 'list_project_files',
label: 'List Project Files',
description: 'List files in a project. Optionally search by filename.',
parameters: ListFilesParams,
async execute(_id: string, params: any) {
const files = await ctx.trackerClient.listProjectFiles(params.project_slug, params.search);
if (files.length === 0) return ok('No files found.');
const list = files.map((f: any) => `- ${f.filename} (${f.size} bytes) ${f.description ? `${f.description}` : ''} [id: ${f.id}]`).join('\n');
return ok(`${files.length} file(s):\n${list}`);
},
},
{
name: 'get_project_file',
label: 'Get Project File Info',
description: 'Get metadata of a project file by ID.',
parameters: GetFileParams,
async execute(_id: string, params: any) {
const file = await ctx.trackerClient.getProjectFile(params.project_slug, params.file_id);
return ok(JSON.stringify(file, null, 2));
},
},
{
name: 'download_project_file',
label: 'Download Project File',
description: 'Download the content of a project file (returns text content).',
parameters: DownloadFileParams,
async execute(_id: string, params: any) {
const content = await ctx.trackerClient.downloadProjectFile(params.project_slug, params.file_id);
return ok(content);
},
},
{
name: 'upload_project_file',
label: 'Upload Project File',
description: 'Upload a file to project. Content must be base64 encoded. If file with same name exists, it will be updated.',
parameters: UploadFileParams,
async execute(_id: string, params: any) {
const result = await ctx.trackerClient.uploadProjectFile(
params.project_slug,
params.filename,
params.content,
params.description,
);
return ok(`Uploaded: ${(result as any).filename} (${(result as any).size} bytes)`);
},
},
{
name: 'update_file_description',
label: 'Update File Description',
description: 'Update the description of a project file.',
parameters: UpdateFileParams,
async execute(_id: string, params: any) {
await ctx.trackerClient.updateProjectFile(params.project_slug, params.file_id, { description: params.description });
return ok('Description updated.');
},
},
{
name: 'delete_project_file',
label: 'Delete Project File',
description: 'Delete a project file from disk and database.',
parameters: DeleteFileParams,
async execute(_id: string, params: any) {
await ctx.trackerClient.deleteProjectFile(params.project_slug, params.file_id);
return ok('File deleted.');
},
},
];
}

View File

@ -5,6 +5,7 @@ import { createStepTools } from './steps.js';
import { createMessageTools } from './messages.js'; import { createMessageTools } from './messages.js';
import { createProjectTools } from './projects.js'; import { createProjectTools } from './projects.js';
import { createMemberTools } from './members.js'; import { createMemberTools } from './members.js';
import { createFileTools } from './files.js';
/** /**
* Create all Team Board tracker tools for the agent. * Create all Team Board tracker tools for the agent.
@ -17,6 +18,7 @@ export function createTrackerTools(ctx: ToolContext): ToolDefinition[] {
...createMessageTools(ctx), ...createMessageTools(ctx),
...createProjectTools(ctx), ...createProjectTools(ctx),
...createMemberTools(ctx), ...createMemberTools(ctx),
...createFileTools(ctx),
]; ];
} }

View File

@ -111,15 +111,51 @@ export class TrackerClient {
return this.request('GET', `/api/v1/messages?${qs}`); return this.request('GET', `/api/v1/messages?${qs}`);
} }
// --- Files (Attachments) --- // --- Project Files ---
// Files are attachments on Messages. To attach a file to a task,
// send a message with task_id + attachment.
// Upload endpoint: POST /api/v1/messages/{id}/attachments (TODO in Tracker)
// Download endpoint: GET /api/v1/attachments/{id} (TODO in Tracker)
// async uploadAttachment(messageId: string, filename: string, data: Buffer): Promise<Record<string, unknown>> { async listProjectFiles(projectSlug: string, search?: string): Promise<Record<string, unknown>[]> {
// return this.request('POST', `/api/v1/messages/${messageId}/attachments`, ...); const qs = search ? `?search=${encodeURIComponent(search)}` : '';
// } return this.request('GET', `/api/v1/projects/${projectSlug}/files${qs}`);
}
async getProjectFile(projectSlug: string, fileId: string): Promise<Record<string, unknown>> {
return this.request('GET', `/api/v1/projects/${projectSlug}/files/${fileId}`);
}
async downloadProjectFile(projectSlug: string, fileId: string): Promise<string> {
const url = `${this.baseUrl}/api/v1/projects/${projectSlug}/files/${fileId}/download`;
const res = await fetch(url, {
headers: { 'Authorization': `Bearer ${this.token}` },
});
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
return res.text();
}
async uploadProjectFile(projectSlug: string, filename: string, content: string, description?: string): Promise<Record<string, unknown>> {
const url = `${this.baseUrl}/api/v1/projects/${projectSlug}/files`;
const formData = new FormData();
formData.append('file', new Blob([Buffer.from(content, 'base64')]), filename);
if (description) formData.append('description', description);
const res = await fetch(url, {
method: 'POST',
headers: { 'Authorization': `Bearer ${this.token}` },
body: formData,
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Upload failed: ${res.status} ${text}`);
}
return res.json();
}
async updateProjectFile(projectSlug: string, fileId: string, data: { description?: string }): Promise<Record<string, unknown>> {
return this.request('PATCH', `/api/v1/projects/${projectSlug}/files/${fileId}`, data);
}
async deleteProjectFile(projectSlug: string, fileId: string): Promise<void> {
await this.request('DELETE', `/api/v1/projects/${projectSlug}/files/${fileId}`);
}
// --- Projects --- // --- Projects ---