@loopops/mcp-server 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,38 @@
1
+ /**
2
+ * tRPC client for connecting to the hosted Loop Operations API.
3
+ * Used when the MCP server runs in API mode (API_URL + API_KEY set).
4
+ */
5
+ export declare function isApiMode(): boolean;
6
+ export declare function getApiUrl(): string;
7
+ export declare function getApiKey(): string;
8
+ /** Base class so callers can distinguish API errors from unexpected errors. */
9
+ export declare class ApiError extends Error {
10
+ constructor(message: string);
11
+ }
12
+ /** The request took longer than the configured timeout. */
13
+ export declare class ApiTimeoutError extends ApiError {
14
+ readonly timeoutMs: number;
15
+ constructor(timeoutMs: number);
16
+ }
17
+ /** fetch() itself failed (DNS, TCP reset, etc.) — no HTTP response received. */
18
+ export declare class ApiNetworkError extends ApiError {
19
+ constructor(cause: unknown);
20
+ }
21
+ /** API returned a non-2xx HTTP status. */
22
+ export declare class ApiHttpError extends ApiError {
23
+ readonly status: number;
24
+ readonly body: string;
25
+ constructor(status: number, body: string);
26
+ }
27
+ /** API returned 401/403 — API key missing, expired, or lacks permission. */
28
+ export declare class ApiAuthError extends ApiError {
29
+ readonly status: number;
30
+ readonly body: string;
31
+ /** The tRPC error's `message` field if parseable, else null. */
32
+ readonly serverMessage: string | null;
33
+ constructor(status: number, body: string);
34
+ }
35
+ /** Make a tRPC query call to the hosted API. Retries once on timeout/network/5xx. */
36
+ export declare function trpcQuery<T = unknown>(path: string, input?: Record<string, unknown>): Promise<T>;
37
+ /** Make a tRPC mutation call to the hosted API. Does NOT retry (non-idempotent). */
38
+ export declare function trpcMutation<T = unknown>(path: string, input: Record<string, unknown>): Promise<T>;
@@ -0,0 +1,188 @@
1
+ /**
2
+ * tRPC client for connecting to the hosted Loop Operations API.
3
+ * Used when the MCP server runs in API mode (API_URL + API_KEY set).
4
+ */
5
+ const apiUrl = process.env.API_URL;
6
+ const apiKey = process.env.API_KEY;
7
+ const DEFAULT_TIMEOUT_MS = Number(process.env.API_TIMEOUT_MS || 30_000);
8
+ export function isApiMode() {
9
+ return !!apiUrl;
10
+ }
11
+ export function getApiUrl() {
12
+ if (!apiUrl)
13
+ throw new Error("API_URL not set");
14
+ return apiUrl;
15
+ }
16
+ export function getApiKey() {
17
+ if (!apiKey)
18
+ throw new Error("API_KEY not set");
19
+ return apiKey;
20
+ }
21
+ /** Base class so callers can distinguish API errors from unexpected errors. */
22
+ export class ApiError extends Error {
23
+ constructor(message) {
24
+ super(message);
25
+ this.name = "ApiError";
26
+ }
27
+ }
28
+ /** The request took longer than the configured timeout. */
29
+ export class ApiTimeoutError extends ApiError {
30
+ timeoutMs;
31
+ constructor(timeoutMs) {
32
+ super(`Loop API request timed out after ${timeoutMs}ms`);
33
+ this.timeoutMs = timeoutMs;
34
+ this.name = "ApiTimeoutError";
35
+ }
36
+ }
37
+ /** fetch() itself failed (DNS, TCP reset, etc.) — no HTTP response received. */
38
+ export class ApiNetworkError extends ApiError {
39
+ constructor(cause) {
40
+ super(`Loop API network error: ${cause instanceof Error ? cause.message : String(cause)}`);
41
+ this.name = "ApiNetworkError";
42
+ this.cause = cause;
43
+ }
44
+ }
45
+ /** API returned a non-2xx HTTP status. */
46
+ export class ApiHttpError extends ApiError {
47
+ status;
48
+ body;
49
+ constructor(status, body) {
50
+ super(`Loop API returned ${status}: ${body.slice(0, 300)}`);
51
+ this.status = status;
52
+ this.body = body;
53
+ this.name = "ApiHttpError";
54
+ }
55
+ }
56
+ /** API returned 401/403 — API key missing, expired, or lacks permission. */
57
+ export class ApiAuthError extends ApiError {
58
+ status;
59
+ body;
60
+ /** The tRPC error's `message` field if parseable, else null. */
61
+ serverMessage;
62
+ constructor(status, body) {
63
+ super(`Loop API auth error (${status}): ${body.slice(0, 300)}`);
64
+ this.status = status;
65
+ this.body = body;
66
+ this.name = "ApiAuthError";
67
+ this.serverMessage = extractTrpcMessage(body);
68
+ }
69
+ }
70
+ /**
71
+ * tRPC returns errors wrapped in an envelope. Extract the `message`
72
+ * field so MCP tools can surface the server's actionable text verbatim
73
+ * instead of our generic fallback.
74
+ *
75
+ * Shape (tRPC fetch adapter, HTTP error): [{ error: { json: { message } } }]
76
+ * or { error: { json: { message } } }
77
+ */
78
+ function extractTrpcMessage(body) {
79
+ try {
80
+ const parsed = JSON.parse(body);
81
+ const candidates = Array.isArray(parsed) ? parsed : [parsed];
82
+ for (const c of candidates) {
83
+ if (!c || typeof c !== "object")
84
+ continue;
85
+ const errorNode = c.error;
86
+ if (errorNode && typeof errorNode === "object") {
87
+ const json = errorNode.json;
88
+ if (json && typeof json === "object") {
89
+ const msg = json.message;
90
+ if (typeof msg === "string" && msg.trim().length > 0)
91
+ return msg;
92
+ }
93
+ // Older/simpler shape: error.message at the top level
94
+ const flat = errorNode.message;
95
+ if (typeof flat === "string" && flat.trim().length > 0)
96
+ return flat;
97
+ }
98
+ }
99
+ }
100
+ catch {
101
+ // Body wasn't JSON; fall through.
102
+ }
103
+ return null;
104
+ }
105
+ async function doFetch(url, init, timeoutMs) {
106
+ const controller = new AbortController();
107
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
108
+ try {
109
+ return await fetch(url, { ...init, signal: controller.signal });
110
+ }
111
+ catch (err) {
112
+ if (err instanceof Error && err.name === "AbortError") {
113
+ throw new ApiTimeoutError(timeoutMs);
114
+ }
115
+ throw new ApiNetworkError(err);
116
+ }
117
+ finally {
118
+ clearTimeout(timer);
119
+ }
120
+ }
121
+ async function readAndThrow(response) {
122
+ const body = await response.text().catch(() => "<unreadable>");
123
+ if (response.status === 401 || response.status === 403) {
124
+ throw new ApiAuthError(response.status, body);
125
+ }
126
+ throw new ApiHttpError(response.status, body);
127
+ }
128
+ function isRetryable(err) {
129
+ if (err instanceof ApiTimeoutError)
130
+ return true;
131
+ if (err instanceof ApiNetworkError)
132
+ return true;
133
+ if (err instanceof ApiHttpError && err.status >= 500)
134
+ return true;
135
+ return false;
136
+ }
137
+ /** Make a tRPC query call to the hosted API. Retries once on timeout/network/5xx. */
138
+ export async function trpcQuery(path, input) {
139
+ const url = new URL(getApiUrl());
140
+ url.pathname = url.pathname.replace(/\/$/, "") + "/" + path;
141
+ if (input !== undefined) {
142
+ url.searchParams.set("input", JSON.stringify({ json: input }));
143
+ }
144
+ const init = {
145
+ method: "GET",
146
+ redirect: "follow",
147
+ headers: {
148
+ Authorization: `Bearer ${getApiKey()}`,
149
+ "Content-Type": "application/json",
150
+ },
151
+ };
152
+ let lastErr;
153
+ for (let attempt = 0; attempt < 2; attempt++) {
154
+ try {
155
+ const response = await doFetch(url.toString(), init, DEFAULT_TIMEOUT_MS);
156
+ if (!response.ok)
157
+ await readAndThrow(response);
158
+ const data = (await response.json());
159
+ return data.result?.data?.json;
160
+ }
161
+ catch (err) {
162
+ lastErr = err;
163
+ if (!isRetryable(err))
164
+ throw err;
165
+ if (attempt === 0)
166
+ continue;
167
+ }
168
+ }
169
+ throw lastErr;
170
+ }
171
+ /** Make a tRPC mutation call to the hosted API. Does NOT retry (non-idempotent). */
172
+ export async function trpcMutation(path, input) {
173
+ const url = new URL(getApiUrl());
174
+ url.pathname = url.pathname.replace(/\/$/, "") + "/" + path;
175
+ const response = await doFetch(url.toString(), {
176
+ method: "POST",
177
+ redirect: "follow",
178
+ headers: {
179
+ Authorization: `Bearer ${getApiKey()}`,
180
+ "Content-Type": "application/json",
181
+ },
182
+ body: JSON.stringify({ json: input }),
183
+ }, DEFAULT_TIMEOUT_MS);
184
+ if (!response.ok)
185
+ await readAndThrow(response);
186
+ const data = (await response.json());
187
+ return data.result?.data?.json;
188
+ }
package/dist/db.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ import pg from "pg";
2
+ export type Db = pg.Pool;
3
+ export declare function createDb(connectionString: string): Db;
package/dist/db.js ADDED
@@ -0,0 +1,8 @@
1
+ import pg from "pg";
2
+ export function createDb(connectionString) {
3
+ return new pg.Pool({
4
+ connectionString,
5
+ max: 3,
6
+ idleTimeoutMillis: 30000,
7
+ });
8
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod";
5
+ import { trpcQuery } from "./api-client.js";
6
+ import { registerReportingTools } from "./tools/reporting.js";
7
+ import { registerConfigTools } from "./tools/config.js";
8
+ import { registerEngTools } from "./tools/eng.js";
9
+ import { registerCrmTools } from "./tools/crm.js";
10
+ import { registerEngageTools } from "./tools/engage.js";
11
+ const skillsResponseSchema = z.object({
12
+ role: z.string(),
13
+ skills: z.array(z.string()),
14
+ });
15
+ async function loadSkills() {
16
+ try {
17
+ const raw = await trpcQuery("mcp.getMySkills");
18
+ const parsed = skillsResponseSchema.safeParse(raw);
19
+ if (!parsed.success) {
20
+ console.error("[MCP] Unexpected response shape from mcp.getMySkills:", parsed.error.message);
21
+ console.error("[MCP] Raw response:", JSON.stringify(raw).slice(0, 500));
22
+ return { role: "unknown", skills: [] };
23
+ }
24
+ return parsed.data;
25
+ }
26
+ catch (err) {
27
+ console.error("[MCP] Failed to load skills from Loop API — starting with zero tools:", err instanceof Error ? err.message : String(err));
28
+ return { role: "unknown", skills: [] };
29
+ }
30
+ }
31
+ const { role, skills } = await loadSkills();
32
+ const allowedSkills = new Set(skills);
33
+ console.error(`[MCP] Connected — role: ${role}, ${skills.length} skills`);
34
+ const server = new McpServer({
35
+ name: "loop-operations",
36
+ version: "1.0.0",
37
+ });
38
+ registerReportingTools(server, allowedSkills);
39
+ registerConfigTools(server, allowedSkills);
40
+ registerEngTools(server, allowedSkills);
41
+ registerCrmTools(server, allowedSkills);
42
+ registerEngageTools(server, allowedSkills);
43
+ const transport = new StdioServerTransport();
44
+ await server.connect(transport);
@@ -0,0 +1 @@
1
+ export declare function loadSkillsForRole(roleName: string): Promise<Set<string>>;
package/dist/roles.js ADDED
@@ -0,0 +1,33 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { resolve, dirname } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { parse } from "yaml";
5
+ function resolveSkills(roles, roleName, visited = new Set()) {
6
+ if (visited.has(roleName))
7
+ return [];
8
+ visited.add(roleName);
9
+ const role = roles[roleName];
10
+ if (!role)
11
+ return [];
12
+ const inherited = role.inherits
13
+ ? resolveSkills(roles, role.inherits, visited)
14
+ : [];
15
+ return [...new Set([...inherited, ...role.skills])];
16
+ }
17
+ export async function loadSkillsForRole(roleName) {
18
+ // Look for skills.yaml relative to the project root
19
+ const __dirname = dirname(fileURLToPath(import.meta.url));
20
+ const configPath = resolve(__dirname, "../../../config/mcp/skills.yaml");
21
+ // Try project-relative path first, then fall back to cwd-based path
22
+ let raw;
23
+ try {
24
+ raw = await readFile(configPath, "utf-8");
25
+ }
26
+ catch {
27
+ const cwdPath = resolve(process.cwd(), "config/mcp/skills.yaml");
28
+ raw = await readFile(cwdPath, "utf-8");
29
+ }
30
+ const config = parse(raw);
31
+ const skills = resolveSkills(config.roles, roleName);
32
+ return new Set(skills);
33
+ }
@@ -0,0 +1,14 @@
1
+ type McpToolResult = {
2
+ content: Array<{
3
+ type: "text";
4
+ text: string;
5
+ }>;
6
+ isError?: boolean;
7
+ };
8
+ /**
9
+ * Wrap a tool handler so backend errors never crash the MCP subprocess.
10
+ * Converts thrown errors into structured MCP `isError: true` responses
11
+ * with a human-readable hint specific to the error class.
12
+ */
13
+ export declare function safeTool<Args>(fn: (args: Args) => Promise<string>): (args: Args) => Promise<McpToolResult>;
14
+ export {};
@@ -0,0 +1,49 @@
1
+ import { ApiAuthError, ApiHttpError, ApiNetworkError, ApiTimeoutError } from "../api-client.js";
2
+ /**
3
+ * Wrap a tool handler so backend errors never crash the MCP subprocess.
4
+ * Converts thrown errors into structured MCP `isError: true` responses
5
+ * with a human-readable hint specific to the error class.
6
+ */
7
+ export function safeTool(fn) {
8
+ return async (args) => {
9
+ try {
10
+ const text = await fn(args);
11
+ return { content: [{ type: "text", text }] };
12
+ }
13
+ catch (err) {
14
+ const text = formatErrorForUser(err);
15
+ // stderr goes to the MCP client's logs (e.g. Claude Code logs) for diagnosis.
16
+ console.error("[MCP] Tool error:", err);
17
+ return { content: [{ type: "text", text }], isError: true };
18
+ }
19
+ };
20
+ }
21
+ function formatErrorForUser(err) {
22
+ if (err instanceof ApiAuthError) {
23
+ // The server sends actionable messages for the four specific auth
24
+ // failure modes (expired, revoked, idle-revoked, suspended). Use them
25
+ // verbatim so the user sees dates + the re-mint URL + the right
26
+ // escalation path. Only fall back to a generic hint when the server
27
+ // didn't include a parseable message.
28
+ if (err.serverMessage)
29
+ return err.serverMessage;
30
+ return ("Your Loop access couldn't be verified. " +
31
+ "Re-mint your MCP key at https://www.loopops.io/mcp/connect (Okta sign-in required), " +
32
+ "then restart Claude Desktop. If the problem persists, contact Revenue Operations.");
33
+ }
34
+ if (err instanceof ApiTimeoutError) {
35
+ return (`The Loop API took longer than ${err.timeoutMs}ms to respond. ` +
36
+ "Try again, or contact ops if this persists.");
37
+ }
38
+ if (err instanceof ApiNetworkError) {
39
+ return ("Could not reach the Loop API. Check your network connection. " +
40
+ `Underlying error: ${err.message}`);
41
+ }
42
+ if (err instanceof ApiHttpError) {
43
+ return `Loop API returned HTTP ${err.status}. Details: ${err.body.slice(0, 500)}`;
44
+ }
45
+ if (err instanceof Error) {
46
+ return `Unexpected error calling Loop API: ${err.message}`;
47
+ }
48
+ return "Unexpected error calling Loop API.";
49
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Zod schemas for MCP tool inputs.
3
+ *
4
+ * IMPORTANT: These must stay in sync with
5
+ * packages/api/src/routers/mcp-schemas.ts. Both sides validate the same
6
+ * fields, so any enum value / regex / refinement added here must also
7
+ * be present in the backend (or the backend will reject a valid-looking
8
+ * MCP input).
9
+ *
10
+ * Why duplicated: `@loopops/mcp-server` compiles to standalone .js via tsc
11
+ * and is distributed outside the pnpm workspace graph consumed by the
12
+ * Next.js web app. Importing `@loop/api/mcp-schemas` as a workspace dep
13
+ * broke the web app's type inference (TS2742). For the demo env the
14
+ * duplication is the pragmatic trade-off; see the PR 3 postmortem in
15
+ * MEMORY.md or the commit message.
16
+ */
17
+ import { z } from "zod";
18
+ export declare const rangeSchema: z.ZodEnum<["1h", "6h", "24h", "7d", "30d", "90d", "180d", "1y"]>;
19
+ export declare const loopNameSchema: z.ZodEnum<["generate", "route", "engage", "pursue", "govern", "plan", "deploy"]>;
20
+ export declare const leadStatusSchema: z.ZodEnum<["Open - Not Contacted", "Working - Contacted", "Qualified", "Closed - Converted", "Closed - Not Converted"]>;
21
+ export declare const opportunityStageSchema: z.ZodEnum<["Prospecting", "Qualification", "Needs Analysis", "Value Proposition", "Id. Decision Makers", "Perception Analysis", "Proposal/Price Quote", "Negotiation/Review", "Closed Won", "Closed Lost"]>;
22
+ export declare const pipelineSortSchema: z.ZodEnum<["close_date", "amount", "stage"]>;
23
+ export declare const sequenceStatusSchema: z.ZodEnum<["draft", "approved", "sending", "completed", "rejected"]>;
24
+ export declare const sequenceApproveMethodSchema: z.ZodEnum<["resend", "sf_task"]>;
25
+ export declare const quarterSchema: z.ZodEnum<["Q1", "Q2", "Q3", "Q4"]>;
26
+ export declare const targetAccountTierSchema: z.ZodEnum<["tier_1", "tier_2", "tier_3"]>;
27
+ export declare const salesforceLeadIdSchema: z.ZodString;
28
+ export declare const salesforceOpportunityIdSchema: z.ZodString;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Zod schemas for MCP tool inputs.
3
+ *
4
+ * IMPORTANT: These must stay in sync with
5
+ * packages/api/src/routers/mcp-schemas.ts. Both sides validate the same
6
+ * fields, so any enum value / regex / refinement added here must also
7
+ * be present in the backend (or the backend will reject a valid-looking
8
+ * MCP input).
9
+ *
10
+ * Why duplicated: `@loopops/mcp-server` compiles to standalone .js via tsc
11
+ * and is distributed outside the pnpm workspace graph consumed by the
12
+ * Next.js web app. Importing `@loop/api/mcp-schemas` as a workspace dep
13
+ * broke the web app's type inference (TS2742). For the demo env the
14
+ * duplication is the pragmatic trade-off; see the PR 3 postmortem in
15
+ * MEMORY.md or the commit message.
16
+ */
17
+ import { z } from "zod";
18
+ // ─── Shared enums ──────────────────────────────────────────────────────────
19
+ export const rangeSchema = z.enum([
20
+ "1h",
21
+ "6h",
22
+ "24h",
23
+ "7d",
24
+ "30d",
25
+ "90d",
26
+ "180d",
27
+ "1y",
28
+ ]);
29
+ export const loopNameSchema = z.enum(["generate", "route", "engage", "pursue", "govern", "plan", "deploy"]);
30
+ export const leadStatusSchema = z.enum([
31
+ "Open - Not Contacted",
32
+ "Working - Contacted",
33
+ "Qualified",
34
+ "Closed - Converted",
35
+ "Closed - Not Converted",
36
+ ]);
37
+ export const opportunityStageSchema = z.enum([
38
+ "Prospecting",
39
+ "Qualification",
40
+ "Needs Analysis",
41
+ "Value Proposition",
42
+ "Id. Decision Makers",
43
+ "Perception Analysis",
44
+ "Proposal/Price Quote",
45
+ "Negotiation/Review",
46
+ "Closed Won",
47
+ "Closed Lost",
48
+ ]);
49
+ export const pipelineSortSchema = z.enum(["close_date", "amount", "stage"]);
50
+ export const sequenceStatusSchema = z.enum([
51
+ "draft",
52
+ "approved",
53
+ "sending",
54
+ "completed",
55
+ "rejected",
56
+ ]);
57
+ export const sequenceApproveMethodSchema = z.enum(["resend", "sf_task"]);
58
+ export const quarterSchema = z.enum(["Q1", "Q2", "Q3", "Q4"]);
59
+ export const targetAccountTierSchema = z.enum(["tier_1", "tier_2", "tier_3"]);
60
+ export const salesforceLeadIdSchema = z
61
+ .string()
62
+ .regex(/^00Q[a-zA-Z0-9]{12}([a-zA-Z0-9]{3})?$/, {
63
+ message: "Must be a Salesforce Lead ID starting with 00Q.",
64
+ });
65
+ export const salesforceOpportunityIdSchema = z
66
+ .string()
67
+ .regex(/^006[a-zA-Z0-9]{12}([a-zA-Z0-9]{3})?$/, {
68
+ message: "Must be a Salesforce Opportunity ID starting with 006.",
69
+ });
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function registerConfigTools(server: McpServer, allowed: Set<string>): void;
@@ -0,0 +1,146 @@
1
+ import { z } from "zod";
2
+ import { trpcQuery, trpcMutation } from "../api-client.js";
3
+ import { safeTool } from "./_helpers.js";
4
+ import { loopNameSchema } from "./_schemas.js";
5
+ export function registerConfigTools(server, allowed) {
6
+ if (allowed.has("show_config")) {
7
+ server.tool("show_config", "Show current YAML config for a loop. Returns the raw YAML content from the Git repo.", {
8
+ loop: loopNameSchema.describe("Loop whose config you want to view."),
9
+ file: z
10
+ .string()
11
+ .optional()
12
+ .describe("Config file name without extension (e.g., 'lead_scoring', 'rules', 'plays'). Defaults to the loop's main config."),
13
+ }, safeTool(async ({ loop, file }) => trpcQuery("mcp.showConfig", { loop, file })));
14
+ }
15
+ if (allowed.has("diff_config")) {
16
+ server.tool("diff_config", "Show recent changes to a loop's config files via Git commit history.", {
17
+ loop: loopNameSchema.describe("Loop whose config history you want."),
18
+ commits: z
19
+ .number()
20
+ .int()
21
+ .positive()
22
+ .max(50)
23
+ .optional()
24
+ .describe("Number of recent commits to show (1–50). Default: 5."),
25
+ }, safeTool(async ({ loop, commits }) => trpcQuery("mcp.diffConfig", { loop, commits })));
26
+ }
27
+ if (allowed.has("update_config")) {
28
+ server.tool("update_config", "Update a YAML config file. Shows the diff before committing. Validates the YAML structure.", {
29
+ loop: loopNameSchema.describe("Loop whose config you want to update."),
30
+ file: z
31
+ .string()
32
+ .optional()
33
+ .describe("Config file name. Defaults to the loop's main config."),
34
+ content: z.string().min(1).describe("The full updated YAML content."),
35
+ message: z.string().min(1).describe("Commit message describing the change."),
36
+ branch: z.string().optional().describe("Branch to commit to. Default: main."),
37
+ }, safeTool(async ({ loop, file, content, message, branch }) => trpcMutation("mcp.updateConfig", { loop, file, content, message, branch })));
38
+ }
39
+ if (allowed.has("deploy_config")) {
40
+ server.tool("deploy_config", "Deploy config by committing to main branch. Triggers Vercel deploy automatically.", {
41
+ loop: loopNameSchema.describe("Loop to deploy config for."),
42
+ message: z.string().min(1).describe("Deploy commit message."),
43
+ }, safeTool(async ({ loop, message }) => trpcQuery("mcp.deployConfig", { loop, message })));
44
+ }
45
+ if (allowed.has("deactivate_loop")) {
46
+ server.tool("deactivate_loop", "Stop a loop from running on the next webhook. Sets loops.active=false; the /api/loops/{slug}/config endpoint then returns 503 and any n8n workflow trying to fetch config halts. Trigger payloads are NOT lost — they remain in n8n's execution log and can be re-run after activate_loop. Use when a loop is misbehaving and needs immediate containment.", {
47
+ slug: loopNameSchema.describe("Slug of the loop to deactivate (e.g., 'pursue')."),
48
+ reason: z
49
+ .string()
50
+ .min(8)
51
+ .describe("Why you're deactivating. Required — recorded in audit_log so the next on-call has context."),
52
+ }, safeTool(async ({ slug, reason }) => trpcMutation("mcp.deactivateLoop", { slug, reason })));
53
+ }
54
+ if (allowed.has("activate_loop")) {
55
+ server.tool("activate_loop", "Resume a previously-deactivated loop. Sets loops.active=true; n8n workflows resume on the next webhook. Audit_log row written.", {
56
+ slug: loopNameSchema.describe("Slug of the loop to reactivate (e.g., 'pursue')."),
57
+ }, safeTool(async ({ slug }) => trpcMutation("mcp.activateLoop", { slug })));
58
+ }
59
+ if (allowed.has("sync_territories")) {
60
+ server.tool("sync_territories", "Reconcile config/plan/hierarchy.yaml (tree) + config/deploy/assignments.yaml (user coverage) with Salesforce ETM (Territory2 + UserTerritory2Association records). Dry-run by default — returns a diff summary. Pass dryRun:false to actually write to Salesforce. Safe to re-run; idempotent.", {
61
+ dryRun: z
62
+ .boolean()
63
+ .default(true)
64
+ .describe("When true (default), reports what would change but makes no writes to Salesforce. Pass false to apply."),
65
+ branch: z
66
+ .string()
67
+ .optional()
68
+ .describe("Branch to read YAML from. Default: main."),
69
+ }, safeTool(async ({ dryRun, branch }) => trpcMutation("mcp.syncTerritories", { dryRun, branch })));
70
+ }
71
+ if (allowed.has("list_scenarios")) {
72
+ server.tool("list_scenarios", "List every planning scenario declared in config/plan/scenarios/. Each scenario is a complete, self-describing plan (declares its own roster, targets, target_productivity). Shows which scenario is currently active (per capacity_config.yaml) and flags any scenarios that are missing required components. Discovery tool — use this before running capacity_report with a specific scenarioId.", {
73
+ branch: z
74
+ .string()
75
+ .optional()
76
+ .describe("Git branch to read scenarios from. Default: main."),
77
+ }, safeTool(async ({ branch }) => trpcMutation("mcp.listScenarios", { branch })));
78
+ }
79
+ if (allowed.has("capacity_report")) {
80
+ server.tool("capacity_report", "Run the Plan capacity model: rolls each AE's ramp-aware contribution into territory totals at the configured target_depth and compares against targets.yaml. Returns one table per measure (capacity, target, gap, % attainment). Pure config-driven math; updating roster.yaml or target_productivity.yaml and re-running shows the new plan immediately. No writes.", {
81
+ scenarioId: z
82
+ .string()
83
+ .optional()
84
+ .describe("Override the active scenario. Defaults to capacity_config.active_scenario (typically 'base')."),
85
+ planYear: z
86
+ .number()
87
+ .int()
88
+ .optional()
89
+ .describe("Override the configured plan year. Defaults to capacity_config.planning.plan_year."),
90
+ measureIds: z
91
+ .array(z.string())
92
+ .optional()
93
+ .describe("Filter the report to a subset of measures. Default: all target_settable measures (new_logo_volume, new_logo_acv, new_acv, new_arr)."),
94
+ showContributions: z
95
+ .boolean()
96
+ .default(false)
97
+ .describe("When true, include the per-AE contribution table beneath each cell. Useful for auditing the math; verbose."),
98
+ branch: z
99
+ .string()
100
+ .optional()
101
+ .describe("Git branch to read configs from. Default: main."),
102
+ }, safeTool(async ({ scenarioId, planYear, measureIds, showContributions, branch }) => trpcMutation("mcp.capacityReport", {
103
+ scenarioId,
104
+ planYear,
105
+ measureIds,
106
+ showContributions,
107
+ branch,
108
+ })));
109
+ }
110
+ if (allowed.has("preview_routing")) {
111
+ server.tool("preview_routing", "Simulate Route loop resolution for a hypothetical lead. Walks config/route/rules.yaml, dispatches to territory/pool/queue/user/target_account handler, and returns the resolved owner + audit trail. Useful for testing rule changes before merging. Requires at least billing geography or email.", {
112
+ email: z.string().optional().describe("Lead email — also extracts the domain for target_account lookup."),
113
+ billing_country: z.string().optional().describe("ISO country code (US, GB, JP, etc.)."),
114
+ billing_state: z.string().optional().describe("ISO state code (CA, NY, etc.)."),
115
+ billing_city: z.string().optional().describe("City name."),
116
+ employee_count: z.number().int().nonnegative().optional().describe("Company headcount."),
117
+ industry: z.string().optional().describe("SF Industry picklist value."),
118
+ source: z.string().optional().describe("Lead source (Web, Partner Referral, etc.)."),
119
+ }, safeTool(async (input) => trpcMutation("mcp.previewRouting", input)));
120
+ }
121
+ if (allowed.has("list_pending_territories")) {
122
+ server.tool("list_pending_territories", "List Accounts currently in a pending territory assignment state (Territory_Slug__c starts with 'pending:'). Returns the status breakdown + per-account table with agent reasoning when available. Pending accounts are re-evaluated on each assign_territories run.", {
123
+ includeReasoning: z
124
+ .boolean()
125
+ .default(true)
126
+ .describe("Include the agent's reasoning column (joins agent_decisions)."),
127
+ }, safeTool(async ({ includeReasoning }) => trpcQuery("mcp.listPendingTerritories", { includeReasoning })));
128
+ }
129
+ if (allowed.has("assign_territories")) {
130
+ server.tool("assign_territories", "Match each target Account to a territory Patch using config/plan/hierarchy.yaml (tree) + config/deploy/territories.yaml (billing match rules). Writes Account.Territory_Slug__c + ObjectTerritory2Association in SF. Dry-run by default. Uses Claude Haiku + web_search as a fallback when deterministic matching fails. Auto-applies agent matches at confidence ≥ 0.9; flags 0.75-0.9 for review.", {
131
+ mode: z
132
+ .enum(["new", "all"])
133
+ .default("new")
134
+ .describe("'new' (default): only accounts without a current territory. 'all': re-evaluate everything (use after territories.yaml changes)."),
135
+ dryRun: z
136
+ .boolean()
137
+ .default(true)
138
+ .describe("When true (default), shows the diff without writing to Salesforce."),
139
+ useAgent: z
140
+ .boolean()
141
+ .default(true)
142
+ .describe("Run Claude fallback for accounts the deterministic matcher cannot resolve. Set false to skip the agent."),
143
+ branch: z.string().optional().describe("Branch to read YAML from. Default: main."),
144
+ }, safeTool(async ({ mode, dryRun, useAgent, branch }) => trpcMutation("mcp.assignTerritories", { mode, dryRun, useAgent, branch })));
145
+ }
146
+ }
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function registerCrmTools(server: McpServer, allowed: Set<string>): void;
@@ -0,0 +1,113 @@
1
+ import { z } from "zod";
2
+ import { trpcQuery, trpcMutation } from "../api-client.js";
3
+ import { safeTool } from "./_helpers.js";
4
+ import { leadStatusSchema, opportunityStageSchema, pipelineSortSchema, quarterSchema, rangeSchema, salesforceLeadIdSchema, salesforceOpportunityIdSchema, targetAccountTierSchema, } from "./_schemas.js";
5
+ export function registerCrmTools(server, allowed) {
6
+ if (allowed.has("my_pipeline")) {
7
+ server.tool("my_pipeline", "List open Salesforce opportunities (deals with forecasts). Reps see their own, managers see their team's, leadership sees all. For new leads that haven't converted yet, use 'my_leads' instead.", {
8
+ stage: opportunityStageSchema
9
+ .optional()
10
+ .describe("Filter by Salesforce stage. Omit to see all open opportunities."),
11
+ sort: pipelineSortSchema
12
+ .optional()
13
+ .describe("Sort order. Default: close_date ascending."),
14
+ }, safeTool(async ({ stage, sort }) => trpcQuery("mcp.myPipeline", { stage, sort })));
15
+ }
16
+ if (allowed.has("my_leads")) {
17
+ server.tool("my_leads", "List Salesforce leads assigned to you (or your team if you're a manager) that were created in the given time window. For open opportunities with forecasts, use 'my_pipeline' instead.", {
18
+ status: leadStatusSchema
19
+ .optional()
20
+ .describe("Filter by Salesforce Lead status. E.g., 'Open - Not Contacted'. Omit for any status."),
21
+ range: rangeSchema
22
+ .optional()
23
+ .describe("Time window for lead creation date. E.g., '7d' = last 7 days, '30d' = default, '90d' = last quarter."),
24
+ }, safeTool(async ({ status, range }) => trpcQuery("mcp.myLeads", { status, range })));
25
+ }
26
+ if (allowed.has("pipeline_health")) {
27
+ server.tool("pipeline_health", "Pipeline health summary: deals by stage, win rate, average deal size, pipeline velocity.", {
28
+ range: rangeSchema
29
+ .optional()
30
+ .describe("Time window for closed-deal analysis. Longer ranges (30d, 90d, 180d, 1y) give more stable metrics. Default: 90d."),
31
+ }, safeTool(async ({ range }) => trpcQuery("mcp.pipelineHealth", { range })));
32
+ }
33
+ if (allowed.has("rep_performance")) {
34
+ server.tool("rep_performance", "Compare rep performance: open pipeline, activities, lead count, win rate.", {
35
+ range: rangeSchema
36
+ .optional()
37
+ .describe("Time window for closed deals and activity. Default: 30d."),
38
+ }, safeTool(async ({ range }) => trpcQuery("mcp.repPerformance", { range })));
39
+ }
40
+ if (allowed.has("forecast")) {
41
+ server.tool("forecast", "Pipeline forecast: weighted pipeline by close date, comparison to target.", {
42
+ quarter: quarterSchema
43
+ .optional()
44
+ .describe("Quarter to forecast. Default: current quarter."),
45
+ }, safeTool(async ({ quarter }) => trpcQuery("mcp.forecast", { quarter })));
46
+ }
47
+ if (allowed.has("update_opportunity")) {
48
+ server.tool("update_opportunity", "Update an opportunity's close date, next step, or description. You can identify the deal by Salesforce Opportunity ID or exact opportunity name. Reps can only update their own deals. Requires a reason for the change.", {
49
+ opportunityId: salesforceOpportunityIdSchema
50
+ .optional()
51
+ .describe("Salesforce Opportunity ID (006…). Preferred if known."),
52
+ opportunityName: z
53
+ .string()
54
+ .optional()
55
+ .describe("Exact opportunity name, used when you don't have the Salesforce ID."),
56
+ close_date: z
57
+ .string()
58
+ .regex(/^\d{4}-\d{2}-\d{2}$/, { message: "Use YYYY-MM-DD." })
59
+ .optional()
60
+ .describe("New close date in YYYY-MM-DD format."),
61
+ next_step: z.string().optional().describe("Next step text."),
62
+ description: z.string().optional().describe("Opportunity description."),
63
+ reason: z
64
+ .string()
65
+ .min(1)
66
+ .describe("Why this change is being made (required, logged)."),
67
+ }, safeTool(async ({ opportunityId, opportunityName, close_date, next_step, description, reason }) => trpcMutation("mcp.updateOpportunity", {
68
+ opportunityId,
69
+ opportunityName,
70
+ fields: { close_date, next_step, description },
71
+ reason,
72
+ })));
73
+ }
74
+ if (allowed.has("update_lead")) {
75
+ server.tool("update_lead", "Update a lead's status or description. Reps can only update their own leads. Requires a reason for the change.", {
76
+ leadId: salesforceLeadIdSchema.describe("Salesforce Lead ID (00Q…)."),
77
+ status: leadStatusSchema
78
+ .optional()
79
+ .describe("New Salesforce Lead status."),
80
+ description: z.string().optional().describe("Lead description."),
81
+ reason: z
82
+ .string()
83
+ .min(1)
84
+ .describe("Why this change is being made (required, logged)."),
85
+ }, safeTool(async ({ leadId, status, description, reason }) => trpcMutation("mcp.updateLead", {
86
+ leadId,
87
+ fields: { status, description },
88
+ reason,
89
+ })));
90
+ }
91
+ if (allowed.has("push_target_account_to_sf")) {
92
+ server.tool("push_target_account_to_sf", "Create Salesforce Account records from the target account list. Pushes accounts that don't yet have an SF Account ID. Ops+ only.", {
93
+ domain: z
94
+ .string()
95
+ .optional()
96
+ .describe("Push a single account by domain (e.g., 'databricks.com')."),
97
+ tier: targetAccountTierSchema
98
+ .optional()
99
+ .describe("Push all accounts in a tier."),
100
+ limit: z
101
+ .number()
102
+ .int()
103
+ .positive()
104
+ .max(500)
105
+ .optional()
106
+ .describe("Max accounts to push in one batch (1–500). Default: 100."),
107
+ }, safeTool(async ({ domain, tier, limit }) => trpcMutation("mcp.pushTargetAccountToSf", {
108
+ domain,
109
+ tier,
110
+ limit: limit || 100,
111
+ })));
112
+ }
113
+ }
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function registerEngTools(server: McpServer, allowed: Set<string>): void;
@@ -0,0 +1,54 @@
1
+ import { execSync } from "node:child_process";
2
+ import { z } from "zod";
3
+ import { trpcMutation, trpcQuery } from "../api-client.js";
4
+ import { safeTool } from "./_helpers.js";
5
+ import { rangeSchema } from "./_schemas.js";
6
+ export function registerEngTools(server, allowed) {
7
+ if (allowed.has("system_diagnostics")) {
8
+ server.tool("system_diagnostics", "System health diagnostics: failed executions, error patterns, latency outliers.", {
9
+ range: rangeSchema
10
+ .optional()
11
+ .describe("Time window. Short ranges (1h, 6h, 24h, 7d) are most useful. Default: 24h."),
12
+ }, safeTool(async ({ range }) => trpcQuery("mcp.systemDiagnostics", { range })));
13
+ }
14
+ if (allowed.has("revoke_api_key")) {
15
+ server.tool("revoke_api_key", "Immediately revoke every active MCP API key for a user. The user's loop_sk_* tokens in Claude Desktop stop working on the next tRPC call (401). Use for urgent access cutoff (suspected compromise, termination not yet processed in Okta). Routine offboarding should just deactivate the user in Okta — the 5-min reconciler picks that up.", {
16
+ email: z
17
+ .string()
18
+ .email()
19
+ .describe("Email of the Loop user to revoke."),
20
+ reason: z
21
+ .string()
22
+ .min(8)
23
+ .describe("Why you're revoking. Required — recorded in audit_log."),
24
+ }, safeTool(async ({ email, reason }) => trpcMutation("mcp.revokeApiKey", { email, reason })));
25
+ }
26
+ if (allowed.has("access_review")) {
27
+ server.tool("access_review", "Produce a user-access snapshot for SOC/security review. Lists every Loop user with role, status, SF linkage, active/revoked key counts, and last-active date. The review itself is audited — auditors can see who produced it and when. Intended for quarterly access reviews.", {
28
+ includeRevoked: z
29
+ .boolean()
30
+ .optional()
31
+ .describe("If true, also include users whose API keys have been revoked (for full historical picture). Default: false (active access only)."),
32
+ }, safeTool(async ({ includeRevoked }) => trpcQuery("mcp.accessReview", { includeRevoked })));
33
+ }
34
+ if (allowed.has("sync_local")) {
35
+ server.tool("sync_local", "Pull the latest changes from the remote repo into your local working tree. Runs `git pull --ff-only` in the repo at $LOOP_REPO_PATH. Use this after update_config / deploy_config writes commits directly to the remote branch so your local clone catches up.", {}, safeTool(async () => {
36
+ const repoPath = process.env.LOOP_REPO_PATH;
37
+ if (!repoPath) {
38
+ return "LOOP_REPO_PATH is not set. Add it to your MCP server env (pointing at your local repo root) and restart Claude.";
39
+ }
40
+ try {
41
+ const output = execSync("git pull --ff-only", {
42
+ cwd: repoPath,
43
+ encoding: "utf-8",
44
+ stdio: ["ignore", "pipe", "pipe"],
45
+ });
46
+ return `Synced \`${repoPath}\`:\n\n\`\`\`\n${output.trim() || "Already up to date."}\n\`\`\``;
47
+ }
48
+ catch (err) {
49
+ const message = err instanceof Error ? err.message : String(err);
50
+ return `Failed to sync \`${repoPath}\`:\n\n\`\`\`\n${message}\n\`\`\`\n\nCommon causes: uncommitted changes, wrong branch, or diverged history. Resolve manually with \`git status\` / \`git log\`.`;
51
+ }
52
+ }));
53
+ }
54
+ }
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function registerEngageTools(server: McpServer, allowed: Set<string>): void;
@@ -0,0 +1,60 @@
1
+ import { z } from "zod";
2
+ import { trpcQuery, trpcMutation } from "../api-client.js";
3
+ import { safeTool } from "./_helpers.js";
4
+ import { salesforceLeadIdSchema, sequenceApproveMethodSchema, sequenceStatusSchema, } from "./_schemas.js";
5
+ export function registerEngageTools(server, allowed) {
6
+ if (allowed.has("my_sequences")) {
7
+ server.tool("my_sequences", "Show email sequences generated by the Engage loop. Reps see their own, managers see their team's.", {
8
+ status: sequenceStatusSchema
9
+ .optional()
10
+ .describe("Filter by sequence lifecycle status."),
11
+ leadId: salesforceLeadIdSchema
12
+ .optional()
13
+ .describe("Filter by Salesforce Lead ID."),
14
+ leadEmail: z
15
+ .string()
16
+ .email()
17
+ .optional()
18
+ .describe("Filter by lead email address."),
19
+ leadCompany: z.string().optional().describe("Filter by company name."),
20
+ }, safeTool(async ({ status, leadId, leadEmail, leadCompany }) => trpcQuery("mcp.mySequences", {
21
+ status,
22
+ leadId,
23
+ leadEmail,
24
+ leadCompany,
25
+ })));
26
+ }
27
+ if (allowed.has("review_sequence")) {
28
+ server.tool("review_sequence", "Show full detail of a generated sequence: all emails, quality scores, and research brief. You can look it up by sequence ID, lead ID, lead email, or company.", {
29
+ sequenceId: z.string().uuid().optional().describe("Sequence ID (UUID)."),
30
+ leadId: salesforceLeadIdSchema
31
+ .optional()
32
+ .describe("Salesforce Lead ID."),
33
+ leadEmail: z
34
+ .string()
35
+ .email()
36
+ .optional()
37
+ .describe("Lead email address."),
38
+ leadCompany: z.string().optional().describe("Company name."),
39
+ }, safeTool(async ({ sequenceId, leadId, leadEmail, leadCompany }) => trpcQuery("mcp.reviewSequence", {
40
+ sequenceId,
41
+ leadId,
42
+ leadEmail,
43
+ leadCompany,
44
+ })));
45
+ }
46
+ if (allowed.has("approve_sequence")) {
47
+ server.tool("approve_sequence", "Approve a sequence for deployment. Sends first email and creates follow-up tasks.", {
48
+ sequenceId: z.string().uuid().describe("Sequence ID (UUID) to approve."),
49
+ method: sequenceApproveMethodSchema
50
+ .optional()
51
+ .describe("Deployment method. Default: resend."),
52
+ }, safeTool(async ({ sequenceId, method }) => trpcMutation("mcp.approveSequence", { sequenceId, method })));
53
+ }
54
+ if (allowed.has("reject_sequence")) {
55
+ server.tool("reject_sequence", "Reject a generated sequence with a reason.", {
56
+ sequenceId: z.string().uuid().describe("Sequence ID (UUID) to reject."),
57
+ reason: z.string().min(1).describe("Why the sequence is being rejected."),
58
+ }, safeTool(async ({ sequenceId, reason }) => trpcMutation("mcp.rejectSequence", { sequenceId, reason })));
59
+ }
60
+ }
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function registerReportingTools(server: McpServer, allowed: Set<string>): void;
@@ -0,0 +1,41 @@
1
+ import { trpcQuery } from "../api-client.js";
2
+ import { safeTool } from "./_helpers.js";
3
+ import { loopNameSchema, rangeSchema, salesforceLeadIdSchema, } from "./_schemas.js";
4
+ export function registerReportingTools(server, allowed) {
5
+ if (allowed.has("loop_health")) {
6
+ server.tool("loop_health", "Query loop execution health: counts, success rate, latency, errors. Filter by loop name and time range.", {
7
+ loop: loopNameSchema
8
+ .optional()
9
+ .describe("Filter to a specific loop. Omit for all loops."),
10
+ range: rangeSchema
11
+ .optional()
12
+ .describe("Time window. Short ranges (1h, 6h, 24h) are most useful. Default: 24h."),
13
+ }, safeTool(async ({ loop, range }) => trpcQuery("mcp.loopHealth", { loop, range })));
14
+ }
15
+ if (allowed.has("scoring_distribution")) {
16
+ server.tool("scoring_distribution", "Analyze lead scoring distribution: score histogram, grade breakdown, average score by factor.", {
17
+ range: rangeSchema
18
+ .optional()
19
+ .describe("Time window. Default: 7d."),
20
+ }, safeTool(async ({ range }) => trpcQuery("mcp.scoringDistribution", { range })));
21
+ }
22
+ if (allowed.has("routing_report")) {
23
+ server.tool("routing_report", "Analyze routing decisions: rule match rates, queue distribution, agent fallback percentage.", {
24
+ range: rangeSchema
25
+ .optional()
26
+ .describe("Time window. Default: 7d."),
27
+ }, safeTool(async ({ range }) => trpcQuery("mcp.routingReport", { range })));
28
+ }
29
+ if (allowed.has("enrichment_coverage")) {
30
+ server.tool("enrichment_coverage", "Analyze lead enrichment coverage: enrichment rate, top industries, headcount distribution.", {
31
+ range: rangeSchema
32
+ .optional()
33
+ .describe("Time window. Default: 7d."),
34
+ }, safeTool(async ({ range }) => trpcQuery("mcp.enrichmentCoverage", { range })));
35
+ }
36
+ if (allowed.has("decision_explain")) {
37
+ server.tool("decision_explain", "Explain a specific lead's end-to-end scoring, enrichment, routing, and outreach trail. Pass a Salesforce Lead ID.", {
38
+ leadId: salesforceLeadIdSchema.describe("Salesforce Lead ID (e.g., 00QgL00000BQL0tUAH)."),
39
+ }, safeTool(async ({ leadId }) => trpcQuery("mcp.decisionExplain", { leadId })));
40
+ }
41
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@loopops/mcp-server",
3
+ "version": "1.0.0",
4
+ "description": "Loop Operations MCP Server — AI skills for RevOps",
5
+ "license": "UNLICENSED",
6
+ "type": "module",
7
+ "main": "dist/index.js",
8
+ "bin": {
9
+ "loop-mcp": "dist/index.js"
10
+ },
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/Loop-Operations/loop.git",
17
+ "directory": "packages/mcp-server"
18
+ },
19
+ "homepage": "https://www.loopops.io",
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "dependencies": {
24
+ "@modelcontextprotocol/sdk": "^1.12.1",
25
+ "zod": "^3.24.4"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^22.15.21",
29
+ "tsx": "^4.19.4",
30
+ "typescript": "^5.8.3"
31
+ },
32
+ "scripts": {
33
+ "build": "tsc",
34
+ "dev": "tsx src/index.ts",
35
+ "start": "node dist/index.js"
36
+ }
37
+ }