Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 22 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,23 +70,38 @@ Reload your editor, then type: **"List all my agents"**
| `get_phone_numbers` | List phone numbers owned by your organization |
| `get_voices` | List available voices with gender, language, and model filters |
| `get_playbooks` | Read a multi-agent (Playbooks) agent's SOPs, intent router, and shared auth tools |
| `get_branch_draft` | View a branch's pending (unpublished) draft changes |
| `get_revision` | Get a single committed revision's metadata and resolved config |

### Write

Edits are saved to a branch's **draft** (agents use the branch/revision model). Pass an optional `branch_id` to any editing tool — omit it to edit the live branch; if the agent has several branches you'll be asked which one. Run `publish_draft` once to commit.

| Tool | Description |
|---|---|
| `create_agent` | Create a new AI voice agent (`single_prompt`, or `multi_agents` for Playbooks) |
| `update_agent_prompt` | Update an agent's system prompt / instructions |
| `update_agent` | Update agent settings — name, prompt, first message, voice, model, language, variables, pre-call API, etc. |
| `add_agent_tool` | Add or update an API-call tool the agent can invoke during a call |
| `remove_agent_tool` | Remove a tool from an agent by name |
| `configure_call_actions` | Enable/disable end_call and set a transfer number — agent-level |
| `add_playbooks` | Add SOP playbooks (intent + prompt + scoped API tools + auth level) to a multi-agent |
| `update_playbook` | Edit, archive, or restore one playbook |
| `configure_playbooks` | Set the intent router, conversation guide, and shared weak/strong auth tools |
| `configure_call_actions` | Enable/disable end_call and set a transfer number — agent-level, applies across all playbooks |
| `update_agent_config` | Update agent settings — name, language, voice, STT, first message, etc. |
| `add_agent_tool` | Add or update an API-call tool the agent can invoke during a call |
| `remove_agent_tool` | Remove a tool from an agent by name |
| `set_pre_call_api` | Configure (or disable) the pre-call API that runs before a call to enrich variables |
| `delete_agent` | Archive (soft-delete) or unarchive an agent |
| `publish_draft` | Publish or discard a draft on a versioned agent |
| `duplicate_agent` | Copy an agent |

### Versioning (branches & revisions)

| Tool | Description |
|---|---|
| `list_branches` | List the agent's branches (which is live, which have a pending draft) |
| `create_branch` | Create a working branch from another branch's head |
| `rename_branch` | Rename a branch |
| `make_branch_live` | Make a branch's head the live (serving) config |
| `publish_draft` | Publish (commit) or discard a branch's pending draft |
| `list_revisions` | List a branch's committed revisions |
| `diff` | Compare two configs (revisions or a branch draft) |
| `test_agent` | Start a test call against a branch's head, its draft, or a specific revision |

