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
8 changes: 8 additions & 0 deletions packages/agent-toolkit/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## 5.9.1

### Asset upload MCP tools

- Added `get_asset_upload_url` — requests a presigned S3 upload URL for a file. Returns `upload_id`, `upload_url`, and expiry. Includes inline `curl` example for the upload step and ETag capture guidance.
- Added `finalize_asset_upload` — finalizes the upload via `complete_upload` and attaches the asset to a file column on a board item using `change_column_value` (append semantics). Returns `asset_id`, `filename`, `content_type`, `file_size`, `url`, and `filelink`.
- Both tools use `versionOverride: 'dev'` as `create_upload` / `complete_upload` are currently dev-schema-only.

## 5.7.1

### form_questions_editor — fix ConditionOperator and remove existing_column_id
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { gql } from 'graphql-request';

export const changeColumnValue = gql`
mutation ChangeColumnValue($boardId: ID!, $itemId: ID!, $columnId: String!, $value: JSON!) {
change_column_value(board_id: $boardId, item_id: $itemId, column_id: $columnId, value: $value) {
id
}
}
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { createMockApiClient } from '../test-utils/mock-api-client';
import { FinalizeAssetUploadTool } from './finalize-asset-upload-tool';

const MOCK_ASSET = {
id: 987654,
filename: 'report.pdf',
content_type: 'application/pdf',
file_size: 1024,
url: '/protected_static/12345/resources/987654/report.pdf',
created_at: '2026-04-17T00:00:00Z',
filelink: 'https://monday.com/files/987654/report.pdf',
};

describe('FinalizeAssetUploadTool', () => {
let mocks: ReturnType<typeof createMockApiClient>;

beforeEach(() => {
mocks = createMockApiClient();
});

it('completes upload, attaches to column, and returns asset details', async () => {
mocks.mockRequest.mockResolvedValueOnce({ complete_upload: MOCK_ASSET });
mocks.mockRequest.mockResolvedValueOnce({ change_column_value: { id: '42' } });

const tool = new FinalizeAssetUploadTool(mocks.mockApiClient);
const result = await tool.execute({
uploadId: 'uuid-upload-123',
etag: '"abc123etag"',
boardId: '100',
itemId: '42',
columnId: 'file_mkvvv9cm',
});

expect(result.content).toEqual({
asset_id: 987654,
filename: 'report.pdf',
content_type: 'application/pdf',
file_size: 1024,
url: '/protected_static/12345/resources/987654/report.pdf',
filelink: 'https://monday.com/files/987654/report.pdf',
});

expect(mocks.mockRequest).toHaveBeenCalledTimes(2);

expect(mocks.mockRequest).toHaveBeenNthCalledWith(
1,
expect.anything(),
{
input: {
upload_id: 'uuid-upload-123',
holder: { type: 'ITEM', id: '42' },
board_id: '100',
parts: [{ part_number: 1, etag: '"abc123etag"' }],
},
},
expect.objectContaining({ versionOverride: 'dev' }),
);

expect(mocks.mockRequest).toHaveBeenNthCalledWith(
2,
expect.anything(),
{
boardId: '100',
itemId: '42',
columnId: 'file_mkvvv9cm',
value: JSON.stringify({
added_file: {
fileType: 'ASSET',
name: 'report.pdf',
assetId: '987654',
},
}),
},
);
});

it('propagates complete_upload errors', async () => {
mocks.mockRequest.mockRejectedValueOnce(new Error('Upload not found'));
const tool = new FinalizeAssetUploadTool(mocks.mockApiClient);

await expect(
tool.execute({ uploadId: 'bad', etag: '"etag"', boardId: '100', itemId: '42', columnId: 'file_mkvvv9cm' }),
).rejects.toThrow('Upload not found');
});

it('propagates change_column_value errors', async () => {
mocks.mockRequest.mockResolvedValueOnce({ complete_upload: MOCK_ASSET });
mocks.mockRequest.mockRejectedValueOnce(new Error('Column not found'));
const tool = new FinalizeAssetUploadTool(mocks.mockApiClient);

await expect(
tool.execute({ uploadId: 'uuid-upload-123', etag: '"abc123etag"', boardId: '100', itemId: '42', columnId: 'bad_col' }),
).rejects.toThrow('Column not found');
});

it('has correct metadata', () => {
const tool = new FinalizeAssetUploadTool(mocks.mockApiClient);
expect(tool.name).toBe('finalize_asset_upload');
expect(tool.type).toBe('write');
expect(tool.getDescription()).toContain('get_asset_upload_url');
expect(tool.getDescription()).toContain('asset_id');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { z } from 'zod';
import { ToolInputType, ToolOutputType, ToolType } from '../../../tool';
import { BaseMondayApiTool, createMondayApiAnnotations } from '../base-monday-api-tool';
import { completeUploadMutationDev } from './finalize-asset-upload.graphql.dev';
import { changeColumnValue } from './change-column-value.graphql';

export const finalizeAssetUploadSchema = {
uploadId: z.string().describe('The upload_id returned by get_asset_upload_url'),
etag: z.string().describe('The ETag header value from the PUT response when uploading to the presigned URL'),
boardId: z.string().describe("The board's unique identifier"),
itemId: z.string().describe("The item's unique identifier"),
columnId: z.string().describe("The file or doc column's unique identifier to attach the uploaded asset to"),
};

interface CompleteUploadMutation {
complete_upload: {
id: number;
filename: string;
content_type: string;
file_size: number;
url: string;
created_at: string;
filelink: string;
};
}

interface ChangeColumnValueMutation {
change_column_value?: { id: string };
}

export class FinalizeAssetUploadTool extends BaseMondayApiTool<typeof finalizeAssetUploadSchema, never> {
name = 'finalize_asset_upload';
type = ToolType.WRITE;
annotations = createMondayApiAnnotations({
title: 'Finalize Asset Upload',
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
});

getDescription(): string {
return (
'Finalize a file upload and create the asset on monday.com. ' +
'Call this after uploading the file to the presigned URL from get_asset_upload_url. ' +
'Requires the etag value from the PUT response headers. ' +
'Automatically attaches the uploaded asset to the specified file column on the item. ' +
'Returns the created asset_id.'
);
}

getInputSchema(): typeof finalizeAssetUploadSchema {
return finalizeAssetUploadSchema;
}

protected async executeInternal(
input: ToolInputType<typeof finalizeAssetUploadSchema>,
): Promise<ToolOutputType<never>> {
const completeRes = await this.mondayApi.request<CompleteUploadMutation>(
completeUploadMutationDev,
{
input: {
upload_id: input.uploadId,
holder: { type: 'ITEM', id: input.itemId },
board_id: input.boardId,
parts: [{ part_number: 1, etag: input.etag }],
},
},
// complete_upload is only available in the dev schema; remove versionOverride once promoted to stable
{ versionOverride: 'dev' },
);

const asset = completeRes.complete_upload;

const value = JSON.stringify({
added_file: {
fileType: 'ASSET',
name: asset.filename,
assetId: String(asset.id),
},
});

await this.mondayApi.request<ChangeColumnValueMutation>(changeColumnValue, {
boardId: input.boardId,
itemId: input.itemId,
columnId: input.columnId,
value,
});

return {
content: {
asset_id: asset.id,
filename: asset.filename,
content_type: asset.content_type,
file_size: asset.file_size,
url: asset.url,
filelink: asset.filelink,
},
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { gql } from 'graphql-request';

export const completeUploadMutationDev = gql`
mutation CompleteUpload($input: CompleteUploadInput!) {
complete_upload(input: $input) {
id
filename
content_type
file_size
url
created_at
filelink
}
}
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { createMockApiClient } from '../test-utils/mock-api-client';
import { GetAssetUploadUrlTool } from './get-asset-upload-url-tool';

