@jclvsh/iris 0.22.0 → 0.23.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
@@ -19,6 +19,8 @@ Or run one-shot without installing:
19
19
  npx -y -p @jclvsh/iris iris-mcp
20
20
  ```
21
21
 
22
+ Imported as a library, the package resolves only through its `exports` map (`.`, `./hosted`, `./tool-policy`, `./policy`, `./package.json`); deep imports of `dist/*` are not supported.
23
+
22
24
  ## CLI
23
25
 
24
26
  ```bash
package/dist/client.d.ts CHANGED
@@ -1,11 +1,31 @@
1
+ export interface IrisClientOptions {
2
+ /** API key to send. Defaults to the IRIS_API_KEY environment variable. */
3
+ apiKey?: string;
4
+ /** REST base URL. Defaults to IRIS_API_URL, then https://api.iris-ai.dev. */
5
+ baseUrl?: string;
6
+ }
1
7
  export declare class IrisClient {
2
8
  private apiKey;
3
9
  private baseUrl;
4
- constructor();
10
+ /** True when the base URL was injected; the network-error hint then names no URL or env var. */
11
+ private hasInjectedBaseUrl;
12
+ constructor(options?: IrisClientOptions);
5
13
  get<T>(path: string, params?: Record<string, string>): Promise<T>;
6
14
  post<T>(path: string, body?: unknown): Promise<T>;
7
15
  patch<T>(path: string, body: unknown): Promise<T>;
8
16
  delete(path: string): Promise<void>;
17
+ /**
18
+ * POST with an Idempotency-Key. The API answers a repeat of the same key within 24 hours
19
+ * with the original response and `Idempotent-Replayed: true`; `replayed` surfaces that so
20
+ * the caller can say "nothing new was created".
21
+ */
22
+ postIdempotent<T>(path: string, body: unknown, idempotencyKey: string): Promise<{
23
+ result: T;
24
+ replayed: boolean;
25
+ }>;
9
26
  private request;
27
+ /** Read a JSON body; a timeout while the body streams gets the same message as one during fetch. */
28
+ private readJson;
29
+ private send;
10
30
  private handleError;
11
31
  }
