From e4e604b0491727e63b148e16e11543af1cd3e9fe Mon Sep 17 00:00:00 2001 From: Markov Date: Wed, 25 Feb 2026 10:40:40 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20project=20files=20MCP=20tools=20?= =?UTF-8?q?=E2=80=94=20list,=20get,=20download,=20upload,=20update=20descr?= =?UTF-8?q?iption,=20delete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/tools/files.ts | 112 ++++++++++++++++++++++++++++++++++++++++++ src/tools/index.ts | 2 + src/tracker/client.ts | 52 +++++++++++++++++--- 3 files changed, 158 insertions(+), 8 deletions(-) create mode 100644 src/tools/files.ts diff --git a/src/tools/files.ts b/src/tools/files.ts new file mode 100644 index 0000000..080941a --- /dev/null +++ b/src/tools/files.ts @@ -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[] { + 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.'); + }, + }, + ]; +} diff --git a/src/tools/index.ts b/src/tools/index.ts index e0836e0..ca4e212 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -5,6 +5,7 @@ import { createStepTools } from './steps.js'; import { createMessageTools } from './messages.js'; import { createProjectTools } from './projects.js'; import { createMemberTools } from './members.js'; +import { createFileTools } from './files.js'; /** * Create all Team Board tracker tools for the agent. @@ -17,6 +18,7 @@ export function createTrackerTools(ctx: ToolContext): ToolDefinition[] { ...createMessageTools(ctx), ...createProjectTools(ctx), ...createMemberTools(ctx), + ...createFileTools(ctx), ]; } diff --git a/src/tracker/client.ts b/src/tracker/client.ts index 787cac2..41209e9 100644 --- a/src/tracker/client.ts +++ b/src/tracker/client.ts @@ -111,15 +111,51 @@ export class TrackerClient { return this.request('GET', `/api/v1/messages?${qs}`); } - // --- Files (Attachments) --- - // 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) + // --- Project Files --- - // async uploadAttachment(messageId: string, filename: string, data: Buffer): Promise> { - // return this.request('POST', `/api/v1/messages/${messageId}/attachments`, ...); - // } + async listProjectFiles(projectSlug: string, search?: string): Promise[]> { + const qs = search ? `?search=${encodeURIComponent(search)}` : ''; + return this.request('GET', `/api/v1/projects/${projectSlug}/files${qs}`); + } + + async getProjectFile(projectSlug: string, fileId: string): Promise> { + return this.request('GET', `/api/v1/projects/${projectSlug}/files/${fileId}`); + } + + async downloadProjectFile(projectSlug: string, fileId: string): Promise { + 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> { + 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> { + return this.request('PATCH', `/api/v1/projects/${projectSlug}/files/${fileId}`, data); + } + + async deleteProjectFile(projectSlug: string, fileId: string): Promise { + await this.request('DELETE', `/api/v1/projects/${projectSlug}/files/${fileId}`); + } // --- Projects ---