# Build a custom MCP app

> Connect your own application to the ActuallyCare MCP server with the TypeScript SDK, choose an auth method, and drive the tools with the Anthropic API.

<!-- Source: https://docs.actuallycare.com/mcp/custom-apps -->

The ActuallyCare MCP server isn't just for Claude. Any standards-compliant MCP client can connect to it, which means you can build things like:

- **An internal dashboard** that asks the CRM questions in plain English and renders the answers
- **A voice assistant** that looks up escrows and schedules showings while an agent is driving between appointments
- **A nightly AI analyst** that pulls pipeline stats, flags stalled deals, and emails a summary before the morning standup

The shape is always the same: your app speaks MCP to `https://mcp.actuallycare.com/mcp` over Streamable HTTP — JSON-RPC requests via POST, with an SSE stream for server-initiated messages. A legacy HTTP+SSE transport also exists at `/sse` for older clients.

## Quickstart: a TypeScript MCP client

Install the official MCP TypeScript SDK ([github.com/modelcontextprotocol/typescript-sdk](https://github.com/modelcontextprotocol/typescript-sdk)):

```bash
npm install @modelcontextprotocol/sdk
```

Then connect, list the tools, and call one. This example authenticates with an API key in the `X-API-Key` header (see [Authentication](#authentication) below for the other options):

```typescript
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

const mcp = new Client({ name: "my-actuallycare-app", version: "1.0.0" });

const transport = new StreamableHTTPClientTransport(
  new URL("https://mcp.actuallycare.com/mcp"),
  {
    requestInit: {
      headers: { "X-API-Key": process.env.ACTUALLYCARE_API_KEY! },
    },
  },
);

await mcp.connect(transport);

// Discover the advertised tools
const { tools } = await mcp.listTools();
console.log("Advertised tools:", tools.map((t) => t.name).join(", "));

// Call one
const result = await mcp.callTool({
  name: "escrows_list",
  arguments: {},
});
console.log(JSON.stringify(result.content, null, 2));

await mcp.close();
```

You should see the advertised tool names first — entity_verb names like `escrows_list`, `clients_list`, `appointments_today`, plus discovery tools like `discover_tools` — followed by the tool result. Tool results come back as MCP content blocks: an array of text items whose `text` carries the data as JSON.

```json
[
  {
    "type": "text",
    "text": "..."
  }
]
```

One important nuance: `tools/list` does **not** return everything. The server advertises a short hot-list of the most-used tools plus discovery meta-tools, and every other tool stays fully callable by name. Browse the full surface at [/tools](/tools) or fetch it as machine-readable JSON from [/tools.json](/tools.json).

## Authentication

The MCP server accepts three credentials. Pick based on who the app acts as:

| Method | Best for | Header |
|---|---|---|
| API key | Server-side apps acting as one account (dashboards, cron jobs) | `X-API-Key` |
| JWT bearer token | Apps where users log in with their ActuallyCare credentials | `Authorization: Bearer ...` |
| OAuth 2.1 | Apps users authorize without sharing credentials | `Authorization: Bearer ...` |

### API key (simplest)

API keys are 64-character hex strings. Create one in the app under Settings → API Keys (`https://app.actuallycare.com/settings?tab=api`). Every user can create their own keys: Settings → API Keys → Create API Key. The full key is shown once at creation, so store it immediately; the UI only displays the first 8 and last 4 characters afterward.

Pass the key in the `X-API-Key` header on every request, as in the quickstart above. See [API authentication](/api/authentication) for key creation via the REST API and key management details.

### JWT bearer token (user-session apps)

If your app already signs users in against the ActuallyCare REST API, the same JWT works against the MCP server. Log in via the REST API to get an access token, then send it as `Authorization: Bearer` instead of `X-API-Key`:

```typescript
const transport = new StreamableHTTPClientTransport(
  new URL("https://mcp.actuallycare.com/mcp"),
  {
    requestInit: {
      headers: { Authorization: `Bearer ${accessToken}` },
    },
  },
);
```

Access-token lifetime is role-based (15 minutes to 8 hours), so build in token refresh. The login and refresh flows are documented in [API authentication](/api/authentication).

### OAuth 2.1 (user-delegated apps)

For apps acting on behalf of users who shouldn't hand you their password, the MCP server supports OAuth 2.1 with PKCE and dynamic client registration. Register your client with `POST /register` on `mcp.actuallycare.com`, then run the standard authorization-code flow with PKCE: the user logs into ActuallyCare, approves a consent screen, and your app receives tokens scoped to that user. Request the scopes you need from `read`, `write`, and `mcp` (`mcp` is required for tool calls; the full list is published in the [authorization-server metadata](https://mcp.actuallycare.com/.well-known/oauth-authorization-server)). Access tokens last 24 hours and refresh tokens rotate automatically, so the connection stays live without re-approval.

This is the same flow Claude itself uses when someone adds ActuallyCare as a connector.

## Drive the tools with Claude

The most common custom app is a thin loop: send a question to Claude through the Anthropic API, hand it the ActuallyCare tool definitions, execute whatever tools it asks for against the MCP server, and feed the results back.

```bash
npm install @anthropic-ai/sdk
```

```typescript
import Anthropic from "@anthropic-ai/sdk";

const anthropic = new Anthropic(); // reads ANTHROPIC_API_KEY from the environment

// 1. Fetch tool definitions from the MCP server and convert to Anthropic's shape
const { tools } = await mcp.listTools();
const anthropicTools = tools.map((t) => ({
  name: t.name,
  description: t.description ?? "",
  input_schema: t.inputSchema,
}));

// 2. The tool-use loop
let messages: Anthropic.MessageParam[] = [
  { role: "user", content: "Which of my escrows are closing soonest?" },
];

while (true) {
  const response = await anthropic.messages.create({
    model: "claude-sonnet-4-6",
    max_tokens: 16000,
    tools: anthropicTools,
    messages,
  });

  if (response.stop_reason !== "tool_use") {
    for (const block of response.content) {
      if (block.type === "text") console.log(block.text);
    }
    break;
  }

  // 3. Execute each tool_use block against the MCP server
  messages.push({ role: "assistant", content: response.content });

  const toolResults: Anthropic.ToolResultBlockParam[] = [];
  for (const block of response.content) {
    if (block.type === "tool_use") {
      const result = await mcp.callTool({
        name: block.name,
        arguments: block.input as Record<string, unknown>,
      });
      toolResults.push({
        type: "tool_result",
        tool_use_id: block.id,
        content: JSON.stringify(result.content),
      });
    }
  }

  // 4. Return the results and let Claude continue
  messages.push({ role: "user", content: toolResults });
}
```

Because `listTools()` only returns the advertised hot-list, Claude starts with the most-used tools. That's usually what you want — but if your app needs the whole surface up front, build your tool array from [/tools.json](/tools.json) instead. Every documented tool is callable by name whether or not it's advertised.

For the full tool-use pattern — streaming, parallel tool calls, error handling — see the Anthropic documentation at [docs.anthropic.com](https://docs.anthropic.com).

## Operational notes

| Limit | Value |
|---|---|
| Requests | 120 per minute per user |
| Tool calls | 30 per minute per user |
| Tool execution timeout | 300 seconds |

A few things to design around:

- **Back off on rate-limit errors.** The per-user limits above apply across everything that user's credentials touch, so a busy dashboard and a background job sharing one key share one budget.
- **Long-running tools.** Most tools return in well under a second, but the server allows up to 300 seconds per call — keep your own HTTP timeouts above that if you call heavyweight tools.
- **Progressive disclosure.** As covered above, `tools/list` advertises a hot-list and discovery tools find the rest on demand. Consult [/tools.json](/tools.json) for the complete, always-current catalog.
- **Built-in prompts.** The server ships ready-made prompt templates — `daily_summary`, `escrow_status_check`, `hot_leads_followup`, and more — that your app can list and invoke via the standard MCP prompts capability. The full reference is at [/prompts](/prompts).

## Security

- **Keep credentials server-side.** Never put an API key, JWT, or OAuth token in browser JavaScript — anything shipped to the client is readable by anyone who opens dev tools. Browser apps should talk to your backend, and your backend talks to the MCP server.
- **Scope keys minimally.** Scopes are what **grant** a key access — a key created without scopes has none and gets `403` on everything. Grant each app the narrowest scopes that work (for example `{"all":["read"]}` for a read-only dashboard, or `{"clients":["read","write"]}` for a clients-only app), and set an expiration rather than minting forever-keys — a key without one never expires.
- **One key per app.** Separate keys per integration make rotation painless and let you revoke a compromised app without breaking the others.
- **Treat keys like passwords.** They're stored hashed and shown once — if a key leaks, revoke it and create a new one.