describe('GetAssetUploadUrlTool', () => {
let mocks: ReturnType<typeof createMockApiClient>;

beforeEach(() => {
mocks = createMockApiClient();
});

it('returns upload_id, upload_url, and url_expires_at', async () => {
mocks.setResponse({
create_upload: {
upload_id: 'uuid-123',
parts: [{ part_number: 1, url: 'https://s3.example.com/presigned', size_range_start: 0, size_range_end: 1023 }],
part_size: 1024,
expires_at: '2026-04-17T12:00:00Z',
},
});

const tool = new GetAssetUploadUrlTool(mocks.mockApiClient);
const result = await tool.execute({ fileName: 'test.pdf', contentType: 'application/pdf', fileSize: 1024 });

expect(result.content).toEqual({
upload_id: 'uuid-123',
upload_url: 'https://s3.example.com/presigned',
url_expires_at: '2026-04-17T12:00:00Z',
});
});

it('passes correct variables with versionOverride dev', async () => {
mocks.setResponse({
create_upload: {
upload_id: 'uuid-456',
parts: [{ part_number: 1, url: 'https://s3.example.com/url2', size_range_start: 0, size_range_end: 2047 }],
part_size: 2048,
expires_at: '2026-04-18T00:00:00Z',
},
});

const tool = new GetAssetUploadUrlTool(mocks.mockApiClient);
await tool.execute({ fileName: 'photo.jpg', contentType: 'image/jpeg', fileSize: 2048 });

expect(mocks.mockRequest).toHaveBeenCalledWith(
expect.anything(),
{
input: {
file_name: 'photo.jpg',
content_type: 'image/jpeg',
file_size: 2048,
source: 'mcp',
multipart: false,
},
},
expect.objectContaining({ versionOverride: 'dev' }),
);
});

it('throws when parts array is empty', async () => {
mocks.setResponse({
create_upload: {
upload_id: 'uuid-999',
parts: [],
part_size: 1024,
expires_at: '2026-04-17T12:00:00Z',
},
});

const tool = new GetAssetUploadUrlTool(mocks.mockApiClient);
await expect(
tool.execute({ fileName: 'test.pdf', contentType: 'application/pdf', fileSize: 1024 }),
).rejects.toThrow('create_upload returned no upload URL');
});

it('has correct metadata', () => {
const tool = new GetAssetUploadUrlTool(mocks.mockApiClient);
expect(tool.name).toBe('get_asset_upload_url');
expect(tool.type).toBe('write');
expect(tool.getDescription()).toContain('curl');
expect(tool.getDescription()).toContain('ETag');
expect(tool.getDescription()).toContain('finalize_asset_upload');
});
});
Loading
Loading