conduyt-mcp 4.10.0 → 4.12.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.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  MCP server for [Conduyt CRM](https://conduyt.app) — expose your CRM as AI-accessible tools. Covers the core CRM surface (contacts, deals, pipelines, custom fields, automations, messaging, and more); use `conduyt_api_catalog` to discover the full set of 500+ REST endpoints.
4
4
 
5
- ## 136 Tools
5
+ ## 161 Tools
6
6
 
7
7
  ### Discovery
8
8
  | Tool | Description |
@@ -252,6 +252,12 @@ MCP server for [Conduyt CRM](https://conduyt.app) — expose your CRM as AI-acce
252
252
  | `conduyt_ai_task_segment_and_act` | AI task (preview only): segment by filter, then tag/assign/update — execution is human-session-only |
253
253
  | `conduyt_ai_task_pipeline_cleanup` | AI task (preview only): find stale deals and tag/move/notify/report — execution is human-session-only |
254
254
 
255
+ ### Privacy (GDPR)
256
+ | Tool | Description |
257
+ |------|-------------|
258
+ | `conduyt_export_contact_data` | GDPR data-portability export — full portable JSON of everything held about a contact (owner/admin) |
259
+ | `conduyt_forget_contact` | IRREVERSIBLE GDPR right-to-be-forgotten erasure — requires `confirm='FORGET'` (owner only) |
260
+
255
261
  ## Setup
256
262
 
257
263
  ```bash
package/dist/client.d.ts CHANGED
@@ -1,15 +1,58 @@
1
1
  import type { ConduytConfig } from "./types.js";
2
+ /**
3
+ * Structured API error (SOL-017). `code` distinguishes transport-level
4
+ * failures (timeout / response_too_large / network) from HTTP failures
5
+ * (code "http", status set). `retryable` reflects whether the FAILURE class
6
+ * is transient — the client itself only ever retries idempotent GETs.
7
+ * The message keeps the legacy "Conduyt API <status>: <msg>" shape so
8
+ * existing tool-level catch blocks keep working.
9
+ */
10
+ export declare class ConduytApiError extends Error {
11
+ readonly status: number | null;
12
+ readonly code: "http" | "timeout" | "response_too_large" | "network";
13
+ readonly retryable: boolean;
14
+ constructor(opts: {
15
+ message: string;
16
+ status?: number | null;
17
+ code: ConduytApiError["code"];
18
+ retryable: boolean;
19
+ cause?: unknown;
20
+ });
21
+ }
22
+ /** Per-call overrides for legitimately long/large operations (GDPR export,
23
+ * AI chat) — everything else rides the bounded defaults. */
24
+ export interface RequestOptions {
25
+ timeoutMs?: number;
26
+ maxResponseBytes?: number;
27
+ }
2
28
  export declare class ConduytClient {
3
29
  private baseUrl;
4
30
  private apiKey;
31
+ private timeoutMs;
32
+ private maxResponseBytes;
33
+ private retryBaseMs;
5
34
  constructor(config: ConduytConfig);
6
- request(method: string, path: string, body?: unknown): Promise<unknown>;
7
- requestText(method: string, path: string, body?: unknown): Promise<string>;
8
- get(path: string): Promise<unknown>;
9
- post(path: string, body: unknown): Promise<unknown>;
10
- postText(path: string, body: unknown): Promise<string>;
11
- patch(path: string, body: unknown): Promise<unknown>;
12
- del(path: string): Promise<unknown>;
35
+ /**
36
+ * One bounded HTTP attempt: deadline via AbortSignal, size cap enforced
37
+ * from Content-Length before the body is read AND from the actual bytes
38
+ * after (chunked responses carry no length header).
39
+ */
40
+ private attempt;
41
+ private httpError;
42
+ private retryDelayMs;
43
+ /**
44
+ * Retries are for idempotent GETs ONLY: transient statuses (429/502/503/
45
+ * 504), timeouts, and network errors. Mutations are never replayed — the
46
+ * server may have applied the write before the failure surfaced.
47
+ */
48
+ private requestRaw;
49
+ request(method: string, path: string, body?: unknown, opts?: RequestOptions): Promise<unknown>;
50
+ requestText(method: string, path: string, body?: unknown, opts?: RequestOptions): Promise<string>;
51
+ get(path: string, opts?: RequestOptions): Promise<unknown>;
52
+ post(path: string, body: unknown, opts?: RequestOptions): Promise<unknown>;
53
+ postText(path: string, body: unknown, opts?: RequestOptions): Promise<string>;
54
+ patch(path: string, body: unknown, opts?: RequestOptions): Promise<unknown>;
55
+ del(path: string, opts?: RequestOptions): Promise<unknown>;
13
56
  }
14
57
  export declare function formatResult(data: unknown): {
15
58
  content: Array<{
package/dist/client.js CHANGED
@@ -1,6 +1,36 @@
1
+ /**
2
+ * Structured API error (SOL-017). `code` distinguishes transport-level
3
+ * failures (timeout / response_too_large / network) from HTTP failures
4
+ * (code "http", status set). `retryable` reflects whether the FAILURE class
5
+ * is transient — the client itself only ever retries idempotent GETs.
6
+ * The message keeps the legacy "Conduyt API <status>: <msg>" shape so
7
+ * existing tool-level catch blocks keep working.
8
+ */
9
+ export class ConduytApiError extends Error {
10
+ status;
11
+ code;
12
+ retryable;
13
+ constructor(opts) {
14
+ super(opts.message, opts.cause !== undefined ? { cause: opts.cause } : undefined);
15
+ this.name = "ConduytApiError";
16
+ this.status = opts.status ?? null;
17
+ this.code = opts.code;
18
+ this.retryable = opts.retryable;
19
+ }
20
+ }
21
+ /** Bounded defaults; each overridable via config / env (see src/index.ts). */
22
+ const DEFAULT_TIMEOUT_MS = 30_000;
23
+ const DEFAULT_MAX_RESPONSE_BYTES = 5_000_000;
24
+ const DEFAULT_RETRY_BASE_MS = 500;
25
+ const MAX_GET_RETRIES = 2;
26
+ const RETRYABLE_STATUSES = new Set([429, 502, 503, 504]);
27
+ const MAX_RETRY_AFTER_MS = 10_000;
1
28
  export class ConduytClient {
2
29
  baseUrl;
3
30
  apiKey;
31
+ timeoutMs;
32
+ maxResponseBytes;
33
+ retryBaseMs;
4
34
  constructor(config) {
5
35
  // Strip trailing slashes, then guard against a misconfigured base URL that
6
36
  // already includes the API prefix. Every tool builds paths as `/api/v1/...`,
@@ -17,65 +47,197 @@ export class ConduytClient {
17
47
  }
18
48
  this.baseUrl = base;
19
49
  this.apiKey = config.apiKey;
50
+ this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
51
+ this.maxResponseBytes = config.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
52
+ this.retryBaseMs = config.retryBaseMs ?? DEFAULT_RETRY_BASE_MS;
20
53
  }
21
- async request(method, path, body) {
54
+ /**
55
+ * One bounded HTTP attempt: deadline via AbortSignal, size cap enforced
56
+ * from Content-Length before the body is read AND from the actual bytes
57
+ * after (chunked responses carry no length header).
58
+ */
59
+ async attempt(method, path, body, timeoutMs, maxResponseBytes) {
22
60
  const url = `${this.baseUrl}${path}`;
23
61
  const headers = {
24
62
  Authorization: `Bearer ${this.apiKey}`,
25
63
  "Content-Type": "application/json",
26
64
  };
27
- const res = await fetch(url, {
28
- method,
29
- headers,
30
- body: body ? JSON.stringify(body) : undefined,
65
+ let res;
66
+ try {
67
+ res = await fetch(url, {
68
+ method,
69
+ headers,
70
+ body: body != null ? JSON.stringify(body) : undefined,
71
+ signal: AbortSignal.timeout(timeoutMs),
72
+ });
73
+ }
74
+ catch (err) {
75
+ if (err?.name === "TimeoutError" || err?.name === "AbortError") {
76
+ throw new ConduytApiError({
77
+ message: `Conduyt API timeout: no response within ${timeoutMs}ms for ${method} ${path}`,
78
+ code: "timeout",
79
+ retryable: true,
80
+ cause: err,
81
+ });
82
+ }
83
+ throw new ConduytApiError({
84
+ message: `Conduyt API network error for ${method} ${path}: ${err?.message ?? String(err)}`,
85
+ code: "network",
86
+ retryable: true,
87
+ cause: err,
88
+ });
89
+ }
90
+ const tooLarge = (bytes) => new ConduytApiError({
91
+ message: `Conduyt API response too large (${bytes} bytes > ${maxResponseBytes} cap) for ${method} ${path}. Narrow the query or paginate.`,
92
+ status: res.status,
93
+ code: "response_too_large",
94
+ retryable: false,
31
95
  });
32
- const json = await res.json().catch(() => null);
33
- if (!res.ok) {
34
- const msg = json?.error || res.statusText;
35
- throw new Error(`Conduyt API ${res.status}: ${msg}`);
96
+ // Declared-size fast path (uncompressed responses)...
97
+ const declared = Number(res.headers.get("content-length") || 0);
98
+ if (declared > maxResponseBytes)
99
+ throw tooLarge(declared);
100
+ // ...then a STREAMED byte-accurate read: chunked/compressed responses
101
+ // carry no usable length header, and reading the whole body before
102
+ // checking would defeat the memory bound. The read stops (and the
103
+ // stream is cancelled) the moment the cap is crossed.
104
+ let text = "";
105
+ if (res.body) {
106
+ const reader = res.body.getReader();
107
+ const chunks = [];
108
+ let bytes = 0;
109
+ try {
110
+ for (;;) {
111
+ const { done, value } = await reader.read();
112
+ if (done)
113
+ break;
114
+ bytes += value.byteLength;
115
+ if (bytes > maxResponseBytes) {
116
+ await reader.cancel().catch(() => { });
117
+ throw tooLarge(bytes);
118
+ }
119
+ chunks.push(value);
120
+ }
121
+ }
122
+ catch (err) {
123
+ if (err instanceof ConduytApiError)
124
+ throw err;
125
+ throw new ConduytApiError({
126
+ message: `Conduyt API network error reading response for ${method} ${path}: ${err?.message ?? String(err)}`,
127
+ code: "network",
128
+ retryable: true,
129
+ cause: err,
130
+ });
131
+ }
132
+ const buf = new Uint8Array(bytes);
133
+ let off = 0;
134
+ for (const c of chunks) {
135
+ buf.set(c, off);
136
+ off += c.byteLength;
137
+ }
138
+ text = new TextDecoder().decode(buf);
36
139
  }
37
- return json;
140
+ else {
141
+ text = await res.text().catch(() => "");
142
+ if (text.length > maxResponseBytes)
143
+ throw tooLarge(text.length);
144
+ }
145
+ return { res, text };
38
146
  }
39
- async requestText(method, path, body) {
40
- const url = `${this.baseUrl}${path}`;
41
- const headers = {
42
- Authorization: `Bearer ${this.apiKey}`,
43
- "Content-Type": "application/json",
44
- };
45
- const res = await fetch(url, {
46
- method,
47
- headers,
48
- body: body !== undefined ? JSON.stringify(body) : undefined,
147
+ httpError(res, text) {
148
+ let msg = res.statusText;
149
+ try {
150
+ const json = JSON.parse(text);
151
+ if (json?.error)
152
+ msg = json.error;
153
+ }
154
+ catch {
155
+ if (text)
156
+ msg = text.slice(0, 500);
157
+ }
158
+ return new ConduytApiError({
159
+ message: `Conduyt API ${res.status}: ${msg}`,
160
+ status: res.status,
161
+ code: "http",
162
+ retryable: RETRYABLE_STATUSES.has(res.status),
49
163
  });
50
- const text = await res.text().catch(() => "");
51
- if (!res.ok) {
52
- let msg = res.statusText;
164
+ }
165
+ retryDelayMs(res, attemptIndex) {
166
+ const retryAfter = Number(res?.headers.get("retry-after"));
167
+ if (Number.isFinite(retryAfter) && retryAfter >= 0) {
168
+ return Math.min(retryAfter * 1000, MAX_RETRY_AFTER_MS);
169
+ }
170
+ return Math.min(this.retryBaseMs * 2 ** attemptIndex, MAX_RETRY_AFTER_MS);
171
+ }
172
+ /**
173
+ * Retries are for idempotent GETs ONLY: transient statuses (429/502/503/
174
+ * 504), timeouts, and network errors. Mutations are never replayed — the
175
+ * server may have applied the write before the failure surfaced.
176
+ */
177
+ async requestRaw(method, path, body, opts) {
178
+ const timeoutMs = opts?.timeoutMs ?? this.timeoutMs;
179
+ const maxResponseBytes = opts?.maxResponseBytes ?? this.maxResponseBytes;
180
+ const canRetry = method === "GET";
181
+ const attempts = canRetry ? MAX_GET_RETRIES + 1 : 1;
182
+ // TOTAL time budget across every attempt + backoff: 1.5x the per-call
183
+ // deadline. Without it, 3 stacked attempt timeouts + backoffs could hold
184
+ // an agent turn ~110s while the caller believed the bound was 30s.
185
+ const totalBudgetMs = Math.ceil(timeoutMs * 1.5);
186
+ const startedAt = Date.now();
187
+ let lastError = null;
188
+ for (let i = 0; i < attempts; i++) {
189
+ const remaining = totalBudgetMs - (Date.now() - startedAt);
190
+ let res = null;
53
191
  try {
54
- const json = JSON.parse(text);
55
- msg = json.error || msg;
192
+ const out = await this.attempt(method, path, body, Math.max(1, Math.min(timeoutMs, remaining)), maxResponseBytes);
193
+ res = out.res;
194
+ if (out.res.ok)
195
+ return out.text;
196
+ lastError = this.httpError(out.res, out.text);
56
197
  }
57
- catch {
58
- if (text)
59
- msg = text;
198
+ catch (err) {
199
+ if (!(err instanceof ConduytApiError))
200
+ throw err;
201
+ if (err.code === "response_too_large")
202
+ throw err;
203
+ lastError = err;
60
204
  }
61
- throw new Error(`Conduyt API ${res.status}: ${msg}`);
205
+ const isLast = i === attempts - 1;
206
+ if (!canRetry || isLast || !lastError.retryable)
207
+ throw lastError;
208
+ const delay = this.retryDelayMs(res, i);
209
+ if (Date.now() - startedAt + delay >= totalBudgetMs)
210
+ throw lastError;
211
+ await new Promise((r) => setTimeout(r, delay));
212
+ }
213
+ throw lastError;
214
+ }
215
+ async request(method, path, body, opts) {
216
+ const text = await this.requestRaw(method, path, body, opts);
217
+ try {
218
+ return JSON.parse(text);
219
+ }
220
+ catch {
221
+ return null;
62
222
  }
63
- return text;
64
223
  }
65
- get(path) {
66
- return this.request("GET", path);
224
+ async requestText(method, path, body, opts) {
225
+ return this.requestRaw(method, path, body, opts);
226
+ }
227
+ get(path, opts) {
228
+ return this.request("GET", path, undefined, opts);
67
229
  }
68
- post(path, body) {
69
- return this.request("POST", path, body);
230
+ post(path, body, opts) {
231
+ return this.request("POST", path, body, opts);
70
232
  }
71
- postText(path, body) {
72
- return this.requestText("POST", path, body);
233
+ postText(path, body, opts) {
234
+ return this.requestText("POST", path, body, opts);
73
235
  }
74
- patch(path, body) {
75
- return this.request("PATCH", path, body);
236
+ patch(path, body, opts) {
237
+ return this.request("PATCH", path, body, opts);
76
238
  }
77
- del(path) {
78
- return this.request("DELETE", path);
239
+ del(path, opts) {
240
+ return this.request("DELETE", path, undefined, opts);
79
241
  }
80
242
  }
81
243
  export function formatResult(data) {
package/dist/index.js CHANGED
@@ -34,6 +34,7 @@ import { registerTemplateTools } from "./tools/templates.js";
34
34
  import { registerDashboardTools } from "./tools/dashboard.js";
35
35
  import { registerAiExtendedTools } from "./tools/ai-extended.js";
36
36
  import { registerWorkflowSandboxTools } from "./tools/workflow-sandbox.js";
37
+ import { registerPrivacyTools } from "./tools/privacy.js";
37
38
  const apiUrl = process.env.CONDUYT_API_URL;
38
39
  const apiKey = process.env.CONDUYT_API_KEY;
39
40
  if (!apiUrl || !apiKey) {
@@ -46,7 +47,12 @@ if (!apiKey.startsWith("cdy_")) {
46
47
  console.error("CONDUYT_API_KEY must start with 'cdy_' — generate one at Settings > API Keys in Conduyt.");
47
48
  process.exit(1);
48
49
  }
49
- const client = new ConduytClient({ apiUrl, apiKey });
50
+ // Operational bounds (SOL-017): every HTTP call gets a deadline, a
51
+ // response-size cap, and conservative GET-only retries. Overridable via env
52
+ // for constrained runtimes.
53
+ const timeoutMs = Number(process.env.CONDUYT_TIMEOUT_MS) || undefined;
54
+ const maxResponseBytes = Number(process.env.CONDUYT_MAX_RESPONSE_BYTES) || undefined;
55
+ const client = new ConduytClient({ apiUrl, apiKey, timeoutMs, maxResponseBytes });
50
56
  // Single source of truth for the version — read from package.json so the
51
57
  // runtime initialize metadata can never drift from the published package.
52
58
  const pkg = createRequire(import.meta.url)("../package.json");
@@ -85,5 +91,6 @@ registerTemplateTools(server, client);
85
91
  registerDashboardTools(server, client);
86
92
  registerAiExtendedTools(server, client);
87
93
  registerWorkflowSandboxTools(server, client);
94
+ registerPrivacyTools(server, client);
88
95
  const transport = new StdioServerTransport();
89
96
  await server.connect(transport);
@@ -93,7 +93,8 @@ export function registerAiExtendedTools(server, client) {
93
93
  .optional()
94
94
  .describe("Pin the chat to a specific record for grounded answers"),
95
95
  }, async (params) => {
96
- const streamText = await client.postText("/api/v1/ai/chat", params);
96
+ // A grounded multi-turn reply can stream well past the 30s default.
97
+ const streamText = await client.postText("/api/v1/ai/chat", params, { timeoutMs: 120_000 });
97
98
  const reply = parseSseText(streamText);
98
99
  return formatResult({ reply });
99
100
  });
@@ -50,7 +50,15 @@ export function registerBulkTools(server, client) {
50
50
  qp.set("created_to", params.created_to);
51
51
  if (params.min_score)
52
52
  qp.set("min_score", String(params.min_score));
53
- const result = await client.get(`/api/v1/contacts/export?${qp}`);
53
+ // Full-book exports routinely exceed the 5MB default cap and the 30s
54
+ // deadline — widened, but bounded at 25MB: the payload is re-serialized
55
+ // into the tool result, so an unbounded budget would just move the
56
+ // failure into memory/model-context. Past 25MB the structured
57
+ // response_too_large error tells the agent to narrow the filters.
58
+ const result = await client.get(`/api/v1/contacts/export?${qp}`, {
59
+ timeoutMs: 120_000,
60
+ maxResponseBytes: 25_000_000,
61
+ });
54
62
  return formatResult(result);
55
63
  });
56
64
  server.tool("conduyt_export_deals", "Export deals as JSON with filtering by status, pipeline, or search term.", {
@@ -65,7 +73,11 @@ export function registerBulkTools(server, client) {
65
73
  qp.set("status", params.status);
66
74
  if (params.pipeline)
67
75
  qp.set("pipeline", params.pipeline);
68
- const result = await client.get(`/api/v1/deals/export?${qp}`);
76
+ // Same widened-but-bounded budget as the contacts export.
77
+ const result = await client.get(`/api/v1/deals/export?${qp}`, {
78
+ timeoutMs: 120_000,
79
+ maxResponseBytes: 25_000_000,
80
+ });
69
81
  return formatResult(result);
70
82
  });
71
83
  server.tool("conduyt_bulk_tag_contacts", "Add one tag to many contacts in a single call. Requires contacts:edit.", {
@@ -0,0 +1,11 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { ConduytClient } from "../client.js";
3
+ /**
4
+ * GDPR privacy tools — tri-surface parity for the /gdpr-export and /gdpr-forget
5
+ * endpoints (audit F7). The API enforces role independently (export =
6
+ * owner/admin, forget = owner-only); these tools work with whatever role the
7
+ * API key carries. The forget is IRREVERSIBLE, so the tool requires an explicit
8
+ * confirm="FORGET" literal (mirroring the API contract) — a model cannot erase a
9
+ * contact without deliberately supplying it.
10
+ */
11
+ export declare function registerPrivacyTools(server: McpServer, client: ConduytClient): void;
@@ -0,0 +1,35 @@
1
+ import { z } from "zod";
2
+ import { formatResult } from "../client.js";
3
+ /**
4
+ * GDPR privacy tools — tri-surface parity for the /gdpr-export and /gdpr-forget
5
+ * endpoints (audit F7). The API enforces role independently (export =
6
+ * owner/admin, forget = owner-only); these tools work with whatever role the
7
+ * API key carries. The forget is IRREVERSIBLE, so the tool requires an explicit
8
+ * confirm="FORGET" literal (mirroring the API contract) — a model cannot erase a
9
+ * contact without deliberately supplying it.
10
+ */
11
+ export function registerPrivacyTools(server, client) {
12
+ server.tool("conduyt_export_contact_data", "GDPR data-portability export: returns the full portable JSON of everything held about a contact — profile, custom fields, tags, messages, notes, deals, activities, calls, appointments, files, and consent/suppression records. Read-only. Requires an owner or admin API key (the API enforces this).", {
13
+ // Strict UUID + encode: a raw id with slashes could otherwise traverse
14
+ // into a different subroute. Keep this export-only regardless of key role.
15
+ id: z.string().uuid().describe("Contact UUID to export"),
16
+ }, async ({ id }) => {
17
+ // A full portable export is legitimately big and slow on large
18
+ // tenants — wider per-call budget than the 30s/5MB defaults.
19
+ const result = await client.get(`/api/v1/contacts/${encodeURIComponent(id)}/gdpr-export`, { timeoutMs: 120_000, maxResponseBytes: 25_000_000 });
20
+ return formatResult(result);
21
+ });
22
+ server.tool("conduyt_forget_contact", "IRREVERSIBLE GDPR right-to-be-forgotten erasure: permanently anonymizes the contact and erases their personal data EVERYWHERE it appears — messages, notes, deals, files, automations, and exports — across the whole account. Suppression/consent tombstones are kept so they are not re-contacted. THERE IS NO UNDO. Requires an owner API key (the API enforces this) and an explicit confirm='FORGET'. On a large tenant this can legitimately take tens of seconds. A 409 is retryable (a concurrent merge or in-flight automation) — retry once.", {
23
+ // Strict UUID + encode — this must stay a forget of exactly this contact,
24
+ // never a traversal into another destructive endpoint.
25
+ id: z.string().uuid().describe("Contact UUID to erase"),
26
+ confirm: z
27
+ .literal("FORGET")
28
+ .describe("Must be exactly the string 'FORGET'. This is a hard safety gate for an irreversible erasure — do not supply it unless the erasure is intended."),
29
+ }, async ({ id, confirm }) => {
30
+ // The tool doc promises "can take tens of seconds" on large tenants
31
+ // — the erasure must not be aborted by the 30s default deadline.
32
+ const result = await client.post(`/api/v1/contacts/${encodeURIComponent(id)}/gdpr-forget`, { confirm }, { timeoutMs: 300_000 });
33
+ return formatResult(result);
34
+ });
35
+ }
package/dist/types.d.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  export interface ConduytConfig {
2
2
  apiUrl: string;
3
3
  apiKey: string;
4
+ /** Per-call HTTP deadline in ms (default 30_000). Env: CONDUYT_TIMEOUT_MS. */
5
+ timeoutMs?: number;
6
+ /** Response body cap in bytes (default 5_000_000). Env: CONDUYT_MAX_RESPONSE_BYTES. */
7
+ maxResponseBytes?: number;
8
+ /** Base backoff in ms for GET retries (default 500; tests use 1). */
9
+ retryBaseMs?: number;
4
10
  }
5
11
  export interface ApiResponse<T = unknown> {
6
12
  data?: T;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conduyt-mcp",
3
- "version": "4.10.0",
3
+ "version": "4.12.0",
4
4
  "description": "MCP server for Conduyt CRM — expose CRM operations as AI-accessible tools",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -20,7 +20,8 @@
20
20
  "start": "tsx src/index.ts",
21
21
  "build": "tsc",
22
22
  "typecheck": "tsc --noEmit",
23
- "prepublishOnly": "npm run check:version && npm run build && ENFORCE=1 node scripts/verify-contracts.mjs",
23
+ "test": "node --import tsx --test \"src/**/*.test.ts\"",
24
+ "prepublishOnly": "npm run check:version && npm run test && npm run build && ENFORCE=1 node scripts/verify-contracts.mjs",
24
25
  "audit:contracts": "node scripts/verify-contracts.mjs",
25
26
  "contracts:refresh": "node scripts/refresh-contracts.mjs",
26
27
  "check:version": "node scripts/check-version-sync.mjs"