Skip to content
Merged
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 packages/agent-toolkit/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@mondaydotcomorg/agent-toolkit",
"version": "4.1.3",
"version": "4.1.4",
"description": "monday.com agent toolkit",
"exports": {
"./mcp": {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { randomUUID } from 'crypto';
import {
BlockAlignment,
BlockDirection,
CreateBlockInput,
DocsMention,
ListBlock,
NoticeBoxTheme,
OperationInput,
Expand Down Expand Up @@ -35,22 +37,69 @@ function mapAttributes(attributes: DeltaOperation['attributes']) {
};
}

/**
* Maps delta ops to GraphQL OperationInput[] for the create_doc_blocks path.
* Blot types (mention, column_value) use the BlotInput wrapper required by the typed GraphQL input.
*/
function mapDeltaFormat(ops: DeltaOperation[]): OperationInput[] {
return ops.map((op) => ({
insert: { text: op.insert.text },
attributes: mapAttributes(op.attributes),
}));
return ops.map((op) => {
if ('mention' in op.insert) {
const numericId = Number(op.insert.mention.id);
if (Number.isNaN(numericId)) {
throw new Error(`Invalid mention id: "${op.insert.mention.id}" is not a valid numeric ID`);
}
return {
insert: {
// GraphQL ID scalar expects a string — validated as numeric above, passed as string here
blot: { mention: { id: String(numericId), type: op.insert.mention.type as DocsMention } },
},
};
}
if ('column_value' in op.insert) {
return {
insert: {
// GraphQL ID scalar expects a string
blot: { column_value: { item_id: String(op.insert.column_value.item_id), column_id: op.insert.column_value.column_id } },
},
};
}
return {
insert: { text: op.insert.text },
attributes: mapAttributes(op.attributes),
};
});
}

/**
* Maps delta ops to the server-internal format for the update_doc_block path (raw JSON).
* Plain text inserts must be bare strings, not wrapped in an object.
* No BlotInput wrapper — blots are embedded directly in the insert value.
*/
function mapDeltaFormatRaw(ops: DeltaOperation[]): Record<string, unknown>[] {
return ops.map((op) => ({
insert: op.insert.text,
attributes: mapAttributes(op.attributes),
}));
return ops.map((op) => {
if ('mention' in op.insert) {
const numericId = Number(op.insert.mention.id);
if (Number.isNaN(numericId)) {
throw new Error(`Invalid mention id: "${op.insert.mention.id}" is not a valid numeric ID`);
}
return { insert: { mention: { id: numericId, type: op.insert.mention.type } } };
}
if ('column_value' in op.insert) {
return {
insert: {
macro: {
type: 'COLUMN_VALUE',
macroId: randomUUID(),
// Raw JSON format expects a numeric itemId (unlike GraphQL ID scalar which uses string)
macroData: { itemId: Number(op.insert.column_value.item_id), columnId: op.insert.column_value.column_id },
},
},
};
}
return {
insert: op.insert.text,
attributes: mapAttributes(op.attributes),
};
});
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,32 @@ const AttributesSchema = z
})
.optional();

const MentionInsertSchema = z.object({
mention: z
.object({
id: z.union([z.string(), z.number()]).describe('User, doc, or board ID. Get user IDs from list_users_and_teams.'),
type: z.enum(['USER', 'DOC', 'BOARD']).default('USER').describe('Mention type. USER is most common.'),
})
.describe('Mention blot — tags a user, doc, or board inline. Do not set attributes on mention ops.'),
});

const ColumnValueInsertSchema = z.object({
column_value: z
.object({
item_id: z.union([z.string(), z.number()]).describe('The board item ID.'),
column_id: z.string().describe('The column ID (e.g. "status", "date4"). Get column IDs from get_board_schema.'),
})
.describe('Column value blot — embeds a live board column value inline in the doc.'),
});

export const DeltaOperationSchema = z.object({
insert: z
.object({ text: z.string() })
.describe('Text content to insert. The last operation in the array must insert "\\n".'),
.union([z.object({ text: z.string() }), MentionInsertSchema, ColumnValueInsertSchema])
.describe(
'Content to insert. Use {text: "..."} for plain text, {mention: {id, type}} to tag a user/doc/board, or {column_value: {item_id, column_id}} to embed a live column value. The last operation in the array must be {text: "\\n"}.',
),
attributes: AttributesSchema.describe(
'Optional formatting: bold, italic, underline, strike, code, link, color, background.',
'Optional formatting: bold, italic, underline, strike, code, link, color, background. Not applicable to mention or column_value ops.',
),
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -681,4 +681,145 @@ describe('UpdateDocTool', () => {
expect(result.content[0].text).toContain('Name service down');
expect(deleteCalled).toBe(false);
});

// ─── Mention blot (update_block path — raw JSON format) ──────────────────

it('sends mention blot in raw JSON format for update_block', async () => {
jest.spyOn(mocks, 'mockRequest').mockImplementation((query: string) => {
if (query.includes('mutation updateDocBlock')) return Promise.resolve({ update_doc_block: { id: 'blk' } });
return Promise.resolve({});
});

const result = await callToolByNameRawAsync('update_doc', {
doc_id: 'doc_123',
operations: [
{
operation_type: 'update_block',
block_id: 'blk',
content: {
block_content_type: 'text',
delta_format: [
{ insert: { text: 'Hey ' } },
{ insert: { mention: { id: 12345, type: 'USER' } } },
{ insert: { text: '\n' } },
],
},
},
],
});

expect(result.content[0].text).toContain('[OK] update_block');

const calls = mocks.getMockRequest().mock.calls;
const updateCall = calls.find((c: any) => c[0].includes('mutation updateDocBlock'));
const content = JSON.parse(updateCall[1].content);
expect(content.deltaFormat[0]).toEqual({ insert: 'Hey ' });
expect(content.deltaFormat[1]).toEqual({ insert: { mention: { id: 12345, type: 'USER' } } });
expect(content.deltaFormat[2]).toEqual({ insert: '\n' });
});

// ─── Mention blot (create_block path — GraphQL BlotInput format) ──────────

it('sends mention blot with BlotInput wrapper for create_block', async () => {
jest.spyOn(mocks, 'mockRequest').mockImplementation((query: string) => {
if (query.includes('mutation createDocBlocks')) {
return Promise.resolve({
create_doc_blocks: [{ id: 'new_blk', type: 'normal_text', parent_block_id: null, created_at: '', content: [] }],
});
}
return Promise.resolve({});
});

const result = await callToolByNameRawAsync('update_doc', {
doc_id: 'doc_123',
operations: [
{
operation_type: 'create_block',
block: {
block_type: 'text',
delta_format: [
{ insert: { mention: { id: 99, type: 'USER' } } },
{ insert: { text: ' please review\n' } },
],
},
},
],
});

expect(result.content[0].text).toContain('[OK] create_block');

const calls = mocks.getMockRequest().mock.calls;
const createCall = calls.find((c: any) => c[0].includes('mutation createDocBlocks'));
const deltaFormat = createCall[1].blocksInput[0].text_block.delta_format;
expect(deltaFormat[0]).toEqual({ insert: { blot: { mention: { id: '99', type: 'USER' } } } });
expect(deltaFormat[1]).toEqual({ insert: { text: ' please review\n' }, attributes: undefined });
});

// ─── Column value blot (create_block path — GraphQL BlotInput format) ─────

it('sends column_value blot with BlotInput wrapper for create_block', async () => {
jest.spyOn(mocks, 'mockRequest').mockImplementation((query: string) => {
if (query.includes('mutation createDocBlocks')) {
return Promise.resolve({
create_doc_blocks: [{ id: 'new_blk', type: 'normal_text', parent_block_id: null, created_at: '', content: [] }],
});
}
return Promise.resolve({});
});

await callToolByNameRawAsync('update_doc', {
doc_id: 'doc_123',
operations: [
{
operation_type: 'create_block',
block: {
block_type: 'text',
delta_format: [
{ insert: { column_value: { item_id: 111, column_id: 'status' } } },
{ insert: { text: '\n' } },
],
},
},
],
});

const calls = mocks.getMockRequest().mock.calls;
const createCall = calls.find((c: any) => c[0].includes('mutation createDocBlocks'));
const deltaFormat = createCall[1].blocksInput[0].text_block.delta_format;
expect(deltaFormat[0]).toEqual({ insert: { blot: { column_value: { item_id: '111', column_id: 'status' } } } });
});

// ─── Column value blot (update_block path — macro format) ─────────────────

it('sends column_value as macro format for update_block', async () => {
jest.spyOn(mocks, 'mockRequest').mockImplementation((query: string) => {
if (query.includes('mutation updateDocBlock')) return Promise.resolve({ update_doc_block: { id: 'blk' } });
return Promise.resolve({});
});

await callToolByNameRawAsync('update_doc', {
doc_id: 'doc_123',
operations: [
{
operation_type: 'update_block',
block_id: 'blk',
content: {
block_content_type: 'text',
delta_format: [
{ insert: { column_value: { item_id: 222, column_id: 'date4' } } },
{ insert: { text: '\n' } },
],
},
},
],
});

const calls = mocks.getMockRequest().mock.calls;
const updateCall = calls.find((c: any) => c[0].includes('mutation updateDocBlock'));
const content = JSON.parse(updateCall[1].content);
const macroOp = content.deltaFormat[0];
expect(macroOp.insert.macro.type).toBe('COLUMN_VALUE');
expect(macroOp.insert.macro.macroData).toEqual({ itemId: 222, columnId: 'date4' });
expect(macroOp.insert.macro.macroId).toBeDefined();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ GETTING BLOCK IDs: Call read_docs with include_blocks: true — returns id, type
BLOCK CONTENT (delta_format): Array of insert ops. Last op MUST be {insert: {text: "\\n"}}.
- Plain: [{insert: {text: "Hello"}}, {insert: {text: "\\n"}}]
- Bold: [{insert: {text: "Hi"}, attributes: {bold: true}}, {insert: {text: "\\n"}}]
- Supported attributes: bold, italic, underline, strike, code, link, color, background
- Mention user/doc/board: [{insert: {text: "Hey "}}, {insert: {mention: {id: 12345, type: "USER"}}}, {insert: {text: "\\n"}}] — type is USER, DOC, or BOARD; id is numeric (user IDs from list_users_and_teams)
- Inline column value: [{insert: {column_value: {item_id: 111, column_id: "status"}}}, {insert: {text: "\\n"}}]
- Supported attributes: bold, italic, underline, strike, code, link, color, background (not applicable to mention/column_value ops)

IMAGE WITH ASSET: For asset-based images, use create_block with block_type "image" and asset_id (instead of public_url). add_markdown_content does NOT support asset images — for mixed content, alternate add_markdown_content (text) and create_block (image) operations in sequence.`;
}
Expand Down
Loading