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
93 changes: 93 additions & 0 deletions packages/agent-toolkit/src/mcp/toolkit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1008,6 +1008,99 @@ describe('MondayAgentToolkit', () => {
});
});

it('should include tool metadata in MCP-formatted results', async () => {
const complexity = {
query: 12500,
before: 9950000,
after: 9937500,
reset_in_x_seconds: 42,
};
const mockTool = {
name: 'mcp-metadata-tool',
type: ToolType.READ,
annotations: { audience: [] },
enabledByDefault: true,
getDescription: jest.fn().mockReturnValue('MCP metadata tool'),
getInputSchema: jest.fn().mockReturnValue({ input: z.string() }),
execute: jest.fn().mockResolvedValue({
content: { message: 'Test content' },
metadata: { complexity },
}),
};

mockGetFilteredToolInstances.mockReturnValue([mockTool]);

const toolkit = new MondayAgentToolkit({
mondayApiToken: 'test-token',
});

const [tool] = toolkit.getToolsForMcp();
const result = await tool.handler({ input: 'test' });

expect(result).toEqual({
structuredContent: { message: 'Test content' },
content: [{ type: 'text', text: JSON.stringify({ message: 'Test content' }) }],
_meta: { complexity },
});
});

it('should expose monday API complexity metadata captured during MCP tool calls', async () => {
const complexity = {
query: 12500,
before: 9950000,
after: 9937500,
reset_in_x_seconds: 42,
};
const mockRequest = jest.fn().mockResolvedValue({
complexity,
boards: [{ id: '123' }],
});

mockApiClient.mockImplementationOnce(() => ({ request: mockRequest }) as any);
mockGetFilteredToolInstances.mockImplementationOnce(({ apiClient }) => [
{
name: 'api-complexity-tool',
type: ToolType.READ,
annotations: { audience: [] },
enabledByDefault: true,
getDescription: jest.fn().mockReturnValue('API complexity tool'),
getInputSchema: jest.fn().mockReturnValue({ boardId: z.string() }),
execute: jest.fn().mockImplementation(async ({ boardId }) => {
const response = await apiClient.request(
`query getBoard($boardId: [ID!]) {
boards(ids: $boardId) {
id
}
}`,
{ boardId },
);

return { content: response };
}),
},
]);

const toolkit = new MondayAgentToolkit({
mondayApiToken: 'test-token',
});

const [tool] = toolkit.getToolsForMcp();
const result = await tool.handler({ boardId: '123' });

expect(mockRequest).toHaveBeenCalledWith(
expect.stringContaining(
'complexity {\n query\n before\n after\n reset_in_x_seconds\n }',
),
{ boardId: '123' },
undefined,
);
expect(result).toEqual({
structuredContent: { boards: [{ id: '123' }] },
content: [{ type: 'text', text: JSON.stringify({ boards: [{ id: '123' }] }) }],
_meta: { complexity },
});
});

