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
21 changes: 19 additions & 2 deletions apps/api/server/agent/system-reminder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,18 +61,35 @@ const collectConnectedPresetNames = (
return names;
};

const presetAddInvocation = (preset: {
name: string;
authType?: string;
}): string =>
preset.authType === "token"
? `\`/mcp-add ${preset.name} <api-key>\``
: `\`/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 => {
const connectedPresets = collectConnectedPresetNames(connectedServers);

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 `<available_mcp_presets>\nnot connected. if the user wants one of these capabilities, offer \`/mcp-add <name>\` rather than faking the call.\n${offPresetLines.join("\n")}\n</available_mcp_presets>`;
return `<available_mcp_presets>\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</available_mcp_presets>`;
};

export interface SystemReminderContext {
Expand Down
140 changes: 130 additions & 10 deletions apps/api/server/mcp/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -181,11 +183,116 @@ 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_<server>_<tool>` naming and prompt routing
// apply uniformly.
const missingShimTokenMessage = (serverName: string): string =>
`${serverName} requires an API key. re-run \`/mcp-add ${serverName} <key>\``;

export const tryRegisterShim = async (
config: McpServerConfig,
): Promise<McpRegistrationResult | null> => {
const shimName = resolveShimName(config.name);
if (!shimName) return null;

const shim = resolveShim(shimName);
if (!shim) return null;

if (!config.token) {
throw new Error(missingShimTokenMessage(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 };
};

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<string, AI.Tool> | 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,
teamId: string,
): Promise<McpRegistrationResult> => {
const shimResult = await tryRegisterShim(config);
if (shimResult) return shimResult;

try {
await ensureOAuth(userId, config, teamId);
} catch (error) {
Expand Down Expand Up @@ -317,8 +424,29 @@ export const openMcpTools = async (
}
};

const registerToolSet = (
config: McpServerConfig,
toolSet: Record<string, AI.Tool>,
): void => {
let toolCount = 0;
for (const [toolName, tool] of Object.entries(toolSet)) {
allTools[prefixToolName(config.name, toolName)] = tool;
toolCount++;
}
serverSummaries.push({ name: config.name, toolCount });
};

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);
}
}

const connectionResults = await Promise.allSettled(
configs.map(async (config) => {
transportConfigs.map(async (config) => {
try {
await ensureOAuth(userId, config, teamId);
} catch (authError) {
Expand Down Expand Up @@ -349,15 +477,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<string, AI.Tool>);
} catch (toolError) {
classifyError(config, toolError);
}
Expand Down
7 changes: 6 additions & 1 deletion apps/api/server/mcp/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
39 changes: 39 additions & 0 deletions apps/api/server/mcp/presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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_<name>_*`
// tools so prompts, search routing, and system reminders treat the
// integration uniformly with real MCP servers.
shim?: string;
}

export const MCP_PRESETS: Record<string, McpPreset> = {
Expand Down Expand Up @@ -235,6 +241,36 @@ export const MCP_PRESETS: Record<string, McpPreset> = {
"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://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_leave_requests",
"get_current_company",
"get_departments",
"get_teams",
"get_levels",
"get_company_leave_types",
"get_current_user",
],
exampleQueries: [
"who's out on leave this week according to rippling?",
"list everyone in the engineering department in rippling",
],
},
};

const resolveByPrefix = <T>(
Expand All @@ -260,3 +296,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;
14 changes: 14 additions & 0 deletions apps/api/server/mcp/shims/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
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";

// 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/";
15 changes: 15 additions & 0 deletions apps/api/server/mcp/shims/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { buildRipplingTools, validateRipplingShim } from "./rippling";

import type { McpShim } from "./types";

export type { McpShim, McpShimValidation } from "./types";

const SHIMS: Record<string, McpShim> = {
rippling: {
buildTools: buildRipplingTools,
validate: validateRipplingShim,
},
};

export const resolveShim = (shimName: string): McpShim | undefined =>
SHIMS[shimName];
Loading
Loading