From c2be9bc6622ef6673661c7d5856d230ef21afe08 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Wed, 6 May 2026 22:10:04 -0700 Subject: [PATCH 1/3] feat(mcp): add Rippling integration as a shimmed MCP preset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets users add Rippling with `/mcp-add rippling ` and have it appear to the agent as `mcp_rippling_*` tools, indistinguishable from a real MCP server. Under the hood Pookie hosts the tools locally and calls Rippling's REST API directly — no MCP transport, no proxy server. The shim mechanism is generic: presets gain an optional `shim` field, and `tryRegister` / `openMcpTools` short-circuit to a local toolset builder for shim configs. Future built-in HR/finance/etc. integrations can drop into `server/mcp/shims/` without changing the agent or MCP wiring. Rippling tools cover companies, employees, leave types, leave balances, and leave requests (14 tools total). 401/403 from Rippling map to a structured tool error with regen instructions instead of leaking a stack trace. --- apps/api/server/mcp/client.ts | 82 +++- apps/api/server/mcp/presets.ts | 37 ++ apps/api/server/mcp/shims/constants.ts | 8 + apps/api/server/mcp/shims/index.ts | 24 ++ apps/api/server/mcp/shims/rippling-client.ts | 100 +++++ apps/api/server/mcp/shims/rippling.ts | 392 +++++++++++++++++++ apps/api/tests/rippling-shim.test.ts | 217 ++++++++++ 7 files changed, 850 insertions(+), 10 deletions(-) create mode 100644 apps/api/server/mcp/shims/constants.ts create mode 100644 apps/api/server/mcp/shims/index.ts create mode 100644 apps/api/server/mcp/shims/rippling-client.ts create mode 100644 apps/api/server/mcp/shims/rippling.ts create mode 100644 apps/api/tests/rippling-shim.test.ts diff --git a/apps/api/server/mcp/client.ts b/apps/api/server/mcp/client.ts index 97a67f2..7affc32 100644 --- a/apps/api/server/mcp/client.ts +++ b/apps/api/server/mcp/client.ts @@ -2,6 +2,8 @@ import { auth, createMCPClient } from "@ai-sdk/mcp"; import { createMcpAuthProvider, McpOAuthRedirectError } from "./auth-provider"; import { MCP_TOOL_NAME_SEPARATOR } from "./constants"; +import { resolveShimName } from "./presets"; +import { resolveShim } from "./shims"; import { clearPendingAuthUrl, consumeOAuthState, @@ -181,11 +183,42 @@ const ensureOAuth = async ( } }; +// Shimmed integrations (e.g. Rippling) are exposed to the agent as if they +// were MCP servers, but Pookie hosts the tools locally and skips the MCP +// transport entirely. Register/open paths short-circuit to the shim's +// `buildTools` so the same `mcp__` naming and prompt routing +// apply uniformly. +const tryRegisterShim = async ( + config: McpServerConfig, +): Promise => { + const shimName = resolveShimName(config.name); + if (!shimName) return null; + + const shim = resolveShim(shimName); + if (!shim) return null; + + if (!config.token) { + throw new Error( + `${config.name} requires an API key. re-run \`/mcp-add ${config.name} \``, + ); + } + + const validation = await shim.validate(config.token); + if (!validation.ok) { + throw new Error(validation.message ?? `${config.name} validation failed`); + } + + return { connected: true, toolCount: validation.toolCount }; +}; + export const tryRegister = async ( userId: string, config: McpServerConfig, teamId: string, ): Promise => { + const shimResult = await tryRegisterShim(config); + if (shimResult) return shimResult; + try { await ensureOAuth(userId, config, teamId); } catch (error) { @@ -317,8 +350,45 @@ export const openMcpTools = async ( } }; + const registerToolSet = ( + config: McpServerConfig, + toolSet: Record, + ): void => { + let toolCount = 0; + for (const [toolName, tool] of Object.entries(toolSet)) { + const prefixedName = `mcp${MCP_TOOL_NAME_SEPARATOR}${config.name}${MCP_TOOL_NAME_SEPARATOR}${toolName}`; + allTools[prefixedName] = tool; + toolCount++; + } + serverSummaries.push({ name: config.name, toolCount }); + }; + + const ingestShimConfig = (config: McpServerConfig): boolean => { + const shimName = resolveShimName(config.name); + if (!shimName) return false; + const shim = resolveShim(shimName); + if (!shim) return false; + if (!config.token) { + errors.push({ + name: config.name, + message: `${config.name} requires an API key. re-run \`/mcp-add ${config.name} \``, + }); + return true; + } + try { + registerToolSet(config, shim.buildTools(config.token)); + } catch (error) { + classifyError(config, error); + } + return true; + }; + + const transportConfigs = configs.filter( + (config) => !ingestShimConfig(config), + ); + const connectionResults = await Promise.allSettled( - configs.map(async (config) => { + transportConfigs.map(async (config) => { try { await ensureOAuth(userId, config, teamId); } catch (authError) { @@ -349,15 +419,7 @@ export const openMcpTools = async ( try { const toolSet = await client.tools(); - let toolCount = 0; - - for (const [toolName, tool] of Object.entries(toolSet)) { - const prefixedName = `mcp${MCP_TOOL_NAME_SEPARATOR}${config.name}${MCP_TOOL_NAME_SEPARATOR}${toolName}`; - allTools[prefixedName] = tool as AI.Tool; - toolCount++; - } - - serverSummaries.push({ name: config.name, toolCount }); + registerToolSet(config, toolSet as Record); } catch (toolError) { classifyError(config, toolError); } diff --git a/apps/api/server/mcp/presets.ts b/apps/api/server/mcp/presets.ts index c0a32c8..4afe045 100644 --- a/apps/api/server/mcp/presets.ts +++ b/apps/api/server/mcp/presets.ts @@ -14,6 +14,12 @@ export interface McpPreset { // surfaced first without removing the preset from `/mcp-add` or other // entry points). hiddenByDefault?: boolean; + // Identifier into MCP_SHIMS. When set, Pookie does NOT open an MCP + // transport against `url`; instead it builds tools locally from the + // shim's REST wrappers. The agent still sees them as `mcp__*` + // tools so prompts, search routing, and system reminders treat the + // integration uniformly with real MCP servers. + shim?: string; } export const MCP_PRESETS: Record = { @@ -235,6 +241,34 @@ export const MCP_PRESETS: Record = { "search the web for best practices on slack bot onboarding", ], }, + rippling: { + name: "rippling", + displayName: "Rippling", + url: "https://api.rippling.com", + description: "hr, employees, leave", + authType: "token", + tokenHelpUrl: "https://app.rippling.com/developer/apiKeys", + shim: "rippling", + searchTools: [ + "list_employees", + "get_employee", + "list_employees_including_terminated", + "get_current_company", + "get_departments", + "get_work_locations", + "get_teams", + "get_levels", + "get_company_leave_types", + "get_current_user", + "list_leave_balances", + "get_leave_balance", + "list_leave_requests", + ], + exampleQueries: [ + "who's out on leave this week according to rippling?", + "list everyone in the engineering department in rippling", + ], + }, }; const resolveByPrefix = ( @@ -260,3 +294,6 @@ export const resolvePreset = (name: string): McpPreset | undefined => export const getPresetDisplayName = (preset: McpPreset): string => preset.displayName ?? preset.name; + +export const resolveShimName = (serverName: string): string | undefined => + resolvePreset(serverName)?.shim; diff --git a/apps/api/server/mcp/shims/constants.ts b/apps/api/server/mcp/shims/constants.ts new file mode 100644 index 0000000..3d387b5 --- /dev/null +++ b/apps/api/server/mcp/shims/constants.ts @@ -0,0 +1,8 @@ +export const RIPPLING_API_BASE_URL = "https://api.rippling.com/platform/api"; + +export const RIPPLING_DEFAULT_PAGE_LIMIT = 50; +export const RIPPLING_MAX_PAGE_LIMIT = 100; + +export const RIPPLING_REQUEST_TIMEOUT_MS = 15000; + +export const RIPPLING_USER_AGENT = "pookie-rippling-shim/1.0"; diff --git a/apps/api/server/mcp/shims/index.ts b/apps/api/server/mcp/shims/index.ts new file mode 100644 index 0000000..ec0f490 --- /dev/null +++ b/apps/api/server/mcp/shims/index.ts @@ -0,0 +1,24 @@ +import { buildRipplingTools, validateRipplingShim } from "./rippling"; + +import type * as AI from "ai"; + +export interface McpShimValidation { + ok: boolean; + toolCount: number; + message?: string; +} + +export interface McpShim { + buildTools: (token: string) => Record; + validate: (token: string) => Promise; +} + +const SHIMS: Record = { + rippling: { + buildTools: buildRipplingTools, + validate: validateRipplingShim, + }, +}; + +export const resolveShim = (shimName: string): McpShim | undefined => + SHIMS[shimName]; diff --git a/apps/api/server/mcp/shims/rippling-client.ts b/apps/api/server/mcp/shims/rippling-client.ts new file mode 100644 index 0000000..fd398ba --- /dev/null +++ b/apps/api/server/mcp/shims/rippling-client.ts @@ -0,0 +1,100 @@ +import { + RIPPLING_API_BASE_URL, + RIPPLING_REQUEST_TIMEOUT_MS, + RIPPLING_USER_AGENT, +} from "./constants"; + +export interface RipplingRequestError { + status: number; + message: string; +} + +export class RipplingApiError extends Error { + readonly status: number; + + constructor(status: number, message: string) { + super(message); + this.status = status; + this.name = "RipplingApiError"; + } +} + +const buildAuthHeader = (token: string): Record => ({ + Accept: "application/json", + Authorization: `Bearer ${token}`, + "User-Agent": RIPPLING_USER_AGENT, +}); + +const stringifyQuery = ( + query: Record, +): string => { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value === undefined || value === null) continue; + params.set(key, String(value)); + } + const encoded = params.toString(); + return encoded ? `?${encoded}` : ""; +}; + +// Rippling embeds error details in `{ message }` or `{ detail }` JSON +// bodies depending on which subsystem rejected the request. Drain the +// response body once and surface whichever shape exists. +const extractErrorMessage = async (response: Response): Promise => { + try { + const cloned = response.clone(); + const data = (await cloned.json()) as + | { message?: string; detail?: string; error?: string } + | undefined; + return data?.message ?? data?.detail ?? data?.error ?? response.statusText; + } catch { + return response.statusText; + } +}; + +export const ripplingRequest = async ( + token: string, + path: string, + query: Record = {}, +): Promise => { + const controller = new AbortController(); + const timeoutHandle = setTimeout( + () => controller.abort(), + RIPPLING_REQUEST_TIMEOUT_MS, + ); + + try { + const response = await fetch( + `${RIPPLING_API_BASE_URL}${path}${stringifyQuery(query)}`, + { + method: "GET", + headers: buildAuthHeader(token), + signal: controller.signal, + }, + ); + + if (!response.ok) { + const message = await extractErrorMessage(response); + throw new RipplingApiError(response.status, message); + } + + return (await response.json()) as Result; + } finally { + clearTimeout(timeoutHandle); + } +}; + +export const probeRipplingToken = async ( + token: string, +): Promise<{ ok: true } | { ok: false; status: number; message: string }> => { + try { + await ripplingRequest(token, "/companies/current"); + return { ok: true }; + } catch (error) { + if (error instanceof RipplingApiError) { + return { ok: false, status: error.status, message: error.message }; + } + const message = error instanceof Error ? error.message : String(error); + return { ok: false, status: 0, message }; + } +}; diff --git a/apps/api/server/mcp/shims/rippling.ts b/apps/api/server/mcp/shims/rippling.ts new file mode 100644 index 0000000..8d65fc2 --- /dev/null +++ b/apps/api/server/mcp/shims/rippling.ts @@ -0,0 +1,392 @@ +import { z } from "zod"; + +import { defineTool } from "../../agent/define-tool"; +import { toolErr, toolResult } from "../../agent/tool-result"; +import { + RIPPLING_DEFAULT_PAGE_LIMIT, + RIPPLING_MAX_PAGE_LIMIT, +} from "./constants"; +import { + probeRipplingToken, + RipplingApiError, + ripplingRequest, +} from "./rippling-client"; + +import type * as AI from "ai"; + +import type { PookieToolError } from "../../agent/tool-result"; + +interface RipplingToolContext { + token: string; +} + +const paginationSchema = { + limit: z + .number() + .int() + .min(1) + .max(RIPPLING_MAX_PAGE_LIMIT) + .optional() + .default(RIPPLING_DEFAULT_PAGE_LIMIT) + .describe( + `Page size. Max ${RIPPLING_MAX_PAGE_LIMIT}. Defaults to ${RIPPLING_DEFAULT_PAGE_LIMIT}.`, + ), + offset: z + .number() + .int() + .min(0) + .optional() + .describe("Number of records to skip — use for paging beyond the limit."), +}; + +const ripplingErrorToToolError = (caughtError: unknown): PookieToolError => { + if (caughtError instanceof RipplingApiError) { + if (caughtError.status === 401 || caughtError.status === 403) { + return toolErr( + "validation", + `Rippling rejected the API key (${caughtError.status}). ${caughtError.message}`.trim(), + { + code: "rippling_unauthorized", + instructions: + "ask the user to regenerate the key at https://app.rippling.com/developer/apiKeys and re-run `/mcp-add rippling `.", + }, + ); + } + if (caughtError.status === 429) { + return toolErr("validation", "Rippling rate limited the request", { + code: "rippling_rate_limited", + }); + } + return toolErr( + "unknown", + `Rippling API error (${caughtError.status}): ${caughtError.message}`, + { code: `rippling_${caughtError.status}` }, + ); + } + const message = + caughtError instanceof Error ? caughtError.message : String(caughtError); + return toolErr("unknown", `Rippling request failed: ${message}`); +}; + +const passthroughResult = () => z.unknown(); + +const summarizeArrayLength = (output: unknown): string => { + if (Array.isArray(output)) return `${output.length} record(s)`; + return "ok"; +}; + +const arrayResultModelOutput = ( + output: { type: "success"; result: unknown } | PookieToolError, +): string => { + if (output.type === "error") return output.error.message; + const summary = summarizeArrayLength(output.result); + return `${summary}\n\n${JSON.stringify(output.result, null, 2)}`; +}; + +const objectResultModelOutput = ( + output: { type: "success"; result: unknown } | PookieToolError, +): string => { + if (output.type === "error") return output.error.message; + return JSON.stringify(output.result, null, 2); +}; + +const callRippling = async ( + context: RipplingToolContext, + path: string, + query: Record = {}, +) => { + try { + const data = await ripplingRequest(context.token, path, query); + return toolResult(data); + } catch (caughtError) { + return ripplingErrorToToolError(caughtError); + } +}; + +const getCurrentCompanyTool = (context: RipplingToolContext) => + defineTool({ + description: + "Returns the Rippling company tied to this API key — name, domain, leave-policy ownership, etc. Cheap call; use as a sanity check before deeper queries.", + inputSchema: z.object({}), + resultSchema: passthroughResult(), + errorFallback: "failed to fetch current company", + execute: async () => callRippling(context, "/companies/current"), + toModelOutput: objectResultModelOutput, + }); + +const getCurrentUserTool = (context: RipplingToolContext) => + defineTool({ + description: + "Returns the user identity associated with this API key. Use to confirm whose context Pookie is acting under in Rippling.", + inputSchema: z.object({}), + resultSchema: passthroughResult(), + errorFallback: "failed to fetch current user", + execute: async () => callRippling(context, "/me"), + toModelOutput: objectResultModelOutput, + }); + +const getDepartmentsTool = (context: RipplingToolContext) => + defineTool({ + description: + "List all departments in Rippling. Returns id + name pairs you can match against an employee's `department` field.", + inputSchema: z.object({}), + resultSchema: passthroughResult(), + errorFallback: "failed to fetch departments", + execute: async () => callRippling(context, "/companies/departments"), + toModelOutput: arrayResultModelOutput, + }); + +const getWorkLocationsTool = (context: RipplingToolContext) => + defineTool({ + description: + "List the company's work locations (offices + remote). Use to map an employee's work location nickname to a full address.", + inputSchema: z.object({}), + resultSchema: passthroughResult(), + errorFallback: "failed to fetch work locations", + execute: async () => callRippling(context, "/companies/work_locations"), + toModelOutput: arrayResultModelOutput, + }); + +const getTeamsTool = (context: RipplingToolContext) => + defineTool({ + description: + "List all teams (sub-org groupings, distinct from departments). Returns id/name pairs.", + inputSchema: z.object({}), + resultSchema: passthroughResult(), + errorFallback: "failed to fetch teams", + execute: async () => callRippling(context, "/companies/teams"), + toModelOutput: arrayResultModelOutput, + }); + +const getLevelsTool = (context: RipplingToolContext) => + defineTool({ + description: + "List company levels (e.g. Manager, Senior, Executive). Useful for headcount-by-level questions.", + inputSchema: z.object({}), + resultSchema: passthroughResult(), + errorFallback: "failed to fetch levels", + execute: async () => callRippling(context, "/companies/levels"), + toModelOutput: arrayResultModelOutput, + }); + +const getCompanyLeaveTypesTool = (context: RipplingToolContext) => + defineTool({ + description: + "List the leave types configured for the company (vacation, sick, jury duty, custom policies).", + inputSchema: z.object({ + managedBy: z + .enum(["PTO", "LEAVES", "TILT"]) + .optional() + .describe( + "Filter to a specific Rippling leave subsystem. Omit for all.", + ), + }), + resultSchema: passthroughResult(), + errorFallback: "failed to fetch leave types", + execute: async ({ managedBy }) => + callRippling(context, "/companies/leave_types", { managedBy }), + toModelOutput: arrayResultModelOutput, + }); + +const getCustomFieldsTool = (context: RipplingToolContext) => + defineTool({ + description: + "List the company's custom employee fields and their types. Use to interpret the `customFields` blob on employee records.", + inputSchema: z.object({}), + resultSchema: passthroughResult(), + errorFallback: "failed to fetch custom fields", + execute: async () => callRippling(context, "/companies/custom_fields"), + toModelOutput: arrayResultModelOutput, + }); + +const listEmployeesTool = (context: RipplingToolContext) => + defineTool({ + description: + "List ACTIVE employees. Paginated — pass `limit`/`offset` to walk results. Returned fields depend on the API key scopes; only id, personalEmail, and roleState are guaranteed.", + inputSchema: z.object(paginationSchema), + resultSchema: passthroughResult(), + errorFallback: "failed to list employees", + execute: async ({ limit, offset }) => + callRippling(context, "/employees", { limit, offset }), + toModelOutput: arrayResultModelOutput, + }); + +const listEmployeesIncludingTerminatedTool = (context: RipplingToolContext) => + defineTool({ + description: + "List employees including TERMINATED ones. Use only when the question explicitly involves former employees — list_employees is cheaper otherwise.", + inputSchema: z.object(paginationSchema), + resultSchema: passthroughResult(), + errorFallback: "failed to list employees including terminated", + execute: async ({ limit, offset }) => + callRippling(context, "/employees/include_terminated", { limit, offset }), + toModelOutput: arrayResultModelOutput, + }); + +const getEmployeeTool = (context: RipplingToolContext) => + defineTool({ + description: + "Fetch a single employee by Rippling role ID. Get role IDs from list_employees results — DO NOT pass a Slack ID, email, or full name here.", + inputSchema: z.object({ + employeeId: z + .string() + .min(1) + .describe("Rippling employee role ID (the `id` field on Employee)."), + }), + resultSchema: passthroughResult(), + errorFallback: "failed to fetch employee", + execute: async ({ employeeId }) => + callRippling(context, `/employees/${encodeURIComponent(employeeId)}`), + toModelOutput: objectResultModelOutput, + }); + +const getLeaveBalanceTool = (context: RipplingToolContext) => + defineTool({ + description: + "Get leave balances for ONE employee (role). For all employees in one call, use list_leave_balances instead.", + inputSchema: z.object({ + role: z + .string() + .min(1) + .describe("Rippling role ID (employee.id) to fetch balances for."), + }), + resultSchema: passthroughResult(), + errorFallback: "failed to fetch leave balance", + execute: async ({ role }) => + callRippling(context, `/leave_balances/${encodeURIComponent(role)}`), + toModelOutput: objectResultModelOutput, + }); + +const listLeaveBalancesTool = (context: RipplingToolContext) => + defineTool({ + description: + "List leave balances across employees. Heavy call — paginate aggressively.", + inputSchema: z.object(paginationSchema), + resultSchema: passthroughResult(), + errorFallback: "failed to list leave balances", + execute: async ({ limit, offset }) => + callRippling(context, "/leave_balances", { limit, offset }), + toModelOutput: arrayResultModelOutput, + }); + +const listLeaveRequestsTool = (context: RipplingToolContext) => + defineTool({ + description: + "List leave requests with optional filters. The from/to filters check OVERLAP with the request's range — useful for 'who is out next week' style questions.", + inputSchema: z.object({ + role: z + .string() + .optional() + .describe( + "Filter to a specific employee's role ID (their `id` from list_employees).", + ), + status: z + .enum(["PENDING", "APPROVED", "REJECTED", "CANCELED"]) + .optional() + .describe("Filter by leave request status."), + startDate: z + .string() + .optional() + .describe( + "Match requests starting on this date (YYYY-MM-DD). Use `from`/`to` for ranges.", + ), + endDate: z + .string() + .optional() + .describe( + "Match requests ending on this date (YYYY-MM-DD). Use `from`/`to` for ranges.", + ), + from: z + .string() + .optional() + .describe( + "Range start (YYYY-MM-DD). Returns requests overlapping [from, to].", + ), + to: z + .string() + .optional() + .describe( + "Range end (YYYY-MM-DD). Returns requests overlapping [from, to].", + ), + leavePolicy: z + .string() + .optional() + .describe("Filter to a specific leave policy ID."), + ...paginationSchema, + }), + resultSchema: passthroughResult(), + errorFallback: "failed to list leave requests", + execute: async ({ + role, + status, + startDate, + endDate, + from, + to, + leavePolicy, + limit, + offset, + }) => + callRippling(context, "/leave_requests", { + role, + status, + startDate, + endDate, + from, + to, + leavePolicy, + limit, + offset, + }), + toModelOutput: arrayResultModelOutput, + }); + +export const buildRipplingTools = (token: string): Record => { + const context: RipplingToolContext = { token }; + return { + get_current_company: getCurrentCompanyTool(context), + get_current_user: getCurrentUserTool(context), + get_departments: getDepartmentsTool(context), + get_work_locations: getWorkLocationsTool(context), + get_teams: getTeamsTool(context), + get_levels: getLevelsTool(context), + get_company_leave_types: getCompanyLeaveTypesTool(context), + get_custom_fields: getCustomFieldsTool(context), + list_employees: listEmployeesTool(context), + list_employees_including_terminated: + listEmployeesIncludingTerminatedTool(context), + get_employee: getEmployeeTool(context), + get_leave_balance: getLeaveBalanceTool(context), + list_leave_balances: listLeaveBalancesTool(context), + list_leave_requests: listLeaveRequestsTool(context), + }; +}; + +export interface RipplingShimValidationResult { + ok: boolean; + toolCount: number; + message?: string; +} + +export const validateRipplingShim = async ( + token: string, +): Promise => { + const probe = await probeRipplingToken(token); + const toolCount = Object.keys(buildRipplingTools(token)).length; + + if (probe.ok) return { ok: true, toolCount }; + + if (probe.status === 401 || probe.status === 403) { + return { + ok: false, + toolCount, + message: `Rippling rejected the API key (${probe.status}): ${probe.message}. regenerate at https://app.rippling.com/developer/apiKeys.`, + }; + } + + return { + ok: false, + toolCount, + message: `Rippling probe failed (${probe.status || "network"}): ${probe.message}`, + }; +}; diff --git a/apps/api/tests/rippling-shim.test.ts b/apps/api/tests/rippling-shim.test.ts new file mode 100644 index 0000000..6f85a01 --- /dev/null +++ b/apps/api/tests/rippling-shim.test.ts @@ -0,0 +1,217 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { MCP_PRESETS, resolveShimName } from "../server/mcp/presets"; +import { resolveShim } from "../server/mcp/shims"; +import { RIPPLING_API_BASE_URL } from "../server/mcp/shims/constants"; +import { + buildRipplingTools, + validateRipplingShim, +} from "../server/mcp/shims/rippling"; + +import type * as AI from "ai"; + +interface ToolSuccess { + type: "success"; + result: T; +} + +interface ToolFailure { + type: "error"; + error: { code?: string; message: string; instructions?: string }; +} + +type ToolOutcome = ToolSuccess | ToolFailure; + +const runTool = async ( + tool: AI.Tool, + input: Record, + toolCallId: string, +): Promise> => { + if (!tool.execute) throw new Error("tool has no execute fn"); + const outcome = await tool.execute(input, { toolCallId, messages: [] }); + return outcome as ToolOutcome; +}; + +interface FetchMockResponse { + ok: boolean; + status: number; + statusText: string; + body: unknown; +} + +const buildMockResponse = (response: FetchMockResponse) => ({ + ok: response.ok, + status: response.status, + statusText: response.statusText, + json: () => Promise.resolve(response.body), + clone() { + return buildMockResponse(response); + }, +}); + +const stubFetchOnce = (response: FetchMockResponse) => { + const fetchSpy = vi.fn().mockResolvedValue(buildMockResponse(response)); + vi.stubGlobal("fetch", fetchSpy); + return fetchSpy; +}; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("rippling preset wiring", () => { + it("registers rippling as a token-based shim preset", () => { + const preset = MCP_PRESETS.rippling; + expect(preset).toBeDefined(); + expect(preset.authType).toBe("token"); + expect(preset.shim).toBe("rippling"); + expect(resolveShimName("rippling")).toBe("rippling"); + }); + + it("exposes a shim entry for rippling", () => { + const shim = resolveShim("rippling"); + expect(shim).toBeDefined(); + expect(typeof shim?.buildTools).toBe("function"); + expect(typeof shim?.validate).toBe("function"); + }); +}); + +describe("buildRipplingTools", () => { + it("returns the full HR/leave toolset", () => { + const tools = buildRipplingTools("test-token"); + const expectedTools = [ + "get_current_company", + "get_current_user", + "get_departments", + "get_work_locations", + "get_teams", + "get_levels", + "get_company_leave_types", + "get_custom_fields", + "list_employees", + "list_employees_including_terminated", + "get_employee", + "get_leave_balance", + "list_leave_balances", + "list_leave_requests", + ]; + for (const toolName of expectedTools) { + expect(tools[toolName], `missing tool ${toolName}`).toBeDefined(); + } + expect(Object.keys(tools)).toHaveLength(expectedTools.length); + }); + + it("hits the correct Rippling endpoint with bearer auth and pagination", async () => { + const employees = [ + { id: "role_123", workEmail: "alice@example.com", roleState: "ACTIVE" }, + ]; + const fetchSpy = stubFetchOnce({ + ok: true, + status: 200, + statusText: "OK", + body: employees, + }); + + const tools = buildRipplingTools("rippling-key"); + const result = await runTool( + tools.list_employees, + { limit: 25, offset: 50 }, + "call_1", + ); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + const [url, init] = fetchSpy.mock.calls[0]; + expect(url).toBe(`${RIPPLING_API_BASE_URL}/employees?limit=25&offset=50`); + expect(init.method).toBe("GET"); + expect(init.headers.Authorization).toBe("Bearer rippling-key"); + + expect(result.type).toBe("success"); + if (result.type !== "success") return; + expect(result.result).toEqual(employees); + }); + + it("maps a 401 from Rippling to a validation error with regen instructions", async () => { + stubFetchOnce({ + ok: false, + status: 401, + statusText: "Unauthorized", + body: { message: "Invalid API key" }, + }); + + const tools = buildRipplingTools("bad-key"); + const result = await runTool(tools.get_current_company, {}, "call_2"); + expect(result.type).toBe("error"); + if (result.type !== "error") return; + expect(result.error.code).toBe("rippling_unauthorized"); + expect(result.error.instructions).toContain( + "https://app.rippling.com/developer/apiKeys", + ); + }); + + it("forwards leave-request filters as query params", async () => { + const fetchSpy = stubFetchOnce({ + ok: true, + status: 200, + statusText: "OK", + body: [], + }); + + const tools = buildRipplingTools("rippling-key"); + await runTool( + tools.list_leave_requests, + { + status: "APPROVED", + from: "2026-05-01", + to: "2026-05-31", + limit: 10, + }, + "call_3", + ); + + const [url] = fetchSpy.mock.calls[0]; + const parsed = new URL(url); + expect(parsed.pathname).toBe("/platform/api/leave_requests"); + expect(parsed.searchParams.get("status")).toBe("APPROVED"); + expect(parsed.searchParams.get("from")).toBe("2026-05-01"); + expect(parsed.searchParams.get("to")).toBe("2026-05-31"); + expect(parsed.searchParams.get("limit")).toBe("10"); + // omitted filters must NOT appear as the literal string "undefined" + expect(parsed.searchParams.has("role")).toBe(false); + expect(parsed.searchParams.has("offset")).toBe(false); + }); +}); + +describe("validateRipplingShim", () => { + beforeEach(() => { + vi.unstubAllGlobals(); + }); + + it("returns ok with the tool count on a successful probe", async () => { + stubFetchOnce({ + ok: true, + status: 200, + statusText: "OK", + body: { id: "company_123", name: "Acme" }, + }); + + const result = await validateRipplingShim("good-token"); + expect(result.ok).toBe(true); + expect(result.toolCount).toBeGreaterThan(0); + }); + + it("returns a regen-instruction message on 401", async () => { + stubFetchOnce({ + ok: false, + status: 401, + statusText: "Unauthorized", + body: { detail: "API key revoked" }, + }); + + const result = await validateRipplingShim("bad-token"); + expect(result.ok).toBe(false); + expect(result.message).toContain("rejected the API key"); + expect(result.message).toContain( + "https://app.rippling.com/developer/apiKeys", + ); + }); +}); From dffa84c57b78e47048602729a8a646b33671221f Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Wed, 6 May 2026 22:23:16 -0700 Subject: [PATCH 2/3] refactor(mcp): address review on the rippling shim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hardens the shim path against context-window blowups, makes the agent guidance accurate for token-only presets, and adds the integration-glue tests the original PR was missing. - Project list endpoints (list_employees, list_employees_including_terminated, list_leave_requests) before handing the JSON to the model. Drops photo blobs, workSchedule, customFields, per-day breakdowns, and timestamp metadata — fields the agent rarely needs at list level and that can push a single call past 50k characters. - Trim the search subagent's view of the Rippling toolset: drop list_employees_including_terminated, list_leave_balances, and get_leave_balance from `searchTools`. The main agent can still call them when the user is explicit. - Map AbortController-driven timeouts to a structured tool error (`code: rippling_timeout`) instead of leaking "This operation was aborted" to the model. - System reminder now flags `authType: "token"` presets with a "(requires API key)" tag and the correct invocation (`/mcp-add rippling `), so the agent stops telling users to run a command that immediately rejects them. - Replace the hard-coded `app.rippling.com/developer/apiKeys` URL with a stable Rippling developer docs link, captured in a single constant used by both the preset registry and the shim's error messages. - Warn admins when adding any shim preset at `--global` scope that the shared API key gives every workspace member visibility limited only by the key's own data scopes. - Extract `partitionShimConfigs` and `tryRegisterShim` into pure exported helpers, and cover them with tests for: alias resolution (rippling_finance), missing-token error path, fall-through for non-shim configs, and 401-on-validate. - Cleanup: hoist shared `RIPPLING_RESULT_SCHEMA`, drop unused `RipplingRequestError` interface, tighten `summarizeArrayLength` into `formatArrayOutput`. --- apps/api/server/agent/system-reminder.ts | 21 ++- apps/api/server/mcp/client.ts | 114 +++++++++--- apps/api/server/mcp/handlers.ts | 7 +- apps/api/server/mcp/presets.ts | 14 +- apps/api/server/mcp/shims/constants.ts | 6 + apps/api/server/mcp/shims/rippling-client.ts | 53 ++++-- apps/api/server/mcp/shims/rippling.ts | 173 ++++++++++++++----- apps/api/tests/mcp-shim-integration.test.ts | 159 +++++++++++++++++ apps/api/tests/rippling-shim.test.ts | 105 ++++++++++- apps/api/tests/system-reminder.test.ts | 12 +- 10 files changed, 563 insertions(+), 101 deletions(-) create mode 100644 apps/api/tests/mcp-shim-integration.test.ts diff --git a/apps/api/server/agent/system-reminder.ts b/apps/api/server/agent/system-reminder.ts index e3411c4..aa0cadf 100644 --- a/apps/api/server/agent/system-reminder.ts +++ b/apps/api/server/agent/system-reminder.ts @@ -61,6 +61,23 @@ const collectConnectedPresetNames = ( return names; }; +const presetAddInvocation = (preset: { + name: string; + authType?: string; +}): string => + preset.authType === "token" + ? `\`/mcp-add ${preset.name} \`` + : `\`/mcp-add ${preset.name}\``; + +const describeOffPreset = (preset: { + name: string; + description: string; + authType?: string; +}): string => { + const tokenSuffix = preset.authType === "token" ? " (requires API key)" : ""; + return `- ${preset.name}: ${preset.description}${tokenSuffix} → ${presetAddInvocation(preset)}`; +}; + const buildAvailableMcpPresetsSection = ( connectedServers: McpServerSummary[] | undefined, ): string | undefined => { @@ -68,11 +85,11 @@ const buildAvailableMcpPresetsSection = ( const offPresetLines = Object.values(MCP_PRESETS) .filter((preset) => !connectedPresets.has(preset.name)) - .map((preset) => `- ${preset.name}: ${preset.description}`); + .map(describeOffPreset); if (offPresetLines.length === 0) return undefined; - return `\nnot connected. if the user wants one of these capabilities, offer \`/mcp-add \` rather than faking the call.\n${offPresetLines.join("\n")}\n`; + return `\nnot connected. if the user wants one of these capabilities, offer the matching invocation rather than faking the call. token presets need an API key inline.\n${offPresetLines.join("\n")}\n`; }; export interface SystemReminderContext { diff --git a/apps/api/server/mcp/client.ts b/apps/api/server/mcp/client.ts index 7affc32..6f53fef 100644 --- a/apps/api/server/mcp/client.ts +++ b/apps/api/server/mcp/client.ts @@ -188,7 +188,10 @@ const ensureOAuth = async ( // transport entirely. Register/open paths short-circuit to the shim's // `buildTools` so the same `mcp__` naming and prompt routing // apply uniformly. -const tryRegisterShim = async ( +const missingShimTokenMessage = (serverName: string): string => + `${serverName} requires an API key. re-run \`/mcp-add ${serverName} \``; + +export const tryRegisterShim = async ( config: McpServerConfig, ): Promise => { const shimName = resolveShimName(config.name); @@ -198,9 +201,7 @@ const tryRegisterShim = async ( if (!shim) return null; if (!config.token) { - throw new Error( - `${config.name} requires an API key. re-run \`/mcp-add ${config.name} \``, - ); + throw new Error(missingShimTokenMessage(config.name)); } const validation = await shim.validate(config.token); @@ -211,6 +212,79 @@ const tryRegisterShim = async ( return { connected: true, toolCount: validation.toolCount }; }; +const prefixToolName = (serverName: string, toolName: string): string => + `mcp${MCP_TOOL_NAME_SEPARATOR}${serverName}${MCP_TOOL_NAME_SEPARATOR}${toolName}`; + +export interface ShimIngestionEntry { + config: McpServerConfig; + // null + error means the shim resolved but couldn't be loaded (missing + // token, malformed registry entry); null + no error means the config + // does not resolve to a shim and should fall through to the MCP + // transport path. + tools: Record | null; + error?: McpServerError; +} + +const tryBuildShimTools = ( + config: McpServerConfig, +): ShimIngestionEntry | null => { + const shimName = resolveShimName(config.name); + if (!shimName) return null; + + const shim = resolveShim(shimName); + if (!shim) return null; + + if (!config.token) { + return { + config, + tools: null, + error: { + name: config.name, + message: missingShimTokenMessage(config.name), + }, + }; + } + + try { + return { config, tools: shim.buildTools(config.token) }; + } catch (caughtError) { + const message = + caughtError instanceof Error ? caughtError.message : String(caughtError); + console.warn( + `[mcp-shim] failed to build tools for ${config.name}:`, + caughtError, + ); + return { + config, + tools: null, + error: { name: config.name, message }, + }; + } +}; + +export interface PartitionedConfigs { + shimEntries: ShimIngestionEntry[]; + transportConfigs: McpServerConfig[]; +} + +export const partitionShimConfigs = ( + configs: McpServerConfig[], +): PartitionedConfigs => { + const shimEntries: ShimIngestionEntry[] = []; + const transportConfigs: McpServerConfig[] = []; + + for (const config of configs) { + const entry = tryBuildShimTools(config); + if (entry === null) { + transportConfigs.push(config); + } else { + shimEntries.push(entry); + } + } + + return { shimEntries, transportConfigs }; +}; + export const tryRegister = async ( userId: string, config: McpServerConfig, @@ -356,36 +430,20 @@ export const openMcpTools = async ( ): void => { let toolCount = 0; for (const [toolName, tool] of Object.entries(toolSet)) { - const prefixedName = `mcp${MCP_TOOL_NAME_SEPARATOR}${config.name}${MCP_TOOL_NAME_SEPARATOR}${toolName}`; - allTools[prefixedName] = tool; + allTools[prefixToolName(config.name, toolName)] = tool; toolCount++; } serverSummaries.push({ name: config.name, toolCount }); }; - const ingestShimConfig = (config: McpServerConfig): boolean => { - const shimName = resolveShimName(config.name); - if (!shimName) return false; - const shim = resolveShim(shimName); - if (!shim) return false; - if (!config.token) { - errors.push({ - name: config.name, - message: `${config.name} requires an API key. re-run \`/mcp-add ${config.name} \``, - }); - return true; - } - try { - registerToolSet(config, shim.buildTools(config.token)); - } catch (error) { - classifyError(config, error); + const { shimEntries, transportConfigs } = partitionShimConfigs(configs); + for (const entry of shimEntries) { + if (entry.tools) { + registerToolSet(entry.config, entry.tools); + } else if (entry.error) { + errors.push(entry.error); } - return true; - }; - - const transportConfigs = configs.filter( - (config) => !ingestShimConfig(config), - ); + } const connectionResults = await Promise.allSettled( transportConfigs.map(async (config) => { diff --git a/apps/api/server/mcp/handlers.ts b/apps/api/server/mcp/handlers.ts index 0159439..0c40d5b 100644 --- a/apps/api/server/mcp/handlers.ts +++ b/apps/api/server/mcp/handlers.ts @@ -306,9 +306,14 @@ const handleAdd = async ( const result = await tryRegister(event.user.userId, config, teamId); if (result.connected) { + const presetDisplay = preset ? getPresetDisplayName(preset) : serverName; + const sharedKeyWarning = + scope.kind === "global" && preset?.shim + ? `\n\n⚠️ *${serverName}* runs at *global* scope, so every workspace member's questions will hit ${presetDisplay} using this single API key. Anyone whose query the agent routes here gets visibility limited only by the key's own scopes. If that's not the intent, remove with \`/mcp-remove ${serverName} --global\` and add it as personal scope (default) or to specific channels with \`--channel\` instead.` + : ""; await reply( event, - `connected to *${serverName}* (${scopeLabel(scope)}) — ${result.toolCount} tool${result.toolCount === 1 ? "" : "s"} available.`, + `connected to *${serverName}* (${scopeLabel(scope)}) — ${result.toolCount} tool${result.toolCount === 1 ? "" : "s"} available.${sharedKeyWarning}`, ); } else if (result.authorizationUrl) { const authorizationStartUrl = await createAuthorizationStartUrl( diff --git a/apps/api/server/mcp/presets.ts b/apps/api/server/mcp/presets.ts index 4afe045..8488923 100644 --- a/apps/api/server/mcp/presets.ts +++ b/apps/api/server/mcp/presets.ts @@ -247,22 +247,24 @@ export const MCP_PRESETS: Record = { url: "https://api.rippling.com", description: "hr, employees, leave", authType: "token", - tokenHelpUrl: "https://app.rippling.com/developer/apiKeys", + tokenHelpUrl: "https://developer.rippling.com/", shim: "rippling", + // Subset deliberately excludes the three heaviest paginated calls + // (list_employees_including_terminated, list_leave_balances, + // get_leave_balance). The search subagent runs sequential + // explorations and these endpoints can return tens of thousands of + // tokens of JSON — the main agent can still call them when the + // user explicitly asks about former employees or PTO balances. searchTools: [ "list_employees", "get_employee", - "list_employees_including_terminated", + "list_leave_requests", "get_current_company", "get_departments", - "get_work_locations", "get_teams", "get_levels", "get_company_leave_types", "get_current_user", - "list_leave_balances", - "get_leave_balance", - "list_leave_requests", ], exampleQueries: [ "who's out on leave this week according to rippling?", diff --git a/apps/api/server/mcp/shims/constants.ts b/apps/api/server/mcp/shims/constants.ts index 3d387b5..a88ac1c 100644 --- a/apps/api/server/mcp/shims/constants.ts +++ b/apps/api/server/mcp/shims/constants.ts @@ -6,3 +6,9 @@ export const RIPPLING_MAX_PAGE_LIMIT = 100; export const RIPPLING_REQUEST_TIMEOUT_MS = 15000; export const RIPPLING_USER_AGENT = "pookie-rippling-shim/1.0"; + +// Single source of truth for the help link. Points at Rippling's +// developer docs hub, which is a stable Help Center URL that walks +// users to the right "Create an API key" surface for their tenant. +// Avoids hard-coding `app.rippling.com/...` paths whose UI may change. +export const RIPPLING_TOKEN_HELP_URL = "https://developer.rippling.com/"; diff --git a/apps/api/server/mcp/shims/rippling-client.ts b/apps/api/server/mcp/shims/rippling-client.ts index fd398ba..60ec930 100644 --- a/apps/api/server/mcp/shims/rippling-client.ts +++ b/apps/api/server/mcp/shims/rippling-client.ts @@ -4,11 +4,6 @@ import { RIPPLING_USER_AGENT, } from "./constants"; -export interface RipplingRequestError { - status: number; - message: string; -} - export class RipplingApiError extends Error { readonly status: number; @@ -19,6 +14,16 @@ export class RipplingApiError extends Error { } } +export class RipplingTimeoutError extends Error { + readonly timeoutMs: number; + + constructor(timeoutMs: number) { + super(`Rippling request timed out after ${timeoutMs}ms`); + this.timeoutMs = timeoutMs; + this.name = "RipplingTimeoutError"; + } +} + const buildAuthHeader = (token: string): Record => ({ Accept: "application/json", Authorization: `Bearer ${token}`, @@ -52,16 +57,27 @@ const extractErrorMessage = async (response: Response): Promise => { } }; +// Distinguish a timeout-driven AbortError from any other AbortError the +// runtime might surface, so callers can map it to a friendly tool error +// instead of the cryptic "This operation was aborted" default. We use a +// shared flag instead of `signal.reason` because Node's undici fetch was +// inconsistent about plumbing reason through prior to v20. +const isTimeoutAbort = (error: unknown, didTimeout: boolean): boolean => + didTimeout && + ((error instanceof Error && error.name === "AbortError") || + (typeof DOMException !== "undefined" && error instanceof DOMException)); + export const ripplingRequest = async ( token: string, path: string, query: Record = {}, ): Promise => { const controller = new AbortController(); - const timeoutHandle = setTimeout( - () => controller.abort(), - RIPPLING_REQUEST_TIMEOUT_MS, - ); + let didTimeout = false; + const timeoutHandle = setTimeout(() => { + didTimeout = true; + controller.abort(); + }, RIPPLING_REQUEST_TIMEOUT_MS); try { const response = await fetch( @@ -79,14 +95,23 @@ export const ripplingRequest = async ( } return (await response.json()) as Result; + } catch (caughtError) { + if (isTimeoutAbort(caughtError, didTimeout)) { + throw new RipplingTimeoutError(RIPPLING_REQUEST_TIMEOUT_MS); + } + throw caughtError; } finally { clearTimeout(timeoutHandle); } }; +export type RipplingProbeResult = + | { ok: true } + | { ok: false; status: number; message: string; timedOut?: boolean }; + export const probeRipplingToken = async ( token: string, -): Promise<{ ok: true } | { ok: false; status: number; message: string }> => { +): Promise => { try { await ripplingRequest(token, "/companies/current"); return { ok: true }; @@ -94,6 +119,14 @@ export const probeRipplingToken = async ( if (error instanceof RipplingApiError) { return { ok: false, status: error.status, message: error.message }; } + if (error instanceof RipplingTimeoutError) { + return { + ok: false, + status: 0, + message: error.message, + timedOut: true, + }; + } const message = error instanceof Error ? error.message : String(error); return { ok: false, status: 0, message }; } diff --git a/apps/api/server/mcp/shims/rippling.ts b/apps/api/server/mcp/shims/rippling.ts index 8d65fc2..cb47028 100644 --- a/apps/api/server/mcp/shims/rippling.ts +++ b/apps/api/server/mcp/shims/rippling.ts @@ -5,11 +5,13 @@ import { toolErr, toolResult } from "../../agent/tool-result"; import { RIPPLING_DEFAULT_PAGE_LIMIT, RIPPLING_MAX_PAGE_LIMIT, + RIPPLING_TOKEN_HELP_URL, } from "./constants"; import { probeRipplingToken, RipplingApiError, ripplingRequest, + RipplingTimeoutError, } from "./rippling-client"; import type * as AI from "ai"; @@ -20,6 +22,53 @@ interface RipplingToolContext { token: string; } +// Stripped from list responses before we hand them to the model. These +// fields are either heavy (workSchedule, customFields, photo blobs) +// or rarely useful at list level (drill in via get_employee for the +// full record). Without this, a single list_employees call with a 50- +// employee page can be 50–80k characters of JSON. +const COMPACT_EMPLOYEE_DROP_FIELDS = [ + "photo", + "smallPhoto", + "workSchedule", + "customFields", + "preferredFirstName", + "preferredLastName", + "spokeId", + "identifiedGender", + "isInternational", +] as const; + +const COMPACT_LEAVE_REQUEST_DROP_FIELDS = [ + "dates", + "partialDays", + "createdAt", + "updatedAt", + "startDateStartTime", + "endDateEndTime", + "startDateCustomHours", + "endDateCustomHours", +] as const; + +const stripFields = >( + record: T, + drop: ReadonlyArray, +): Record => { + const compact: Record = { ...record }; + for (const field of drop) delete compact[field]; + return compact; +}; + +const compactRecord = + (drop: ReadonlyArray) => + (item: unknown): unknown => { + if (!item || typeof item !== "object" || Array.isArray(item)) return item; + return stripFields(item as Record, drop); + }; + +const compactEmployee = compactRecord(COMPACT_EMPLOYEE_DROP_FIELDS); +const compactLeaveRequest = compactRecord(COMPACT_LEAVE_REQUEST_DROP_FIELDS); + const paginationSchema = { limit: z .number() @@ -40,6 +89,13 @@ const paginationSchema = { }; const ripplingErrorToToolError = (caughtError: unknown): PookieToolError => { + if (caughtError instanceof RipplingTimeoutError) { + return toolErr( + "validation", + `Rippling request timed out after ${caughtError.timeoutMs}ms — try a smaller limit or narrower filter`, + { code: "rippling_timeout" }, + ); + } if (caughtError instanceof RipplingApiError) { if (caughtError.status === 401 || caughtError.status === 403) { return toolErr( @@ -47,8 +103,7 @@ const ripplingErrorToToolError = (caughtError: unknown): PookieToolError => { `Rippling rejected the API key (${caughtError.status}). ${caughtError.message}`.trim(), { code: "rippling_unauthorized", - instructions: - "ask the user to regenerate the key at https://app.rippling.com/developer/apiKeys and re-run `/mcp-add rippling `.", + instructions: `ask the user to regenerate the key (${RIPPLING_TOKEN_HELP_URL}) and re-run \`/mcp-add rippling \`.`, }, ); } @@ -68,28 +123,35 @@ const ripplingErrorToToolError = (caughtError: unknown): PookieToolError => { return toolErr("unknown", `Rippling request failed: ${message}`); }; -const passthroughResult = () => z.unknown(); +const RIPPLING_RESULT_SCHEMA = z.unknown(); -const summarizeArrayLength = (output: unknown): string => { - if (Array.isArray(output)) return `${output.length} record(s)`; - return "ok"; -}; +type ToolOutcome = { type: "success"; result: unknown } | PookieToolError; -const arrayResultModelOutput = ( - output: { type: "success"; result: unknown } | PookieToolError, +const formatArrayOutput = ( + output: ToolOutcome, + recordLabel: string, + project?: (record: unknown) => unknown, ): string => { if (output.type === "error") return output.error.message; - const summary = summarizeArrayLength(output.result); - return `${summary}\n\n${JSON.stringify(output.result, null, 2)}`; + const data = output.result; + if (!Array.isArray(data)) return JSON.stringify(data, null, 2); + const projected = project ? data.map(project) : data; + return `${data.length} ${recordLabel}\n\n${JSON.stringify(projected, null, 2)}`; }; -const objectResultModelOutput = ( - output: { type: "success"; result: unknown } | PookieToolError, -): string => { +const formatObjectOutput = (output: ToolOutcome): string => { if (output.type === "error") return output.error.message; return JSON.stringify(output.result, null, 2); }; +const arrayModelOutput = (recordLabel: string) => (output: ToolOutcome) => + formatArrayOutput(output, recordLabel); + +const arrayModelOutputWithProjection = + (recordLabel: string, project: (record: unknown) => unknown) => + (output: ToolOutcome) => + formatArrayOutput(output, recordLabel, project); + const callRippling = async ( context: RipplingToolContext, path: string, @@ -108,10 +170,10 @@ const getCurrentCompanyTool = (context: RipplingToolContext) => description: "Returns the Rippling company tied to this API key — name, domain, leave-policy ownership, etc. Cheap call; use as a sanity check before deeper queries.", inputSchema: z.object({}), - resultSchema: passthroughResult(), + resultSchema: RIPPLING_RESULT_SCHEMA, errorFallback: "failed to fetch current company", execute: async () => callRippling(context, "/companies/current"), - toModelOutput: objectResultModelOutput, + toModelOutput: formatObjectOutput, }); const getCurrentUserTool = (context: RipplingToolContext) => @@ -119,10 +181,10 @@ const getCurrentUserTool = (context: RipplingToolContext) => description: "Returns the user identity associated with this API key. Use to confirm whose context Pookie is acting under in Rippling.", inputSchema: z.object({}), - resultSchema: passthroughResult(), + resultSchema: RIPPLING_RESULT_SCHEMA, errorFallback: "failed to fetch current user", execute: async () => callRippling(context, "/me"), - toModelOutput: objectResultModelOutput, + toModelOutput: formatObjectOutput, }); const getDepartmentsTool = (context: RipplingToolContext) => @@ -130,10 +192,10 @@ const getDepartmentsTool = (context: RipplingToolContext) => description: "List all departments in Rippling. Returns id + name pairs you can match against an employee's `department` field.", inputSchema: z.object({}), - resultSchema: passthroughResult(), + resultSchema: RIPPLING_RESULT_SCHEMA, errorFallback: "failed to fetch departments", execute: async () => callRippling(context, "/companies/departments"), - toModelOutput: arrayResultModelOutput, + toModelOutput: arrayModelOutput("department(s)"), }); const getWorkLocationsTool = (context: RipplingToolContext) => @@ -141,10 +203,10 @@ const getWorkLocationsTool = (context: RipplingToolContext) => description: "List the company's work locations (offices + remote). Use to map an employee's work location nickname to a full address.", inputSchema: z.object({}), - resultSchema: passthroughResult(), + resultSchema: RIPPLING_RESULT_SCHEMA, errorFallback: "failed to fetch work locations", execute: async () => callRippling(context, "/companies/work_locations"), - toModelOutput: arrayResultModelOutput, + toModelOutput: arrayModelOutput("location(s)"), }); const getTeamsTool = (context: RipplingToolContext) => @@ -152,10 +214,10 @@ const getTeamsTool = (context: RipplingToolContext) => description: "List all teams (sub-org groupings, distinct from departments). Returns id/name pairs.", inputSchema: z.object({}), - resultSchema: passthroughResult(), + resultSchema: RIPPLING_RESULT_SCHEMA, errorFallback: "failed to fetch teams", execute: async () => callRippling(context, "/companies/teams"), - toModelOutput: arrayResultModelOutput, + toModelOutput: arrayModelOutput("team(s)"), }); const getLevelsTool = (context: RipplingToolContext) => @@ -163,10 +225,10 @@ const getLevelsTool = (context: RipplingToolContext) => description: "List company levels (e.g. Manager, Senior, Executive). Useful for headcount-by-level questions.", inputSchema: z.object({}), - resultSchema: passthroughResult(), + resultSchema: RIPPLING_RESULT_SCHEMA, errorFallback: "failed to fetch levels", execute: async () => callRippling(context, "/companies/levels"), - toModelOutput: arrayResultModelOutput, + toModelOutput: arrayModelOutput("level(s)"), }); const getCompanyLeaveTypesTool = (context: RipplingToolContext) => @@ -181,11 +243,11 @@ const getCompanyLeaveTypesTool = (context: RipplingToolContext) => "Filter to a specific Rippling leave subsystem. Omit for all.", ), }), - resultSchema: passthroughResult(), + resultSchema: RIPPLING_RESULT_SCHEMA, errorFallback: "failed to fetch leave types", execute: async ({ managedBy }) => callRippling(context, "/companies/leave_types", { managedBy }), - toModelOutput: arrayResultModelOutput, + toModelOutput: arrayModelOutput("leave type(s)"), }); const getCustomFieldsTool = (context: RipplingToolContext) => @@ -193,51 +255,57 @@ const getCustomFieldsTool = (context: RipplingToolContext) => description: "List the company's custom employee fields and their types. Use to interpret the `customFields` blob on employee records.", inputSchema: z.object({}), - resultSchema: passthroughResult(), + resultSchema: RIPPLING_RESULT_SCHEMA, errorFallback: "failed to fetch custom fields", execute: async () => callRippling(context, "/companies/custom_fields"), - toModelOutput: arrayResultModelOutput, + toModelOutput: arrayModelOutput("custom field(s)"), }); const listEmployeesTool = (context: RipplingToolContext) => defineTool({ description: - "List ACTIVE employees. Paginated — pass `limit`/`offset` to walk results. Returned fields depend on the API key scopes; only id, personalEmail, and roleState are guaranteed.", + "List ACTIVE employees. Paginated — pass `limit`/`offset` to walk results. Returned fields depend on the API key scopes; only id, personalEmail, and roleState are guaranteed. Heavy fields (workSchedule, customFields, photos) are stripped from the list view; use get_employee for the full record.", inputSchema: z.object(paginationSchema), - resultSchema: passthroughResult(), + resultSchema: RIPPLING_RESULT_SCHEMA, errorFallback: "failed to list employees", execute: async ({ limit, offset }) => callRippling(context, "/employees", { limit, offset }), - toModelOutput: arrayResultModelOutput, + toModelOutput: arrayModelOutputWithProjection( + "employee(s)", + compactEmployee, + ), }); const listEmployeesIncludingTerminatedTool = (context: RipplingToolContext) => defineTool({ description: - "List employees including TERMINATED ones. Use only when the question explicitly involves former employees — list_employees is cheaper otherwise.", + "List employees including TERMINATED ones. Use only when the question explicitly involves former employees — list_employees is cheaper otherwise. Same field stripping as list_employees applies.", inputSchema: z.object(paginationSchema), - resultSchema: passthroughResult(), + resultSchema: RIPPLING_RESULT_SCHEMA, errorFallback: "failed to list employees including terminated", execute: async ({ limit, offset }) => callRippling(context, "/employees/include_terminated", { limit, offset }), - toModelOutput: arrayResultModelOutput, + toModelOutput: arrayModelOutputWithProjection( + "employee(s)", + compactEmployee, + ), }); const getEmployeeTool = (context: RipplingToolContext) => defineTool({ description: - "Fetch a single employee by Rippling role ID. Get role IDs from list_employees results — DO NOT pass a Slack ID, email, or full name here.", + "Fetch a single employee by Rippling role ID. Get role IDs from list_employees results — DO NOT pass a Slack ID, email, or full name here. Returns the full record (including custom fields and work schedule).", inputSchema: z.object({ employeeId: z .string() .min(1) .describe("Rippling employee role ID (the `id` field on Employee)."), }), - resultSchema: passthroughResult(), + resultSchema: RIPPLING_RESULT_SCHEMA, errorFallback: "failed to fetch employee", execute: async ({ employeeId }) => callRippling(context, `/employees/${encodeURIComponent(employeeId)}`), - toModelOutput: objectResultModelOutput, + toModelOutput: formatObjectOutput, }); const getLeaveBalanceTool = (context: RipplingToolContext) => @@ -250,11 +318,11 @@ const getLeaveBalanceTool = (context: RipplingToolContext) => .min(1) .describe("Rippling role ID (employee.id) to fetch balances for."), }), - resultSchema: passthroughResult(), + resultSchema: RIPPLING_RESULT_SCHEMA, errorFallback: "failed to fetch leave balance", execute: async ({ role }) => callRippling(context, `/leave_balances/${encodeURIComponent(role)}`), - toModelOutput: objectResultModelOutput, + toModelOutput: formatObjectOutput, }); const listLeaveBalancesTool = (context: RipplingToolContext) => @@ -262,17 +330,17 @@ const listLeaveBalancesTool = (context: RipplingToolContext) => description: "List leave balances across employees. Heavy call — paginate aggressively.", inputSchema: z.object(paginationSchema), - resultSchema: passthroughResult(), + resultSchema: RIPPLING_RESULT_SCHEMA, errorFallback: "failed to list leave balances", execute: async ({ limit, offset }) => callRippling(context, "/leave_balances", { limit, offset }), - toModelOutput: arrayResultModelOutput, + toModelOutput: arrayModelOutput("leave balance(s)"), }); const listLeaveRequestsTool = (context: RipplingToolContext) => defineTool({ description: - "List leave requests with optional filters. The from/to filters check OVERLAP with the request's range — useful for 'who is out next week' style questions.", + "List leave requests with optional filters. The from/to filters check OVERLAP with the request's range — useful for 'who is out next week' style questions. Per-day breakdowns and timestamp metadata are stripped from list view.", inputSchema: z.object({ role: z .string() @@ -314,7 +382,7 @@ const listLeaveRequestsTool = (context: RipplingToolContext) => .describe("Filter to a specific leave policy ID."), ...paginationSchema, }), - resultSchema: passthroughResult(), + resultSchema: RIPPLING_RESULT_SCHEMA, errorFallback: "failed to list leave requests", execute: async ({ role, @@ -338,7 +406,10 @@ const listLeaveRequestsTool = (context: RipplingToolContext) => limit, offset, }), - toModelOutput: arrayResultModelOutput, + toModelOutput: arrayModelOutputWithProjection( + "leave request(s)", + compactLeaveRequest, + ), }); export const buildRipplingTools = (token: string): Record => { @@ -376,11 +447,19 @@ export const validateRipplingShim = async ( if (probe.ok) return { ok: true, toolCount }; + if (probe.timedOut) { + return { + ok: false, + toolCount, + message: `${probe.message} — Rippling didn't respond in time. retry, or check ${RIPPLING_TOKEN_HELP_URL} if the issue persists.`, + }; + } + if (probe.status === 401 || probe.status === 403) { return { ok: false, toolCount, - message: `Rippling rejected the API key (${probe.status}): ${probe.message}. regenerate at https://app.rippling.com/developer/apiKeys.`, + message: `Rippling rejected the API key (${probe.status}): ${probe.message}. regenerate at ${RIPPLING_TOKEN_HELP_URL}.`, }; } diff --git a/apps/api/tests/mcp-shim-integration.test.ts b/apps/api/tests/mcp-shim-integration.test.ts new file mode 100644 index 0000000..8136439 --- /dev/null +++ b/apps/api/tests/mcp-shim-integration.test.ts @@ -0,0 +1,159 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { partitionShimConfigs, tryRegisterShim } from "../server/mcp/client"; + +import type { McpServerConfig } from "../server/mcp/store"; + +const buildMockResponse = (response: { + ok: boolean; + status: number; + statusText: string; + body: unknown; +}) => ({ + ok: response.ok, + status: response.status, + statusText: response.statusText, + json: () => Promise.resolve(response.body), + clone() { + return buildMockResponse(response); + }, +}); + +const stubFetchOk = (body: unknown) => { + const fetchSpy = vi + .fn() + .mockResolvedValue( + buildMockResponse({ ok: true, status: 200, statusText: "OK", body }), + ); + vi.stubGlobal("fetch", fetchSpy); + return fetchSpy; +}; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +const userScope: McpServerConfig["scope"] = { + kind: "user", + userId: "U_TEST", + teamId: "T_TEST", +}; + +const ripplingConfig = ( + overrides: Partial = {}, +): McpServerConfig => ({ + name: "rippling", + url: "https://api.rippling.com", + scope: userScope, + createdBy: "U_TEST", + createdAt: 0, + token: "rip_test_token", + ...overrides, +}); + +const customConfig = (name: string): McpServerConfig => ({ + name, + url: "https://example.invalid/mcp", + scope: userScope, + createdBy: "U_TEST", + createdAt: 0, +}); + +describe("partitionShimConfigs", () => { + it("routes shim configs to a separate ingestion bucket", () => { + const configs = [ + ripplingConfig(), + customConfig("custom-mcp"), + customConfig("another-custom"), + ]; + + const result = partitionShimConfigs(configs); + + expect(result.shimEntries).toHaveLength(1); + expect(result.shimEntries[0]?.config.name).toBe("rippling"); + expect(result.shimEntries[0]?.tools).toBeDefined(); + expect(result.transportConfigs.map((c) => c.name)).toEqual([ + "custom-mcp", + "another-custom", + ]); + }); + + it("treats aliased shim instances (rippling_finance) as shim configs too", () => { + const aliased = ripplingConfig({ name: "rippling_finance" }); + const result = partitionShimConfigs([aliased]); + expect(result.shimEntries).toHaveLength(1); + expect(result.transportConfigs).toHaveLength(0); + + const tools = result.shimEntries[0]?.tools ?? {}; + expect(Object.keys(tools)).toContain("list_employees"); + expect(Object.keys(tools)).toContain("get_employee"); + }); + + it("returns an error entry (not transport fallback) when a shim config is missing its token", () => { + const noToken = ripplingConfig({ token: undefined }); + const result = partitionShimConfigs([noToken]); + + expect(result.transportConfigs).toHaveLength(0); + expect(result.shimEntries).toHaveLength(1); + expect(result.shimEntries[0]?.tools).toBeNull(); + expect(result.shimEntries[0]?.error?.message).toContain( + "requires an API key", + ); + expect(result.shimEntries[0]?.error?.message).toContain( + "/mcp-add rippling ", + ); + }); + + it("never opens an MCP transport for shim configs", () => { + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + + partitionShimConfigs([ripplingConfig()]); + + // `partitionShimConfigs` builds tools eagerly but tools are lazy; no + // network call should happen on partition. + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); + +describe("tryRegisterShim", () => { + it("returns null for non-shim configs (so the caller falls through to MCP transport)", async () => { + const result = await tryRegisterShim(customConfig("not-a-shim")); + expect(result).toBeNull(); + }); + + it("short-circuits to a connected result without touching @ai-sdk/mcp on a successful probe", async () => { + stubFetchOk({ id: "company_1", name: "Acme" }); + + const result = await tryRegisterShim(ripplingConfig()); + + expect(result).toBeDefined(); + expect(result?.connected).toBe(true); + expect(result?.toolCount).toBeGreaterThan(0); + expect(result?.authorizationUrl).toBeUndefined(); + }); + + it("throws with the missing-token hint when a shim config has no token", async () => { + await expect( + tryRegisterShim(ripplingConfig({ token: undefined })), + ).rejects.toThrow(/requires an API key/); + }); + + it("throws the validation message when Rippling rejects the key", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + buildMockResponse({ + ok: false, + status: 401, + statusText: "Unauthorized", + body: { message: "bad token" }, + }), + ), + ); + + await expect( + tryRegisterShim(ripplingConfig({ token: "bad" })), + ).rejects.toThrow(/rejected the API key/); + }); +}); diff --git a/apps/api/tests/rippling-shim.test.ts b/apps/api/tests/rippling-shim.test.ts index 6f85a01..987b453 100644 --- a/apps/api/tests/rippling-shim.test.ts +++ b/apps/api/tests/rippling-shim.test.ts @@ -2,7 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { MCP_PRESETS, resolveShimName } from "../server/mcp/presets"; import { resolveShim } from "../server/mcp/shims"; -import { RIPPLING_API_BASE_URL } from "../server/mcp/shims/constants"; +import { + RIPPLING_API_BASE_URL, + RIPPLING_TOKEN_HELP_URL, +} from "../server/mcp/shims/constants"; import { buildRipplingTools, validateRipplingShim, @@ -74,6 +77,27 @@ describe("rippling preset wiring", () => { expect(typeof shim?.buildTools).toBe("function"); expect(typeof shim?.validate).toBe("function"); }); + + it("resolves aliased instances (rippling_finance) to the same shim", () => { + expect(resolveShimName("rippling_finance")).toBe("rippling"); + expect(resolveShimName("rippling_personal")).toBe("rippling"); + expect(resolveShimName("RIPPLING")).toBe("rippling"); + }); + + it("does not resolve unrelated names to the rippling shim", () => { + expect(resolveShimName("linear")).toBeUndefined(); + expect(resolveShimName("rippling-no")).toBeUndefined(); + expect(resolveShimName("rip")).toBeUndefined(); + }); + + it("excludes the heaviest paginated tools from search subagent visibility", () => { + const searchTools = MCP_PRESETS.rippling.searchTools ?? []; + expect(searchTools).not.toContain("list_employees_including_terminated"); + expect(searchTools).not.toContain("list_leave_balances"); + expect(searchTools).not.toContain("get_leave_balance"); + expect(searchTools).toContain("list_employees"); + expect(searchTools).toContain("list_leave_requests"); + }); }); describe("buildRipplingTools", () => { @@ -143,9 +167,80 @@ describe("buildRipplingTools", () => { expect(result.type).toBe("error"); if (result.type !== "error") return; expect(result.error.code).toBe("rippling_unauthorized"); - expect(result.error.instructions).toContain( - "https://app.rippling.com/developer/apiKeys", + expect(result.error.instructions).toContain(RIPPLING_TOKEN_HELP_URL); + }); + + it("maps an AbortError-driven timeout to a friendly tool error", async () => { + vi.useFakeTimers(); + const fetchSpy = vi.fn( + (_url: unknown, init: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + init.signal.addEventListener("abort", () => { + const abortError = new Error("This operation was aborted"); + abortError.name = "AbortError"; + reject(abortError); + }); + }), ); + vi.stubGlobal("fetch", fetchSpy); + + const tools = buildRipplingTools("rippling-key"); + const promise = runTool(tools.get_current_company, {}, "call_timeout"); + await vi.advanceTimersByTimeAsync(20000); + const result = await promise; + + expect(result.type).toBe("error"); + if (result.type !== "error") return; + expect(result.error.code).toBe("rippling_timeout"); + expect(result.error.message).toContain("timed out"); + + vi.useRealTimers(); + }); + + it("strips heavy fields from list_employees output projection", async () => { + const heavyEmployee = { + id: "role_1", + name: "Alice", + workEmail: "alice@example.com", + roleState: "ACTIVE", + photo: "data:image/png;base64,AAAAAAAAA".repeat(1000), + smallPhoto: "data:image/png;base64,BBBB".repeat(100), + workSchedule: { MONDAY: { hours: 8 }, TUESDAY: { hours: 8 } }, + customFields: { Marital_Status: "Married", Tshirt: "L" }, + }; + stubFetchOnce({ + ok: true, + status: 200, + statusText: "OK", + body: [heavyEmployee], + }); + + const tools = buildRipplingTools("rippling-key"); + const tool = tools.list_employees; + const outcome = await runTool(tool, { limit: 1 }, "call_proj"); + expect(outcome.type).toBe("success"); + + if (!tool.toModelOutput) throw new Error("tool missing toModelOutput"); + const modelOutput = tool.toModelOutput({ + toolCallId: "call_proj", + input: { limit: 1 }, + output: outcome, + }); + const rendered = + typeof modelOutput === "string" + ? modelOutput + : (modelOutput as { value: string }).value; + + expect(rendered).toContain("alice@example.com"); + expect(rendered).toContain("1 employee(s)"); + // Heavy fields must NOT be in the model-facing output + expect(rendered).not.toContain("photo"); + expect(rendered).not.toContain("workSchedule"); + expect(rendered).not.toContain("customFields"); + // But the raw success result still has the original payload + if (outcome.type === "success") { + expect(outcome.result).toEqual([heavyEmployee]); + } }); it("forwards leave-request filters as query params", async () => { @@ -210,8 +305,6 @@ describe("validateRipplingShim", () => { const result = await validateRipplingShim("bad-token"); expect(result.ok).toBe(false); expect(result.message).toContain("rejected the API key"); - expect(result.message).toContain( - "https://app.rippling.com/developer/apiKeys", - ); + expect(result.message).toContain(RIPPLING_TOKEN_HELP_URL); }); }); diff --git a/apps/api/tests/system-reminder.test.ts b/apps/api/tests/system-reminder.test.ts index abaa851..1713737 100644 --- a/apps/api/tests/system-reminder.test.ts +++ b/apps/api/tests/system-reminder.test.ts @@ -93,7 +93,7 @@ describe("buildSystemReminder", () => { expect(result).toContain("- mercury: banking (14 tools)"); expect(result).toContain(""); - expect(result).toContain("/mcp-add "); + expect(result).toContain("`/mcp-add linear`"); expect(result).toContain("- linear: project management"); expect(result).toContain("- sentry: error tracking"); @@ -104,6 +104,16 @@ describe("buildSystemReminder", () => { expect(availableSection).not.toContain("- mercury:"); }); + it("flags token-only presets with the API-key invocation hint", () => { + const result = buildSystemReminder({}); + expect(result).toContain( + "rippling: hr, employees, leave (requires API key)", + ); + expect(result).toContain("`/mcp-add rippling `"); + // OAuth presets keep the bare invocation + expect(result).toContain("`/mcp-add linear`"); + }); + it("dedupes off presets across multi-instance connections (e.g. linear_personal)", () => { const result = buildSystemReminder({ mcpServers: [ From bd26645c3a67905bed83e74ead267ae9c69edd2e Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Wed, 6 May 2026 22:25:28 -0700 Subject: [PATCH 3/3] refactor(mcp): consolidate shim validation type to a single source Bugbot flagged that `RipplingShimValidationResult` (in rippling.ts) was structurally identical to `McpShimValidation` (in shims/index.ts). Two copies of the same shape risk drifting apart as the contract evolves. Extract `McpShim` and `McpShimValidation` into `shims/types.ts` so both the registry (`index.ts`) and individual shims (`rippling.ts`) import the single canonical definition. `index.ts` re-exports them so existing import sites keep working unchanged. --- apps/api/server/mcp/shims/index.ts | 13 ++----------- apps/api/server/mcp/shims/rippling.ts | 9 ++------- apps/api/server/mcp/shims/types.ts | 12 ++++++++++++ 3 files changed, 16 insertions(+), 18 deletions(-) create mode 100644 apps/api/server/mcp/shims/types.ts diff --git a/apps/api/server/mcp/shims/index.ts b/apps/api/server/mcp/shims/index.ts index ec0f490..26faaf1 100644 --- a/apps/api/server/mcp/shims/index.ts +++ b/apps/api/server/mcp/shims/index.ts @@ -1,17 +1,8 @@ import { buildRipplingTools, validateRipplingShim } from "./rippling"; -import type * as AI from "ai"; +import type { McpShim } from "./types"; -export interface McpShimValidation { - ok: boolean; - toolCount: number; - message?: string; -} - -export interface McpShim { - buildTools: (token: string) => Record; - validate: (token: string) => Promise; -} +export type { McpShim, McpShimValidation } from "./types"; const SHIMS: Record = { rippling: { diff --git a/apps/api/server/mcp/shims/rippling.ts b/apps/api/server/mcp/shims/rippling.ts index cb47028..da062f1 100644 --- a/apps/api/server/mcp/shims/rippling.ts +++ b/apps/api/server/mcp/shims/rippling.ts @@ -17,6 +17,7 @@ import { import type * as AI from "ai"; import type { PookieToolError } from "../../agent/tool-result"; +import type { McpShimValidation } from "./types"; interface RipplingToolContext { token: string; @@ -433,15 +434,9 @@ export const buildRipplingTools = (token: string): Record => { }; }; -export interface RipplingShimValidationResult { - ok: boolean; - toolCount: number; - message?: string; -} - export const validateRipplingShim = async ( token: string, -): Promise => { +): Promise => { const probe = await probeRipplingToken(token); const toolCount = Object.keys(buildRipplingTools(token)).length; diff --git a/apps/api/server/mcp/shims/types.ts b/apps/api/server/mcp/shims/types.ts new file mode 100644 index 0000000..c701a17 --- /dev/null +++ b/apps/api/server/mcp/shims/types.ts @@ -0,0 +1,12 @@ +import type * as AI from "ai"; + +export interface McpShimValidation { + ok: boolean; + toolCount: number; + message?: string; +} + +export interface McpShim { + buildTools: (token: string) => Record; + validate: (token: string) => Promise; +}