it('should handle errors in MCP format', async () => {
const mockTool = {
name: 'mcp-error-tool',
Expand Down
231 changes: 193 additions & 38 deletions packages/agent-toolkit/src/mcp/toolkit.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { CallToolResult, ServerCapabilities } from '@modelcontextprotocol/sdk/types';
import { ApiClient } from '@mondaydotcomorg/api';
import { ApiClient, QueryVariables, RequestOptions } from '@mondaydotcomorg/api';
import { getFilteredToolInstances } from '../utils/tools/tools-filtering.utils';
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
Expand All @@ -15,11 +15,35 @@ export interface GetToolsOptions {
schemaFormat?: 'zod' | 'json';
}

type ApiComplexity = {
query?: number | null;
before?: number | null;
after?: number | null;
reset_in_x_seconds?: number | null;
};

type ComplexityMetadata = {
complexity: ApiComplexity;
complexities?: ApiComplexity[];
};

type ComplexityTrackingApiClient = ApiClient & {
startComplexityTracking?: () => void;
consumeComplexityMetadata?: () => ComplexityMetadata | undefined;
};

const COMPLEXITY_SELECTION = `complexity {
query
before
after
reset_in_x_seconds
}`;

/**
* Monday Agent Toolkit providing an MCP server with monday.com tools
*/
export class MondayAgentToolkit extends McpServer {
private readonly mondayApiClient: ApiClient;
private readonly mondayApiClient: ComplexityTrackingApiClient;
private readonly mondayApiToken: string;
private readonly context?: MondayAgentToolkitConfig['context'];
private readonly dynamicToolManager: DynamicToolManager = new DynamicToolManager();
Expand Down Expand Up @@ -59,7 +83,7 @@ export class MondayAgentToolkit extends McpServer {
* Create and configure the Monday API client
*/
private createApiClient(config: MondayAgentToolkitConfig): ApiClient {
return new ApiClient({
const client = new ApiClient({
token: config.mondayApiToken,
apiVersion: config.mondayApiVersion ?? API_VERSION,
endpoint: config.mondayApiEndpoint,
Expand All @@ -71,6 +95,109 @@ export class MondayAgentToolkit extends McpServer {
},
},
});

return this.createComplexityTrackingClient(client);
}

private createComplexityTrackingClient(client: ApiClient): ComplexityTrackingApiClient {
const trackedClient = client as ComplexityTrackingApiClient;
if (typeof client.request !== 'function') {
return trackedClient;
}

const originalRequest = client.request.bind(client);
let isTracking = false;
let complexities: ApiComplexity[] = [];

trackedClient.startComplexityTracking = () => {
isTracking = true;
complexities = [];
};

trackedClient.consumeComplexityMetadata = () => {
isTracking = false;
const trackedComplexities = complexities;
complexities = [];

if (!trackedComplexities.length) {
return undefined;
}

const latestComplexity = trackedComplexities[trackedComplexities.length - 1];

return {
complexity: latestComplexity,
...(trackedComplexities.length > 1 ? { complexities: trackedComplexities } : {}),
};
};

trackedClient.request = async <T>(
query: string,
variables?: QueryVariables,
options?: RequestOptions,
): Promise<T> => {
const complexityQuery = isTracking ? this.addComplexitySelection(query) : { query, injected: false };
const response = await originalRequest<T>(complexityQuery.query, variables, options);
const complexity = this.extractComplexity(response);

if (isTracking && complexity) {
complexities.push(complexity);
}

if (complexityQuery.injected && this.isRecord(response)) {
const { complexity: _complexity, ...responseWithoutComplexity } = response;
return responseWithoutComplexity as T;
}

return response;
};

return trackedClient;
}

private addComplexitySelection(query: string): { query: string; injected: boolean } {
if (!query || /\bcomplexity\s*\{/i.test(query)) {
return { query, injected: false };
}

const operationStart = query.search(/\b(query|mutation)\b/i);
if (operationStart === -1) {
return { query, injected: false };
}

const selectionStart = query.indexOf('{', operationStart);
if (selectionStart === -1) {
return { query, injected: false };
}

return {
query: `${query.slice(0, selectionStart + 1)}
${COMPLEXITY_SELECTION}${query.slice(selectionStart + 1)}`,
injected: true,
};
}

private extractComplexity(response: unknown): ApiComplexity | undefined {
if (!this.isRecord(response) || !this.isRecord(response.complexity)) {
return undefined;
}

const complexity = response.complexity;

return {
query: this.getNullableNumber(complexity.query),
before: this.getNullableNumber(complexity.before),
after: this.getNullableNumber(complexity.after),
reset_in_x_seconds: this.getNullableNumber(complexity.reset_in_x_seconds),
};
}

private getNullableNumber(value: unknown): number | null | undefined {
return typeof value === 'number' || value === null ? value : undefined;
}

private isRecord(value: unknown): value is Record<string, any> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}

/**
Expand Down Expand Up @@ -133,17 +260,8 @@ export class MondayAgentToolkit extends McpServer {
},
async (args: any, _extra: any) => {
try {
let result;
if (inputSchema) {
const parsedArgs = z.object(inputSchema).safeParse(args);
if (!parsedArgs.success) {
throw new Error(`Invalid arguments: ${parsedArgs.error.message}`);
}
result = await tool.execute(parsedArgs.data);
} else {
result = await tool.execute();
}
return this.formatToolResult(result.content);
const result = await this.executeTool(tool, args);
return this.formatToolResult(result.content, result.metadata);
} catch (error) {
return this.handleToolError(error, tool.name);
}
Expand Down Expand Up @@ -283,20 +401,8 @@ export class MondayAgentToolkit extends McpServer {
private createMcpToolHandler(tool: Tool<any, any>) {
return async (params: any, extra?: any): Promise<CallToolResult> => {
try {
const inputSchema = tool.getInputSchema();

if (inputSchema) {
// inputSchema is already a Zod schema object definition, so we wrap it with z.object()
const parsedArgs = z.object(inputSchema).safeParse(params);
if (!parsedArgs.success) {
throw new Error(`Invalid arguments: ${parsedArgs.error.message}`);
}
const result = await tool.execute(parsedArgs.data, extra);
return this.formatToolResult(result.content);
} else {
const result = await tool.execute(undefined, extra);
return this.formatToolResult(result.content);
}
const result = await this.executeTool(tool, params, extra);
return this.formatToolResult(result.content, result.metadata);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
Expand All @@ -321,26 +427,75 @@ export class MondayAgentToolkit extends McpServer {
}

if (options?.schemaFormat === 'json') {
return zodToJsonSchema(z.object(inputSchema));
return zodToJsonSchema(z.object(inputSchema) as any);
}

return inputSchema;
}

/**
* Execute a tool and attach any API complexity data captured during the call.
*/
private async executeTool(tool: Tool<any, any>, args?: any, extra?: any) {
const inputSchema = tool.getInputSchema();
const tracker = this.mondayApiClient;

tracker.startComplexityTracking?.();

try {
const result = inputSchema
? await tool.execute(this.parseToolArgs(inputSchema, args), extra)
: await tool.execute(undefined, extra);
const complexityMetadata = tracker.consumeComplexityMetadata?.();

if (!complexityMetadata) {
return result;
}

return {
...result,
metadata: {
...result.metadata,
...complexityMetadata,
},
};
} catch (error) {
tracker.consumeComplexityMetadata?.();
throw error;
}
}

private parseToolArgs(inputSchema: z.ZodRawShape, args: any) {
const parsedArgs = z.object(inputSchema).safeParse(args);
if (!parsedArgs.success) {
throw new Error(`Invalid arguments: ${parsedArgs.error.message}`);
}

return parsedArgs.data;
}

/**
* Format the tool result into the expected MCP format
*/
private formatToolResult(content: string | Record<string, any>): CallToolResult {
if(typeof content === 'string') {
private formatToolResult(content: string | Record<string, any>, metadata?: Record<string, unknown>): CallToolResult {
const result: CallToolResult =
typeof content === 'string'
? {
content: [{ type: 'text', text: content }],
}
: {
structuredContent: content,
content: [{ type: 'text', text: JSON.stringify(content) }],
};

if (metadata && Object.keys(metadata).length > 0) {
return {
content: [{ type: 'text', text: content }],
}
...result,
_meta: metadata,
};
}

return {
structuredContent: content,
content: [{ type: 'text', text: JSON.stringify(content) }]
};

return result;
}

/**
Expand Down