package/dist/client.js CHANGED
@@ -30,23 +30,28 @@ function safeStringify(value) {
30
30
  export class IrisClient {
31
31
  apiKey;
32
32
  baseUrl;
33
- constructor() {
34
- const apiKey = process.env.IRIS_API_KEY;
33
+ /** True when the base URL was injected; the network-error hint then names no URL or env var. */
34
+ hasInjectedBaseUrl;
35
+ constructor(options = {}) {
36
+ const apiKey = options.apiKey ?? process.env.IRIS_API_KEY;
35
37
  if (!apiKey) {
36
38
  throw new Error("IRIS_API_KEY environment variable is required. " +
37
39
  "Get your API key from https://iris-ai.dev/settings/api-keys");
38
40
  }
39
41
  if (!apiKey.startsWith("is_live_")) {
40
- throw new Error("IRIS_API_KEY must start with 'is_live_'. " +
42
+ // An injected key came from a hosted caller, who never set the env var.
43
+ const subject = options.apiKey !== undefined ? "api key" : "IRIS_API_KEY";
44
+ throw new Error(`${subject} must start with 'is_live_'. ` +
41
45
  "Get a valid API key from https://iris-ai.dev/settings/api-keys");
42
46
  }
43
47
  this.apiKey = apiKey;
48
+ this.hasInjectedBaseUrl = Boolean(options.baseUrl);
44
49
  // Default to api.iris-ai.dev (Stripe/GitHub/OpenAI convention). The api host
45
50
  // rewrites /<resource> → /api/v1/<resource> via middleware (see iris/src/proxy.ts),
46
51
  // so callers no longer pass the /api/v1/ prefix. Override via IRIS_API_URL if needed.
47
52
  // Older versions (≤0.5.5) defaulted to https://www.iris-ai.dev/api/v1 — that host
48
53
  // still responds, but the api host is canonical going forward.
49
- this.baseUrl = (process.env.IRIS_API_URL || "https://api.iris-ai.dev").replace(/\/+$/, "");
54
+ this.baseUrl = (options.baseUrl || process.env.IRIS_API_URL || "https://api.iris-ai.dev").replace(/\/+$/, "");
50
55
  }
51
56
  async get(path, params) {
52
57
  const url = new URL(`${this.baseUrl}${path}`);
@@ -76,33 +81,68 @@ export class IrisClient {
76
81
  async delete(path) {
77
82
  await this.request(`${this.baseUrl}${path}`, { method: "DELETE" });
78
83
  }
84
+ /**
85
+ * POST with an Idempotency-Key. The API answers a repeat of the same key within 24 hours
86
+ * with the original response and `Idempotent-Replayed: true`; `replayed` surfaces that so
87
+ * the caller can say "nothing new was created".
88
+ */
89
+ async postIdempotent(path, body, idempotencyKey) {
90
+ const response = await this.send(`${this.baseUrl}${path}`, {
91
+ method: "POST",
92
+ headers: { "Content-Type": "application/json", "Idempotency-Key": idempotencyKey },
93
+ body: JSON.stringify(body),
94
+ });
95
+ return {
96
+ result: await this.readJson(response),
97
+ replayed: response.headers.get("Idempotent-Replayed") === "true",
98
+ };
99
+ }
79
100
  async request(url, init) {
101
+ const response = await this.send(url, init);
102
+ if (response.status === 204) {
103
+ return undefined;
104
+ }
105
+ return this.readJson(response);
106
+ }
107
+ /** Read a JSON body; a timeout while the body streams gets the same message as one during fetch. */
108
+ async readJson(response) {
109
+ try {
110
+ return (await response.json());
111
+ }
112
+ catch (error) {
113
+ if (error instanceof Error && error.name === "TimeoutError") {
114
+ throw new Error("request timed out after 30 seconds");
115
+ }
116
+ throw error;
117
+ }
118
+ }
119
+ async send(url, init) {
120
+ let response;
80
121
  try {
81
- const response = await fetch(url, {
122
+ response = await fetch(url, {
82
123
  ...init,
83
124
  headers: {
84
- ...init.headers,
125
+ ...(init.headers || {}),
85
126
  Authorization: `Bearer ${this.apiKey}`,
86
127
  },
87
128
  signal: AbortSignal.timeout(30_000),
88
129
  });
89
- if (!response.ok && response.status !== 204) {
90
- await this.handleError(response);
91
- }
92
- if (response.status === 204) {
93
- return undefined;
94
- }
95
- return (await response.json());
96
130
  }
97
131
  catch (error) {
98
132
  if (error instanceof Error && error.name === "TimeoutError") {
99
133
  throw new Error("request timed out after 30 seconds");
100
134
  }
101
135
  if (error instanceof TypeError) {
102
- throw new Error(`network error: ${error.message}`);
136
+ throw new Error(this.hasInjectedBaseUrl
137
+ ? `network error reaching the api: ${error.message}`
138
+ : `network error: ${error.message}. check IRIS_API_URL and your connection`);
103
139
  }
104
140
  throw error;
105
141
  }
142
+ if (!response.ok) {
143
+ await this.handleError(response);
144
+ }
145
+ return response;
106
146
  }
107
147
  async handleError(response) {
108
148
  let message;
@@ -110,7 +110,7 @@ export function registerCampaignCommands(program, client) {
110
110
  });
111
111
  campaigns
112
112
  .command("analyze <id>")
113
- .description("run AI analysis on a campaign to generate target profiles")
113
+ .description("queue AI analysis of a campaign to generate target profiles (from its product, or from its goal)")
114
114
  .action(async (id) => {
115
115
  try {
116
116
  const result = await client.post(`/campaigns/${id}/analyze`);
@@ -0,0 +1,10 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ /**
3
+ * Serve one MCP request over HTTP. LOCKSTEP across every apps/<app>/packages/mcp.
4
+ *
5
+ * Stateless: a fresh transport per request, no sessions, JSON responses - so it runs on
6
+ * serverless functions where consecutive requests from one client can land on different
7
+ * instances. Only POST is meaningful; the host must not route GET here (a stateless GET
8
+ * opens an event stream that never ends).
9
+ */
10
+ export declare function serveStatelessMcp(server: McpServer, request: Request): Promise<Response>;
@@ -0,0 +1,17 @@
1
+ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
2
+ /**
3
+ * Serve one MCP request over HTTP. LOCKSTEP across every apps/<app>/packages/mcp.
4
+ *
5
+ * Stateless: a fresh transport per request, no sessions, JSON responses - so it runs on
6
+ * serverless functions where consecutive requests from one client can land on different
7
+ * instances. Only POST is meaningful; the host must not route GET here (a stateless GET
8
+ * opens an event stream that never ends).
9
+ */
10
+ export async function serveStatelessMcp(server, request) {
11
+ const transport = new WebStandardStreamableHTTPServerTransport({
12
+ sessionIdGenerator: undefined,
13
+ enableJsonResponse: true,
14
+ });
15
+ await server.connect(transport);
16
+ return transport.handleRequest(request);
17
+ }
@@ -0,0 +1,10 @@
1
+ export interface HostedMcpOptions {
2
+ /** The caller's iris API key. The host authenticates it before calling in. */
3
+ apiKey: string;
4
+ /** REST base URL the tools call, e.g. https://api.iris-ai.dev */
5
+ baseUrl: string;
6
+ /** Reported as serverInfo.version */
7
+ version: string;
8
+ }
9
+ /** One hosted MCP request: a per-request client and server over the stateless transport. */
10
+ export declare function handleHostedMcpRequest(request: Request, options: HostedMcpOptions): Promise<Response>;
package/dist/hosted.js ADDED
@@ -0,0 +1,9 @@
1
+ import { IrisClient } from "./client.js";
2
+ import { serveStatelessMcp } from "./hosted-transport.js";
3
+ import { createIrisServer } from "./server.js";
4
+ /** One hosted MCP request: a per-request client and server over the stateless transport. */
5
+ export function handleHostedMcpRequest(request, options) {
6
+ const client = new IrisClient({ apiKey: options.apiKey, baseUrl: options.baseUrl });
7
+ const server = createIrisServer({ client, version: options.version, profile: "hosted" });
8
+ return serveStatelessMcp(server, request);
9
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * A content-derived Idempotency-Key for create calls.
3
+ *
4
+ * The REST API dedupes a request that repeats a key within 24 hours (per key + endpoint)
5
+ * and answers with the original response plus `Idempotent-Replayed: true`. Deriving the
6
+ * key from the arguments means a retry after a timeout - by an agent, or by a person
7
+ * pressing "try again" in an MCP client - returns the record that was already created
8
+ * instead of creating a second one.
9
+ */
10
+ export declare function contentIdempotencyKey(args: unknown): Promise<string>;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * A content-derived Idempotency-Key for create calls.
3
+ *
4
+ * The REST API dedupes a request that repeats a key within 24 hours (per key + endpoint)
5
+ * and answers with the original response plus `Idempotent-Replayed: true`. Deriving the
6
+ * key from the arguments means a retry after a timeout - by an agent, or by a person
7
+ * pressing "try again" in an MCP client - returns the record that was already created
8
+ * instead of creating a second one.
9
+ */
10
+ function stable(value) {
11
+ if (Array.isArray(value))
12
+ return value.map(stable);
13
+ if (value !== null && typeof value === "object") {
14
+ const source = value;
15
+ const sorted = {};
16
+ for (const key of Object.keys(source).sort()) {
17
+ if (source[key] !== undefined)
18
+ sorted[key] = stable(source[key]);
19
+ }
20
+ return sorted;
21
+ }
22
+ return value;
23
+ }
24
+ export async function contentIdempotencyKey(args) {
25
+ const bytes = new TextEncoder().encode(JSON.stringify(stable(args)));
26
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes);
27
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
28
+ }
package/dist/index.js CHANGED
@@ -1,31 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "node:module";
3
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
4
  import { IrisClient } from "./client.js";
6
- import { registerCampaignTools } from "./tools/campaigns.js";
7
- import { registerContactTools } from "./tools/contacts.js";
8
- import { registerConversationTools } from "./tools/conversations.js";
9
- import { registerMessageTools } from "./tools/messages.js";
10
- import { registerAiTools } from "./tools/ai.js";
11
- import { registerDiscoveryTools } from "./tools/discovery.js";
12
- import { registerAnalyticsTools } from "./tools/analytics.js";
13
- import { registerBillingTools } from "./tools/billing.js";
14
- import { registerChannelTools } from "./tools/channels.js";
15
- import { registerNotificationTools } from "./tools/notifications.js";
16
- import { registerKeyTools } from "./tools/keys.js";
17
- import { registerWebhookTools } from "./tools/webhooks.js";
18
- import { registerAgentCreditTools } from "./tools/agent-credits.js";
19
- import { registerUsageTools } from "./tools/usage.js";
20
- import { registerAutopilotTools } from "./tools/autopilot.js";
21
- import { registerResponderTools } from "./tools/responder.js";
22
- import { registerAudienceTools } from "./tools/audiences.js";
5
+ import { createIrisServer } from "./server.js";
23
6
  const requireFromHere = createRequire(import.meta.url);
24
7
  const pkg = requireFromHere("../package.json");
25
- const server = new McpServer({
26
- name: "iris",
27
- version: pkg.version,
28
- });
29
8
  // Lazy client: defer the IrisClient constructor (which throws when
30
9
  // IRIS_API_KEY is missing) until a tool invocation actually issues an HTTP call.
31
10
  // MCP discovery (initialize, tools/list) never touches the proxy, so the server
@@ -40,22 +19,6 @@ const client = new Proxy({}, {
40
19
  return typeof value === "function" ? value.bind(realClient) : value;
41
20
  },
42
21
  });
43
- registerCampaignTools(server, client);
44
- registerContactTools(server, client);
45
- registerConversationTools(server, client);
46
- registerMessageTools(server, client);
47
- registerAiTools(server, client);
48
- registerDiscoveryTools(server, client);
49
- registerAnalyticsTools(server, client);
50
- registerBillingTools(server, client);
51
- registerChannelTools(server, client);
52
- registerNotificationTools(server, client);
53
- registerKeyTools(server, client);
54
- registerWebhookTools(server, client);
55
- registerAgentCreditTools(server, client);
56
- registerUsageTools(server, client);
57
- registerAutopilotTools(server, client);
58
- registerResponderTools(server, client);
59
- registerAudienceTools(server, client);
22
+ const server = createIrisServer({ client, version: pkg.version, profile: "full" });
60
23
  const transport = new StdioServerTransport();
61
24
  await server.connect(transport);
@@ -0,0 +1,36 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ /**
3
+ * Generic tool-policy machinery. LOCKSTEP across every apps/<app>/packages/mcp
4
+ * (byte-identical; scripts/check-lockstep-identity.sh enforces it). The per-app
5
+ * table lives in tool-policy.ts next to this file.
6
+ *
7
+ * class:
8
+ * read - retrieves information; changes nothing.
9
+ * write - changes the app's own data, reversibly; nothing leaves the app.
10
+ * sensitive - publishes, sends, or deletes on an external platform, deletes a record
11
+ * with no user-facing restore, spends money, handles credentials, or
12
+ * switches on automatic sending or posting.
13
+ * hosted: whether the app's hosted endpoint (api.<brand>/mcp) serves the tool.
14
+ * category: the documentation grouping every docs count is computed from.
15
+ */
16
+ export type ToolClass = "read" | "write" | "sensitive";
17
+ export type ToolProfile = "full" | "hosted";
18
+ export interface ToolPolicy<Category extends string = string> {
19
+ class: ToolClass;
20
+ hosted: boolean;
21
+ category: Category;
22
+ }
23
+ export type PolicyTable<Category extends string = string> = Record<string, ToolPolicy<Category>>;
24
+ export declare function toolPolicyFor<C extends string>(table: PolicyTable<C>, name: string): ToolPolicy<C>;
25
+ export declare function isToolEnabledIn<C extends string>(table: PolicyTable<C>, name: string, profile: ToolProfile): boolean;
26
+ export declare function toolAnnotationsFor<C extends string>(table: PolicyTable<C>, name: string): {
27
+ readOnlyHint: boolean;
28
+ destructiveHint: boolean;
29
+ };
30
+ /** Tool names in a category, in table order. */
31
+ export declare function toolsInCategoryOf<C extends string>(table: PolicyTable<C>, category: C): string[];
32
+ /**
33
+ * A stand-in for McpServer that every register*Tools(server, client) function can be
34
+ * handed. It is the one place a tool is filtered by profile and given its annotations.
35
+ */
36
+ export declare function createPolicyRegistrar<C extends string>(server: McpServer, table: PolicyTable<C>, profile: ToolProfile): McpServer;
package/dist/policy.js ADDED
@@ -0,0 +1,33 @@
1
+ export function toolPolicyFor(table, name) {
2
+ const policy = table[name];
3
+ if (!policy) {
4
+ throw new Error(`tool "${name}" has no entry in tool-policy.ts - classify it before registering`);
5
+ }
6
+ return policy;
7
+ }
8
+ export function isToolEnabledIn(table, name, profile) {
9
+ const { hosted } = toolPolicyFor(table, name);
10
+ return profile === "full" || hosted;
11
+ }
12
+ export function toolAnnotationsFor(table, name) {
13
+ const toolClass = toolPolicyFor(table, name).class;
14
+ return { readOnlyHint: toolClass === "read", destructiveHint: toolClass === "sensitive" };
15
+ }
16
+ /** Tool names in a category, in table order. */
17
+ export function toolsInCategoryOf(table, category) {
18
+ return Object.keys(table).filter((name) => table[name].category === category);
19
+ }
20
+ /**
21
+ * A stand-in for McpServer that every register*Tools(server, client) function can be
22
+ * handed. It is the one place a tool is filtered by profile and given its annotations.
23
+ */
24
+ export function createPolicyRegistrar(server, table, profile) {
25
+ const registerAnnotated = server.tool.bind(server);
26
+ return {
27
+ tool(name, description, schema, handler) {
28
+ if (!isToolEnabledIn(table, name, profile))
29
+ return;
30
+ registerAnnotated(name, description, schema, toolAnnotationsFor(table, name), handler);
31
+ },
32
+ };
33
+ }
@@ -0,0 +1,10 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { IrisClient } from "./client.js";
3
+ import { type ToolProfile } from "./policy.js";
4
+ export interface CreateIrisServerOptions {
5
+ client: IrisClient;
6
+ version: string;
7
+ /** "full" serves every tool (stdio). "hosted" serves the tools marked hosted in tool-policy.ts. */
8
+ profile: ToolProfile;
9
+ }
10
+ export declare function createIrisServer({ client, version, profile }: CreateIrisServerOptions): McpServer;
package/dist/server.js ADDED
@@ -0,0 +1,42 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { createPolicyRegistrar } from "./policy.js";
3
+ import { TOOL_POLICY } from "./tool-policy.js";
4
+ import { registerCampaignTools } from "./tools/campaigns.js";
5
+ import { registerContactTools } from "./tools/contacts.js";
6
+ import { registerConversationTools } from "./tools/conversations.js";
7
+ import { registerMessageTools } from "./tools/messages.js";
8
+ import { registerAiTools } from "./tools/ai.js";
9
+ import { registerDiscoveryTools } from "./tools/discovery.js";
10
+ import { registerAnalyticsTools } from "./tools/analytics.js";
11
+ import { registerBillingTools } from "./tools/billing.js";
12
+ import { registerChannelTools } from "./tools/channels.js";
13
+ import { registerNotificationTools } from "./tools/notifications.js";
14
+ import { registerKeyTools } from "./tools/keys.js";
15
+ import { registerWebhookTools } from "./tools/webhooks.js";
16
+ import { registerAgentCreditTools } from "./tools/agent-credits.js";
17
+ import { registerUsageTools } from "./tools/usage.js";
18
+ import { registerAutopilotTools } from "./tools/autopilot.js";
19
+ import { registerResponderTools } from "./tools/responder.js";
20
+ import { registerAudienceTools } from "./tools/audiences.js";
21
+ export function createIrisServer({ client, version, profile }) {
22
+ const server = new McpServer({ name: "iris", version });
23
+ const registrar = createPolicyRegistrar(server, TOOL_POLICY, profile);
24
+ registerCampaignTools(registrar, client);
25
+ registerContactTools(registrar, client);
26
+ registerConversationTools(registrar, client);
27
+ registerMessageTools(registrar, client);
28
+ registerAiTools(registrar, client);
29
+ registerDiscoveryTools(registrar, client);
30
+ registerAnalyticsTools(registrar, client);
31
+ registerBillingTools(registrar, client);
32
+ registerChannelTools(registrar, client);
33
+ registerNotificationTools(registrar, client);
34
+ registerKeyTools(registrar, client);
35
+ registerWebhookTools(registrar, client);
36
+ registerAgentCreditTools(registrar, client);
37
+ registerUsageTools(registrar, client);
38
+ registerAutopilotTools(registrar, client);
39
+ registerResponderTools(registrar, client);
40
+ registerAudienceTools(registrar, client);
41
+ return server;
42
+ }
@@ -0,0 +1,12 @@
1
+ import { type PolicyTable, type ToolClass, type ToolPolicy, type ToolProfile } from "./policy.js";
2
+ export type { ToolClass, ToolPolicy, ToolProfile };
3
+ export declare const TOOL_CATEGORIES: readonly ["campaigns", "contacts", "conversations", "messages", "channels", "discovery", "audiences", "autopilot", "responder", "ai", "analytics", "notifications", "usage", "billing", "credits", "API keys", "webhooks"];
4
+ export type ToolCategory = (typeof TOOL_CATEGORIES)[number];
5
+ export declare const TOOL_POLICY: PolicyTable<ToolCategory>;
6
+ export declare const toolPolicy: (name: string) => ToolPolicy<"campaigns" | "contacts" | "usage" | "messages" | "conversations" | "discovery" | "ai" | "webhooks" | "notifications" | "analytics" | "billing" | "channels" | "credits" | "responder" | "audiences" | "autopilot" | "API keys">;
7
+ export declare const isToolEnabled: (name: string, profile: ToolProfile) => boolean;
8
+ export declare const toolAnnotations: (name: string) => {
9
+ readOnlyHint: boolean;
10
+ destructiveHint: boolean;
11
+ };
12
+ export declare const toolsInCategory: (category: ToolCategory) => string[];
@@ -0,0 +1,152 @@
1
+ import { isToolEnabledIn, toolAnnotationsFor, toolPolicyFor, toolsInCategoryOf, } from "./policy.js";
2
+ export const TOOL_CATEGORIES = [
3
+ "campaigns",
4
+ "contacts",
5
+ "conversations",
6
+ "messages",
7
+ "channels",
8
+ "discovery",
9
+ "audiences",
10
+ "autopilot",
11
+ "responder",
12
+ "ai",
13
+ "analytics",
14
+ "notifications",
15
+ "usage",
16
+ "billing",
17
+ "credits",
18
+ "API keys",
19
+ "webhooks",
20
+ ];
21
+ const hosted = (category, toolClass) => ({
22
+ class: toolClass,
23
+ hosted: true,
24
+ category,
25
+ });
26
+ const localOnly = (category, toolClass) => ({
27
+ class: toolClass,
28
+ hosted: false,
29
+ category,
30
+ });
31
+ export const TOOL_POLICY = {
32
+ // campaigns
33
+ list_campaigns: hosted("campaigns", "read"),
34
+ get_campaign: hosted("campaigns", "read"),
35
+ list_campaign_contacts: hosted("campaigns", "read"),
36
+ list_campaign_chats: hosted("campaigns", "read"),
37
+ get_campaign_chat_messages: hosted("campaigns", "read"),
38
+ get_campaign_usage: hosted("campaigns", "read"),
39
+ list_target_profiles: hosted("campaigns", "read"),
40
+ create_campaign: hosted("campaigns", "write"),
41
+ update_campaign: hosted("campaigns", "write"),
42
+ add_campaign_contacts: hosted("campaigns", "write"),
43
+ rename_campaign_chat: hosted("campaigns", "write"),
44
+ analyze_campaign: hosted("campaigns", "write"),
45
+ create_target_profile: hosted("campaigns", "write"),
46
+ // soft deletes with no user-facing restore
47
+ delete_campaign: hosted("campaigns", "sensitive"),
48
+ delete_campaign_chat: hosted("campaigns", "sensitive"),
49
+ delete_target_profile: hosted("campaigns", "sensitive"),
50
+ // contacts
51
+ list_contacts: hosted("contacts", "read"),
52
+ get_contact: hosted("contacts", "read"),
53
+ get_contact_handles: hosted("contacts", "read"),
54
+ get_contact_usage: hosted("contacts", "read"),
55
+ list_contact_conversations: hosted("contacts", "read"),
56
+ create_contact: hosted("contacts", "write"),
57
+ update_contact: hosted("contacts", "write"),
58
+ bulk_import_contacts: hosted("contacts", "write"),
59
+ delete_contact: hosted("contacts", "sensitive"),
60
+ // conversations
61
+ list_conversations: hosted("conversations", "read"),
62
+ get_conversation: hosted("conversations", "read"),
63
+ get_conversation_messages: hosted("conversations", "read"),
64
+ get_engagement_metrics: hosted("conversations", "read"),
65
+ create_conversation: hosted("conversations", "write"),
66
+ mark_conversation_read: hosted("conversations", "write"),
67
+ update_conversation: hosted("conversations", "write"),
68
+ // records a message without transmitting it: any status other than pending_approval records it as sent
69
+ create_conversation_message: hosted("conversations", "sensitive"),
70
+ // messages
71
+ list_pending_messages: hosted("messages", "read"),
72
+ mark_message_sent: hosted("messages", "write"),
73
+ submit_message_feedback: hosted("messages", "write"),
74
+ // these send, or discard a draft with no restore
75
+ approve_message: hosted("messages", "sensitive"),
76
+ retry_message: hosted("messages", "sensitive"),
77
+ bulk_message_action: hosted("messages", "sensitive"),
78
+ reject_message: hosted("messages", "sensitive"),
79
+ // channels
80
+ list_connected_accounts: hosted("channels", "read"),
81
+ check_channel_availability: hosted("channels", "read"),
82
+ send_channel_message: hosted("channels", "sensitive"),
83
+ // discovery (spends quota and finds third parties; nothing is sent)
84
+ get_discovery_job: hosted("discovery", "read"),
85
+ list_discovery_sources: hosted("discovery", "read"),
86
+ search_contacts: hosted("discovery", "write"),
87
+ search_campaign_contacts: hosted("discovery", "write"),
88
+ // audiences
89
+ list_audiences: hosted("audiences", "read"),
90
+ create_audience: hosted("audiences", "write"),
91
+ generate_audiences: hosted("audiences", "write"),
92
+ attach_audience_to_campaign: hosted("audiences", "write"),
93
+ delete_audience: hosted("audiences", "sensitive"),
94
+ // autopilot (review-only: it drafts and discovers, it never sends)
95
+ get_autopilot_status: hosted("autopilot", "read"),
96
+ configure_autopilot: hosted("autopilot", "write"),
97
+ run_autopilot_now: hosted("autopilot", "write"),
98
+ // responder (auto-replies and comment-triggered DMs send without a per-message step)
99
+ get_responder: hosted("responder", "read"),
100
+ list_responder_activity: hosted("responder", "read"),
101
+ get_conversation_responder: hosted("responder", "read"),
102
+ list_responder_comment_triggers: hosted("responder", "read"),
103
+ list_responder_posts: hosted("responder", "read"),
104
+ configure_responder: hosted("responder", "sensitive"),
105
+ set_conversation_responder: hosted("responder", "sensitive"),
106
+ create_responder_comment_trigger: hosted("responder", "sensitive"),
107
+ update_responder_comment_trigger: hosted("responder", "sensitive"),
108
+ delete_responder_comment_trigger: hosted("responder", "sensitive"),
109
+ // ai
110
+ get_ai_usage: hosted("ai", "read"),
111
+ generate_content: hosted("ai", "write"),
112
+ generate_campaign_content: hosted("ai", "write"),
113
+ analyze_response: hosted("ai", "write"),
114
+ // analytics
115
+ get_analytics_overview: hosted("analytics", "read"),
116
+ get_campaign_analytics: hosted("analytics", "read"),
117
+ get_performance_metrics: hosted("analytics", "read"),
118
+ get_response_time: hosted("analytics", "read"),
119
+ get_contact_types: hosted("analytics", "read"),
120
+ // notifications
121
+ list_notifications: hosted("notifications", "read"),
122
+ update_notification: hosted("notifications", "write"),
123
+ mark_all_notifications_read: hosted("notifications", "write"),
124
+ delete_notification: hosted("notifications", "sensitive"),
125
+ // usage + billing
126
+ get_usage: hosted("usage", "read"),
127
+ get_changelog: hosted("usage", "read"),
128
+ get_billing_plans: hosted("billing", "read"),
129
+ // credits (agent payments: never hosted)
130
+ get_credit_balance: localOnly("credits", "read"),
131
+ purchase_credit_pack: localOnly("credits", "sensitive"),
132
+ // api keys (credentials: never hosted)
133
+ get_current_api_key: localOnly("API keys", "read"),
134
+ list_api_keys: localOnly("API keys", "read"),
135
+ rename_api_key: localOnly("API keys", "write"),
136
+ create_api_key: localOnly("API keys", "sensitive"),
137
+ revoke_api_key: localOnly("API keys", "sensitive"),
138
+ rotate_api_key: localOnly("API keys", "sensitive"),
139
+ // webhooks (developer plumbing: never hosted)
140
+ list_webhooks: localOnly("webhooks", "read"),
141
+ get_webhook: localOnly("webhooks", "read"),
142
+ list_webhook_deliveries: localOnly("webhooks", "read"),
143
+ create_webhook: localOnly("webhooks", "write"),
144
+ update_webhook: localOnly("webhooks", "write"),
145
+ test_webhook: localOnly("webhooks", "write"),
146
+ delete_webhook: localOnly("webhooks", "sensitive"),
147
+ rotate_webhook_secret: localOnly("webhooks", "sensitive"),
148
+ };
149
+ export const toolPolicy = (name) => toolPolicyFor(TOOL_POLICY, name);
150
+ export const isToolEnabled = (name, profile) => isToolEnabledIn(TOOL_POLICY, name, profile);
151
+ export const toolAnnotations = (name) => toolAnnotationsFor(TOOL_POLICY, name);
152
+ export const toolsInCategory = (category) => toolsInCategoryOf(TOOL_POLICY, category);
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { formatMcpError } from "../cli-utils.js";
3
+ import { contentIdempotencyKey } from "../idempotency.js";
3
4
  const CAMPAIGN_STATUSES = ["draft", "active", "paused", "completed", "deleted"];
4
5
  const OBJECTIVE_TYPES = ["venue_dj", "venue_host", "product_promotion", "custom"];
5
6
  const TARGET_PROFILE_TYPES = [
@@ -46,8 +47,21 @@ export function registerCampaignTools(server, client) {
46
47
  .describe("product description (for product_promotion objective)"),
47
48
  }, async (args) => {
48
49
  try {
49
- const result = await client.post("/campaigns", args);
50
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
50
+ // A retry after a timeout (by an agent, or a person pressing "try again") must not
51
+ // create a second campaign: the key is derived from the arguments, so the API replays
52
+ // the campaign it already created for them.
53
+ const { result, replayed } = await client.postIdempotent("/campaigns", args, await contentIdempotencyKey(args));
54
+ const text = JSON.stringify(result, null, 2);
55
+ return {
56
+ content: [
57
+ {
58
+ type: "text",
59
+ text: replayed
60
+ ? `note: an identical campaign was created within the last 24 hours, so iris returned it - no new campaign was created.\n${text}`
61
+ : text,
62
+ },
63
+ ],
64
+ };
51
65
  }
52
66
  catch (error) {
53
67
  return formatMcpError(error);
@@ -193,7 +207,7 @@ export function registerCampaignTools(server, client) {
193
207
  return formatMcpError(error);
194
208
  }
195
209
  });
196
- server.tool("analyze_campaign", "trigger AI analysis of a campaign to generate target profiles and recommendations. requires write scope.", {
210
+ server.tool("analyze_campaign", "queue AI analysis of a campaign to generate its target profiles: from the product for product_promotion campaigns, from goal_description for any other campaign. runs asynchronously; poll get_campaign until analysis_status is completed, then search_campaign_contacts. requires write scope.", {
197
211
  id: z.string().uuid().describe("campaign ID"),
198
212
  }, async ({ id }) => {
199
213
  try {
package/dist/types.d.ts CHANGED
@@ -33,6 +33,9 @@ export interface Campaign {
33
33
  follow_up_enabled: boolean;
34
34
  follow_up_max_count: number;
35
35
  follow_up_delay_days: number;
36
+ goal_description?: string | null;
37
+ analysis_status?: "none" | "pending" | "analyzing" | "completed" | "failed";
38
+ analysis_error?: string | null;
36
39
  created_at: string;
37
40
  updated_at: string;
38
41
  }
@@ -43,14 +46,15 @@ export interface CampaignWithStats extends Campaign {
43
46
  emails_opened?: number;
44
47
  emails_replied?: number;
45
48
  }
49
+ /** `GET /campaigns/{id}/usage` */
46
50
  export interface CampaignUsage {
47
51
  campaign_id: string;
48
- contacts_count: number;
49
- contacts_limit: number;
50
- emails_sent: number;
51
- emails_limit: number;
52
- discovery_runs: number;
53
- discovery_limit: number;
52
+ status: string;
53
+ total_contacts: number;
54
+ total_conversations: number;
55
+ delivery_status: Record<string, number>;
56
+ /** id of a contact search running for the campaign right now; null when none is */
57
+ discovery_job_id: string | null;
54
58
  }
55
59
  /** `POST /campaigns/{id}/contacts` */
56
60
  export interface AddCampaignContactsResult {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jclvsh/iris",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "MCP server for the Iris API",
5
5
  "repository": {
6
6
  "type": "git",
@@ -10,6 +10,25 @@
10
10
  "type": "module",
11
11
  "main": "dist/index.js",
12
12
  "types": "dist/index.d.ts",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "default": "./dist/index.js"
17
+ },
18
+ "./hosted": {
19
+ "types": "./dist/hosted.d.ts",
20
+ "default": "./dist/hosted.js"
21
+ },
22
+ "./policy": {
23
+ "types": "./dist/policy.d.ts",
24
+ "default": "./dist/policy.js"
25
+ },
26
+ "./tool-policy": {
27
+ "types": "./dist/tool-policy.d.ts",
28
+ "default": "./dist/tool-policy.js"
29
+ },
30
+ "./package.json": "./package.json"
31
+ },
13
32
  "bin": {
14
33
  "iris-mcp": "dist/index.js",
15
34
  "iris": "dist/cli.js"
@@ -20,17 +39,21 @@
20
39
  ],
21
40
  "scripts": {
22
41
  "build": "tsc",
42
+ "prepare": "tsc",
23
43
  "prepublishOnly": "npm run build",
24
- "start": "node dist/index.js"
44
+ "start": "node dist/index.js",
45
+ "typecheck": "tsc -p tsconfig.test.json",
46
+ "test": "vitest run"
25
47
  },
26
48
  "dependencies": {
27
- "@modelcontextprotocol/sdk": "^1.11.0",
49
+ "@modelcontextprotocol/sdk": "^1.26.0",
28
50
  "commander": "^14.0.3",
29
51
  "zod": "^3.24.0"
30
52
  },
31
53
  "devDependencies": {
54
+ "@types/node": "^22.0.0",
32
55
  "typescript": "^5.7.0",
33
- "@types/node": "^22.0.0"
56
+ "vitest": "^4.0.18"
34
57
  },
35
58
  "engines": {
36
59
  "node": ">=18"