From 62518a0db0b17a470d244d4489a3e786c0930ab6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 21 Apr 2026 11:37:27 +0000 Subject: [PATCH] [mcp] Add escalations_get tool with pagination (offset/limit) Adds a new escalations_get MCP tool that addresses the 21MB response problem when the tool is called with empty params ({}). Key changes: - New tool: escalations_get with limit (default 50, max 100) and offset parameters, preventing unbounded fetches - All params are optional; empty {} now defaults to limit=50 instead of returning everything - Query parameter for filtering escalations by text search - Response formatting with truncated descriptions and pagination hints - MSW mock handler in mcp-test-utils for the /rest/api/v1/escalations endpoint - Unit tests covering schema validation, API integration, response formatting, and pagination - Integration tests in server.test.ts for end-to-end tool execution Co-authored-by: Rahul Roy --- packages/local-mcp-server/src/server.ts | 32 ++- .../local-mcp-server/src/test/server.test.ts | 51 +++++ .../src/test/tools/escalations.test.ts | 196 ++++++++++++++++++ .../local-mcp-server/src/tools/escalations.ts | 149 +++++++++++++ packages/mcp-test-utils/src/mocks/handlers.ts | 89 ++++++++ 5 files changed, 516 insertions(+), 1 deletion(-) create mode 100644 packages/local-mcp-server/src/test/tools/escalations.test.ts create mode 100644 packages/local-mcp-server/src/tools/escalations.ts diff --git a/packages/local-mcp-server/src/server.ts b/packages/local-mcp-server/src/server.ts index d37535bc..791b41b9 100644 --- a/packages/local-mcp-server/src/server.ts +++ b/packages/local-mcp-server/src/server.ts @@ -5,11 +5,12 @@ * for AI models to interact with Glean's capabilities. It uses stdio * for communication and implements the MCP specification for tool discovery and execution. * - * The server exposes four tools: + * The server exposes five tools: * 1. company_search - Search across Glean's indexed content * 2. people_profile_search - Search for people profiles inside the company * 3. chat - Converse with Glean's AI assistant * 4. read_documents - Retrieve documents by ID or URL + * 5. escalations_get - List and search escalations with pagination */ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; @@ -24,6 +25,7 @@ import * as search from './tools/search.js'; import * as chat from './tools/chat.js'; import * as peopleProfileSearch from './tools/people_profile_search.js'; import * as readDocuments from './tools/read_documents.js'; +import * as escalations from './tools/escalations.js'; import { formatGleanError, isGleanError, @@ -35,6 +37,7 @@ export const TOOL_NAMES = { peopleProfileSearch: 'people_profile_search', chat: 'chat', readDocuments: 'read_documents', + escalationsGet: 'escalations_get', }; /** @@ -121,6 +124,20 @@ export async function listToolsHandler() { `, inputSchema: z.toJSONSchema(readDocuments.ToolReadDocumentsSchema), }, + { + name: TOOL_NAMES.escalationsGet, + description: `List and search escalations with pagination + + Example request: + + { + "query": "auth service outage", + "limit": 10, + "offset": 0 + } + `, + inputSchema: z.toJSONSchema(escalations.ToolEscalationsGetSchema), + }, ], }; } @@ -183,6 +200,19 @@ export async function callToolHandler(request: CallToolRequest) { }; } + case TOOL_NAMES.escalationsGet: { + const args = escalations.ToolEscalationsGetSchema.parse( + request.params.arguments, + ); + const result = await escalations.escalationsGet(args); + const formattedResults = escalations.formatResponse(result); + + return { + content: [{ type: 'text', text: formattedResults }], + isError: false, + }; + } + default: throw new Error(`Unknown tool: ${request.params.name}`); } diff --git a/packages/local-mcp-server/src/test/server.test.ts b/packages/local-mcp-server/src/test/server.test.ts index 54f617de..1aa81042 100644 --- a/packages/local-mcp-server/src/test/server.test.ts +++ b/packages/local-mcp-server/src/test/server.test.ts @@ -26,6 +26,7 @@ describe('MCP Server Handlers (integration)', () => { TOOL_NAMES.companySearch, TOOL_NAMES.chat, TOOL_NAMES.peopleProfileSearch, + TOOL_NAMES.escalationsGet, ]), ); @@ -213,6 +214,56 @@ describe('MCP Server Handlers (integration)', () => { `); }); + it('executes escalations_get tool with default pagination', async () => { + const req = CallToolRequestSchema.parse({ + method: 'tools/call', + id: '9', + jsonrpc: '2.0', + params: { + name: TOOL_NAMES.escalationsGet, + arguments: {}, + }, + }); + + const res = await callToolHandler(req); + expect(res.isError).toBe(false); + expect(res.content[0].text).toContain('3 escalations'); + expect(res.content[0].text).toContain('Auth service outage'); + }); + + it('executes escalations_get tool with query filter', async () => { + const req = CallToolRequestSchema.parse({ + method: 'tools/call', + id: '10', + jsonrpc: '2.0', + params: { + name: TOOL_NAMES.escalationsGet, + arguments: { query: 'auth', limit: 10 }, + }, + }); + + const res = await callToolHandler(req); + expect(res.isError).toBe(false); + expect(res.content[0].text).toContain('1 escalation'); + expect(res.content[0].text).toContain('Auth service outage'); + }); + + it('executes escalations_get tool with pagination', async () => { + const req = CallToolRequestSchema.parse({ + method: 'tools/call', + id: '11', + jsonrpc: '2.0', + params: { + name: TOOL_NAMES.escalationsGet, + arguments: { limit: 1, offset: 0 }, + }, + }); + + const res = await callToolHandler(req); + expect(res.isError).toBe(false); + expect(res.content[0].text).toContain('more results available'); + }); + it('validation error when pageSize is out of range', async () => { const badReq = CallToolRequestSchema.parse({ method: 'tools/call', diff --git a/packages/local-mcp-server/src/test/tools/escalations.test.ts b/packages/local-mcp-server/src/test/tools/escalations.test.ts new file mode 100644 index 00000000..832240f5 --- /dev/null +++ b/packages/local-mcp-server/src/test/tools/escalations.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + ToolEscalationsGetSchema, + escalationsGet, + formatResponse, +} from '../../tools/escalations.js'; +import { z } from 'zod'; +import '@gleanwork/mcp-test-utils/mocks/setup'; + +describe('Escalations Get Tool', () => { + beforeEach(() => { + delete process.env.GLEAN_URL; + process.env.GLEAN_INSTANCE = 'test'; + process.env.GLEAN_API_TOKEN = 'test-token'; + }); + + afterEach(() => { + delete process.env.GLEAN_INSTANCE; + delete process.env.GLEAN_API_TOKEN; + }); + + describe('JSON Schema Generation', () => { + it('generates correct JSON schema', () => { + expect(z.toJSONSchema(ToolEscalationsGetSchema)).toMatchInlineSnapshot(` + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "limit": { + "description": "Maximum number of escalations to return (1-100, default 50).", + "maximum": 100, + "minimum": 1, + "type": "integer", + }, + "offset": { + "description": "Number of results to skip for pagination (default 0).", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer", + }, + "query": { + "description": "Optional search query to filter escalations.", + "type": "string", + }, + }, + "type": "object", + } + `); + }); + }); + + describe('Schema Validation', () => { + it('accepts empty params with defaults', () => { + const result = ToolEscalationsGetSchema.safeParse({}); + expect(result.success).toBe(true); + }); + + it('accepts a query', () => { + const result = ToolEscalationsGetSchema.safeParse({ + query: 'auth outage', + }); + expect(result.success).toBe(true); + }); + + it('accepts limit and offset', () => { + const result = ToolEscalationsGetSchema.safeParse({ + limit: 10, + offset: 20, + }); + expect(result.success).toBe(true); + }); + + it('rejects limit above max', () => { + const result = ToolEscalationsGetSchema.safeParse({ limit: 200 }); + expect(result.success).toBe(false); + }); + + it('rejects negative offset', () => { + const result = ToolEscalationsGetSchema.safeParse({ offset: -1 }); + expect(result.success).toBe(false); + }); + + it('rejects non-integer limit', () => { + const result = ToolEscalationsGetSchema.safeParse({ limit: 10.5 }); + expect(result.success).toBe(false); + }); + }); + + describe('Tool Implementation', () => { + it('returns escalations with default pagination', async () => { + const response = (await escalationsGet({})) as any; + + expect(response.results).toBeInstanceOf(Array); + expect(response.results.length).toBe(3); + expect(response.totalCount).toBe(3); + expect(response.hasMoreResults).toBe(false); + }); + + it('filters by query', async () => { + const response = (await escalationsGet({ query: 'auth' })) as any; + + expect(response.results).toBeInstanceOf(Array); + expect(response.results.length).toBe(1); + expect(response.results[0].title).toContain('Auth'); + }); + + it('paginates with limit and offset', async () => { + const response = (await escalationsGet({ + limit: 1, + offset: 1, + })) as any; + + expect(response.results.length).toBe(1); + expect(response.results[0].id).toBe('esc-002'); + expect(response.hasMoreResults).toBe(true); + }); + }); + + describe('formatResponse', () => { + it('formats empty results', () => { + expect(formatResponse({ results: [] })).toMatchInlineSnapshot( + `"No escalations found."`, + ); + }); + + it('formats null response', () => { + expect(formatResponse(null)).toMatchInlineSnapshot( + `"No escalations found."`, + ); + }); + + it('formats escalation results', () => { + const response = { + results: [ + { + title: 'Test escalation', + status: 'OPEN', + priority: 'P1', + createdAt: '2026-04-20T08:30:00Z', + assignee: { name: 'Alice', email: 'alice@example.com' }, + description: 'Something is broken.', + url: 'https://example.com/esc/1', + }, + ], + totalCount: 1, + hasMoreResults: false, + }; + + expect(formatResponse(response)).toMatchInlineSnapshot(` + "Found 1 escalation: + + [1] Test escalation + Status: OPEN | Priority: P1 + Assignee: Alice | Created: 4/20/2026 + Something is broken. + URL: https://example.com/esc/1" + `); + }); + + it('indicates when more results are available', () => { + const response = { + results: [ + { + title: 'Escalation A', + status: 'OPEN', + priority: 'P2', + }, + ], + totalCount: 50, + hasMoreResults: true, + }; + + const result = formatResponse(response); + expect(result).toContain('more results available'); + expect(result).toContain('increase offset to paginate'); + }); + + it('truncates long descriptions', () => { + const response = { + results: [ + { + title: 'Verbose escalation', + status: 'OPEN', + priority: 'P1', + description: 'A'.repeat(300), + }, + ], + totalCount: 1, + hasMoreResults: false, + }; + + const result = formatResponse(response); + expect(result).toContain('...'); + }); + }); +}); diff --git a/packages/local-mcp-server/src/tools/escalations.ts b/packages/local-mcp-server/src/tools/escalations.ts new file mode 100644 index 00000000..19943e8e --- /dev/null +++ b/packages/local-mcp-server/src/tools/escalations.ts @@ -0,0 +1,149 @@ +import { + getConfig, + isGleanTokenConfig, +} from '@gleanwork/mcp-server-utils/config'; +import { z } from 'zod'; + +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 100; + +export const ToolEscalationsGetSchema = z.object({ + query: z + .string() + .describe('Optional search query to filter escalations.') + .optional(), + + limit: z + .number() + .int() + .min(1) + .max(MAX_LIMIT) + .describe( + `Maximum number of escalations to return (1-${MAX_LIMIT}, default ${DEFAULT_LIMIT}).`, + ) + .optional(), + + offset: z + .number() + .int() + .min(0) + .describe('Number of results to skip for pagination (default 0).') + .optional(), +}); + +export type ToolEscalationsGetRequest = z.infer< + typeof ToolEscalationsGetSchema +>; + +interface EscalationsAPIRequest { + query?: string; + pageSize: number; + cursor?: string; +} + +function convertToAPIRequest(input: ToolEscalationsGetRequest) { + const request: EscalationsAPIRequest = { + pageSize: input.limit ?? DEFAULT_LIMIT, + }; + + if (input.query) { + request.query = input.query; + } + + if (input.offset !== undefined && input.offset > 0) { + request.cursor = String(input.offset); + } + + return request; +} + +export async function escalationsGet(params: ToolEscalationsGetRequest) { + const apiRequest = convertToAPIRequest(params); + + const config = await getConfig(); + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (isGleanTokenConfig(config)) { + headers['Authorization'] = `Bearer ${config.token}`; + + const { actAs } = config; + if (actAs) { + headers['X-Glean-ActAs'] = actAs; + } + } + + const response = await fetch( + `${config.baseUrl}rest/api/v1/escalations`, + { + method: 'POST', + body: JSON.stringify(apiRequest), + headers, + }, + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `API request failed with status ${response.status}: ${errorText}`, + ); + } + + const contentType = response.headers.get('content-type'); + if (!contentType || !contentType.includes('application/json')) { + const responseText = await response.text(); + throw new Error( + `Expected JSON response but got ${contentType}: ${responseText}`, + ); + } + + return response.json(); +} + +export function formatResponse(escalationsResponse: any): string { + if ( + !escalationsResponse || + !Array.isArray(escalationsResponse.results) || + escalationsResponse.results.length === 0 + ) { + return 'No escalations found.'; + } + + const { results } = escalationsResponse; + + const formatted = results + .map((esc: any, index: number) => { + const title = esc.title || 'Untitled escalation'; + const status = esc.status || 'Unknown'; + const priority = esc.priority || 'Unknown'; + const createdAt = esc.createdAt + ? new Date(esc.createdAt).toLocaleDateString() + : 'Unknown date'; + const assignee = esc.assignee?.name || esc.assignee?.email || 'Unassigned'; + const description = esc.description + ? esc.description.slice(0, 200) + + (esc.description.length > 200 ? '...' : '') + : 'No description'; + const url = esc.url || ''; + + return `[${index + 1}] ${title} + Status: ${status} | Priority: ${priority} + Assignee: ${assignee} | Created: ${createdAt} + ${description}${url ? `\n URL: ${url}` : ''}`; + }) + .join('\n\n'); + + const total = + typeof escalationsResponse.totalCount === 'number' + ? escalationsResponse.totalCount + : results.length; + const hasMore = escalationsResponse.hasMoreResults === true; + + let summary = `Found ${total} escalation${total === 1 ? '' : 's'}`; + if (hasMore) { + summary += ' (more results available — increase offset to paginate)'; + } + + return `${summary}:\n\n${formatted}`; +} diff --git a/packages/mcp-test-utils/src/mocks/handlers.ts b/packages/mcp-test-utils/src/mocks/handlers.ts index c99de5c4..a57ee907 100644 --- a/packages/mcp-test-utils/src/mocks/handlers.ts +++ b/packages/mcp-test-utils/src/mocks/handlers.ts @@ -149,6 +149,95 @@ export const handlers = [ }, ), + // Handler for escalations + http.post( + 'https://:instance-be.glean.com/rest/api/v1/escalations', + async ({ request }) => { + const authHeader = request.headers.get('Authorization'); + + if (!authHeader || authHeader === 'Bearer invalid_token') { + return new HttpResponse('Invalid Secret\nNot allowed', { + status: 401, + statusText: 'Unauthorized', + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + }, + }); + } + + if (authHeader === 'Bearer server_error') { + return new HttpResponse('Something went wrong', { + status: 500, + statusText: 'Internal Server Error', + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + }, + }); + } + + const body = (await request.json()) as { + pageSize?: number; + cursor?: string; + query?: string; + }; + const pageSize = body.pageSize ?? 50; + const cursor = body.cursor ? parseInt(body.cursor, 10) : 0; + + const allEscalations = [ + { + id: 'esc-001', + title: 'Auth service outage affecting SSO', + status: 'OPEN', + priority: 'P1', + createdAt: '2026-04-20T08:30:00Z', + assignee: { name: 'Alice Smith', email: 'alice@example.com' }, + description: + 'Multiple customers reporting SSO login failures after the latest deployment.', + url: 'https://example.com/escalations/esc-001', + }, + { + id: 'esc-002', + title: 'Data pipeline latency spike', + status: 'IN_PROGRESS', + priority: 'P2', + createdAt: '2026-04-19T14:00:00Z', + assignee: { name: 'Bob Jones', email: 'bob@example.com' }, + description: + 'Indexing pipeline showing 10x latency increase since 2pm UTC.', + url: 'https://example.com/escalations/esc-002', + }, + { + id: 'esc-003', + title: 'Search relevance regression', + status: 'RESOLVED', + priority: 'P3', + createdAt: '2026-04-18T10:15:00Z', + assignee: { name: 'Carol Lee', email: 'carol@example.com' }, + description: 'Search quality dropped after model update on April 18.', + url: 'https://example.com/escalations/esc-003', + }, + ]; + + let filtered = allEscalations; + if (body.query) { + const q = body.query.toLowerCase(); + filtered = allEscalations.filter( + (e) => + e.title.toLowerCase().includes(q) || + e.description.toLowerCase().includes(q), + ); + } + + const paged = filtered.slice(cursor, cursor + pageSize); + + return HttpResponse.json({ + results: paged, + totalCount: filtered.length, + hasMoreResults: cursor + pageSize < filtered.length, + }); + }, + ), + // Handler for people profile search (listentities) http.post( 'https://:instance-be.glean.com/rest/api/v1/listentities',