### Act

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
"scripts": {
"build": "esbuild src/index.ts --platform=node --bundle --format=esm --outdir=dist --banner:js=\"#!/usr/bin/env node\" --packages=external",
"dev": "tsx src/index.ts",
"type-check": "tsc --noEmit"
"type-check": "tsc --noEmit",
"smoke": "npm run build && node smoke.mjs"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.1",
Expand Down
68 changes: 68 additions & 0 deletions smoke.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* Minimal smoke test: starts the built server, lists tools over stdio, and
* asserts the expected v2 tool surface is registered (and removed tools are gone).
* No backend needed — tools/list doesn't hit the API.
*
* Usage: npm run build && node smoke.mjs
*/
import { spawn } from "node:child_process";

const EXPECTED = [
// versioning v2
"list_branches", "create_branch", "rename_branch", "make_branch_live",
"get_branch_draft", "publish_draft", "list_revisions", "get_revision",
"diff", "test_agent",
// editing
"update_agent", "add_agent_tool", "remove_agent_tool", "configure_call_actions",
"create_agent", "delete_agent", "duplicate_agent",
// playbooks
"get_playbooks", "add_playbooks", "update_playbook", "configure_playbooks",
// calls
"make_call", "debug_call", "list_calls",
];

// Removed in the v2 cutover — must NOT be present.
const REMOVED = [
"update_agent_config", "update_agent_prompt", "set_pre_call_api",
"activate_version", "list_versions", "get_version", "get_draft",
"list_drafts", "diff_versions", "get_draft_diff", "test_draft",
"test_version", "rename_draft", "update_version", "compare_version_metrics",
];

const srv = spawn(process.execPath, ["dist/index.js"], {
env: { ...process.env, ATOMS_API_KEY: "smoke-test" },
stdio: ["pipe", "pipe", "inherit"],
});

const fail = (msg) => { console.error(`❌ ${msg}`); srv.kill(); process.exit(1); };
const send = (o) => srv.stdin.write(JSON.stringify(o) + "\n");

let buf = "";
srv.stdout.on("data", (d) => {
buf += d.toString();
for (const line of buf.split("\n")) {
if (!line.trim()) continue;
let msg;
try { msg = JSON.parse(line); } catch { continue; }
if (msg.id !== 2) continue;

const names = new Set((msg.result?.tools ?? []).map((t) => t.name));
const missing = EXPECTED.filter((n) => !names.has(n));
const leaked = REMOVED.filter((n) => names.has(n));
if (missing.length) fail(`missing expected tools: ${missing.join(", ")}`);
if (leaked.length) fail(`removed tools still registered: ${leaked.join(", ")}`);

// Every tool must expose a name + inputSchema.
for (const t of msg.result?.tools ?? []) {
if (!t.name || !t.inputSchema) fail(`tool missing name/inputSchema: ${JSON.stringify(t).slice(0, 80)}`);
}

console.log(`✅ ${names.size} tools registered; all ${EXPECTED.length} expected present, none of ${REMOVED.length} removed leaked.`);
srv.kill();
process.exit(0);
}
});

send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "smoke", version: "1" } } });
setTimeout(() => send({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }), 300);
setTimeout(() => fail("timed out waiting for tools/list"), 8000);
17 changes: 16 additions & 1 deletion src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ interface ApiResult {
* Automatically includes the API key and resolves the org context.
*/
export async function atomsApi(
method: "GET" | "POST" | "PATCH" | "DELETE",
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE",
path: string,
body?: unknown,
extraHeaders?: Record<string, string>
Expand Down Expand Up @@ -51,7 +51,22 @@ export async function atomsApi(
return { ok: response.ok, status: response.status, data };
}

/** Backend discriminator (branch-model-guard.ts) for deprecated v1 versioning endpoints. */
const VERSIONING_V2_MIGRATION_ERROR = "versioning_v2_migration_required";

export function formatApiError(result: ApiResult): string {
// Config freeze: the backend locks all config writes during a maintenance window (HTTP 423).
// Surface it as a clear, non-alarming state — reads and test-calls are unaffected.
if (result.status === 423) {
return "Agent config is frozen for a maintenance window — edits are paused. Test-calls and reads still work; try your edit again shortly.";
}

// Deprecated v1 versioning endpoint after the branch-model cutover. This should not happen once
// migrated; if it does, the MCP is out of date relative to the backend.
if (result.data?.error_type === VERSIONING_V2_MIGRATION_ERROR) {
return "This Smallest MCP server is out of date and called a deprecated endpoint. Update it (restart your editor to pull the latest, or re-run the installer), then try again.";
}

const msg = result.data?.message ?? result.data?.error ?? JSON.stringify(result.data);
return `API error ${result.status}: ${msg}`;
}
3 changes: 1 addition & 2 deletions src/tools/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,4 @@ export { registerGetCampaigns } from "./get-campaigns.js";
export { registerGetPhoneNumbers } from "./get-phone-numbers.js";
export { registerGetUsageStats } from "./get-usage-stats.js";
export { registerMakeCall } from "./make-call.js";
export { registerUpdateAgentConfig } from "./update-agent-config.js";
export { registerUpdateAgentPrompt } from "./update-agent-prompt.js";
export { registerUpdateAgent } from "./update-agent.js";
54 changes: 0 additions & 54 deletions src/tools/activate-version.ts

This file was deleted.

39 changes: 14 additions & 25 deletions src/tools/add-agent-tool.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

import { fetchAgentAndTools, persistAgentTools, VERSIONED_DRAFT_HINT } from "./agent-tools-helper.js";
import { fetchAgentAndTools, persistAgentTools, DRAFT_HINT } from "./agent-tools-helper.js";

/** Schema for one API-call tool — used both for the single-tool params and the batch `tools` array.
* Exported for reuse by the Playbooks tools (playbook tools use the same function shape). */
Expand Down Expand Up @@ -115,20 +115,15 @@ export function registerAddAgentTool(server: McpServer) {
"add_agent_tool",
{
description:
"Add (or update) one or more API-call tools on a single_prompt agent. API-call tools let the agent make an HTTP request to an external API during a call — e.g. look up an order, book an appointment, or post to a CRM. " +
"The agent decides when to invoke a tool based on its name and description, filling in any declared parameters. " +
"Pass a single tool via the top-level fields, or several at once via `tools` (preferred when configuring multiple tools — they land in one draft write). " +
"Upserts by name: tools with existing names are replaced; others are added (existing tools are preserved). " +
"For versioned agents the change is saved as a draft — pass `draft_id` to stack onto an existing draft (e.g. one returned by update_agent_prompt or set_pre_call_api) instead of creating a new one, then publish_draft once. " +
"Caveat: the draft's tools section is written wholesale, so when targeting a draft that already had tool edits, include ALL desired tools in this call. Use get_agent_prompt to see an agent's current live tools.",
"Add (or update) one or more API-call tools on a single_prompt agent. API-call tools let the agent make an HTTP request to an external API during a call — e.g. look up an order, book an appointment, or post to a CRM. The agent decides when to invoke a tool from its name + description, filling in any declared parameters. " +
"Pass a single tool via the top-level fields, or several at once via `tools`. Upserts by name: an existing tool with the same name is replaced, others are preserved. " +
"Changes are saved to the branch's draft via read-modify-write against the open draft — make tool edits one at a time (sequential edits stack; concurrent edits to the same branch can drop each other), then publish_draft once to make everything live. Use remove_agent_tool to delete a tool by name, and configure_call_actions for end_call / transfer_call.",
inputSchema: {
agent_id: z.string().describe("The agent ID to add the tool(s) to"),
draft_id: z
branch_id: z
.string()
.optional()
.describe(
"Existing draft to write into (stacks this change onto the draft's other edits). Omit to create a new draft from the live version."
),
.describe("Branch whose draft to edit (from list_branches). Omit to use the live branch; if the agent has multiple branches you'll be asked to pick one."),
tools: z
.array(apiToolSchema)
.optional()
Expand All @@ -149,7 +144,7 @@ export function registerAddAgentTool(server: McpServer) {
},
},
async (params) => {
// Collect the tool inputs: batch `tools` array, or the single top-level tool.
// Collect the batch `tools` array or the single top-level tool.
let inputs: ApiToolInput[];
if (params.tools && params.tools.length > 0) {
inputs = params.tools;
Expand Down Expand Up @@ -181,14 +176,11 @@ export function registerAddAgentTool(server: McpServer) {
];
}

// Reject duplicate names within the batch.
const seen = new Set<string>();
for (const t of inputs) {
if (seen.has(t.name)) {
return {
content: [
{ type: "text" as const, text: `Duplicate tool name '${t.name}' in the tools array.` },
],
content: [{ type: "text" as const, text: `Duplicate tool name '${t.name}' in the tools array.` }],
};
}
seen.add(t.name);
Expand All @@ -206,18 +198,19 @@ export function registerAddAgentTool(server: McpServer) {
};
}

const fetched = await fetchAgentAndTools(params.agent_id);
const fetched = await fetchAgentAndTools(params.agent_id, params.branch_id);
if (!fetched.ok) {
return { content: [{ type: "text" as const, text: fetched.message }] };
}

// Upsert by name (case-sensitive): keep tools not being replaced, append the new ones.
const newTools = inputs.map(buildApiCallTool);
const newNames = new Set(inputs.map((t) => t.name));
const kept = fetched.tools.filter((t) => !newNames.has(t?.name));
const replacedCount = fetched.tools.length - kept.length;
const tools = [...kept, ...inputs.map(buildApiCallTool)];
const tools = [...kept, ...newTools];

const persisted = await persistAgentTools(fetched.agent, fetched.prompt, tools, params.draft_id);
const persisted = await persistAgentTools(fetched.agent, fetched.branchId, tools);
if (!persisted.ok) {
return { content: [{ type: "text" as const, text: persisted.message }] };
}
Expand All @@ -230,13 +223,9 @@ export function registerAddAgentTool(server: McpServer) {
agentId: params.agent_id,
tools: inputs.map((t) => ({ name: t.name, method: t.method, url: t.url })),
totalTools: tools.length,
status: "draft",
hint: DRAFT_HINT,
};
if (persisted.versioned) {
result.versioned = true;
result.draftId = persisted.draftId;
result.status = "draft";
result.hint = VERSIONED_DRAFT_HINT;
}

return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
}
Expand Down
Loading
Loading