Skip to main content
Mastra is a TypeScript agent framework: you give an Agent a model, instructions, tools, and memory, and it runs the tool-calling loop — plus workflows, RAG, and evals — until the job is done.
  • What Mastra ships — agents, the model-calling loop, tools, workflows, memory, RAG, streaming, and first-class MCP support.
  • What it doesn’t ship — a way for those agents to act as a real account: sign up for a SaaS tool, hold a funded card, or spend within a budget you control, per end-user.
That’s the half Naive adds. You keep Mastra’s orchestration; Naive gives each agent a tenant identity, a virtual card, 1,000+ third-party connections, and an Account Kit that bounds exactly what the agent can do — enforced server-side, with human approval on the sensitive actions.

How the pairing works

  • Mastra has first-class MCP support via MCPClient — point it at any MCP server and its tools are discovered and converted into Mastra tools automatically (no manual schema wiring).
  • Naive ships a hosted MCP server and mints per-user sessions — short-lived, revocable endpoints whose tool list is the fused native + third-party toolset, already filtered by that user’s Account Kit.
  • MCPClient.listToolsets() is built for exactly this: per-request, per-user tool config (Mastra’s documented multi-tenant pattern). Mint a session for one user, hand it to the agent for one run, every tool call runs as that user — gated server-side.
  Mastra (agent.generate)               Naive
  ─────────────────────                 ─────
  agent ── model step ──▶ tool call
        │  model picks an MCP tool

  MCPClient (SSE + scoped bearer) ──▶  /mcp/sse/sess_…   (per-user session)
                                          │  AccountKit-gated, scoped to one user

                                    connect GitHub · issue a $50 card · run a capability

                                    sensitive? → 202 pending_approval (human-in-the-loop)
Tested against: @usenaive-sdk/server 0.12.x (Naive API v2, hosted MCP server over SSE + per-user sessions), @mastra/core 1.46.x (Agent, generate), and @mastra/mcp 1.12.x (MCPClient, listToolsets, requireToolApproval), on Node ≥ 20.Naive’s MCP server uses SSE transport. Mastra’s MCPClient tries Streamable HTTP first and falls back to SSE, and SSE with custom headers requires both requestInit and eventSourceInit (a known MCP-SDK quirk) — both are wired below. On older Mastra (@mastra/mcp 0.x), listTools() / listToolsets() were named getTools() / getToolsets(). Pin your versions and adjust the model id to a provider you have access to.

Prerequisites

  • A Naive API key (nv_sk_...) — get one from the dashboard.
  • A model provider key for the model that runs the agent. This guide uses the Mastra model router ("anthropic/claude-sonnet-4-5"), which reads ANTHROPIC_API_KEY.
  • Node ≥ 20.
npm install @usenaive-sdk/server @mastra/core @mastra/mcp
export NAIVE_API_KEY=nv_sk_live_...
export ANTHROPIC_API_KEY=sk-ant-...

Minimal viable integration

The shortest path to a Mastra agent that can actually transact: define a policy and provision a user (control plane, once), then at runtime mint a per-user MCP session and hand it to the agent.
1

Define the policy, then provision a user

An Account Kit is the spend/capability policy. Here a tenant user gets a card (capped at $500, approval required), the vault, and an allowlist of apps. Everything the agent does is bounded by this kit — server-side. These are one-time control-plane calls:
import { Naive } from "@usenaive-sdk/server";

const naive = new Naive({ apiKey: process.env.NAIVE_API_KEY! });

// Control plane: a reusable policy template.
const pro = await naive.accountKits.create({
  name: "Pro",
  primitives_config: {
    cards: { enabled: true, requiresApproval: true, defaults: { spending_limit_cents: 50000 } },
    vault: { enabled: true },
  },
  connections_config: { mode: "allowlist", toolkits: ["github", "gmail", "stripe"] },
});

// Provision one of your end-users and assign the kit.
const alice = await naive.users.create({
  external_id: "user_123",
  email: "alice@acme.com",
  account_kit_id: pro.id,
});

// Data plane: a client bound to Alice. No call here takes a userId — the scope is fixed.
const client = naive.forUser(alice.id);
2

Mint a per-user MCP session and connect it

At runtime, mint a short-lived session for the user and point Mastra’s MCPClient at its scoped SSE endpoint. The bearer lives in the session headers, never in the URL, and expires (default 15 min, max 24h):
import { MCPClient } from "@mastra/mcp";

const session = await client.session({ ttlMs: 15 * 60 * 1000 });

const mcp = new MCPClient({
  servers: {
    naive: {
      url: new URL(session.mcp.url),         // scoped, per-user endpoint
      // POST messages carry the scoped bearer...
      requestInit: { headers: session.mcp.headers },
      // ...and so must the SSE connection (required for SSE + custom headers).
      eventSourceInit: {
        fetch(input: Request | URL | string, init?: RequestInit) {
          const headers = new Headers(init?.headers);
          for (const [k, v] of Object.entries(session.mcp.headers)) headers.set(k, v);
          return fetch(input, { ...init, headers });
        },
      },
    },
  },
});
3

Build the agent — and let it transact

Define an Agent and pass the session’s tools per call via listToolsets(). Because the toolset is minted per request, the same agent definition can serve every tenant — each run is scoped to whichever user’s session you pass:
import { Agent } from "@mastra/core/agent";

const agent = new Agent({
  name: "Ops Agent",
  instructions:
    "You are Alice's operations agent. Use Naive tools to act on her real account.",
  model: "anthropic/claude-sonnet-4-5", // or an @ai-sdk/* provider instance
});

try {
  const res = await agent.generate(
    "Connect my GitHub, then issue a $50 virtual card called 'Ads budget' for our marketing spend.",
    { toolsets: await mcp.listToolsets() }, // per-user tools, scoped to this session
  );
  console.log(res.text);
} finally {
  await mcp.disconnect();
}
The model discovers GitHub, returns a connect link for Alice to authorize, and attempts to issue the card — a real card on Alice’s account, capped by her kit. The whole agent loop is Mastra’s; the real-world actions are Naive’s.
That’s the moat in ~50 lines: the same Mastra agent that would otherwise just describe spending money now issues a policy-bounded card on a specific user’s account.

Extension: human-in-the-loop spend (two gates)

Because the kit set cards.requiresApproval: true, the agent cannot silently spend. You get two complementary layers — pair them for defense in depth:
  • In the agent — Mastra’s requireToolApproval interrupts before a tool from that server runs, so the run pauses and surfaces the call for review.
  • On the server — even if a call gets through, Naive freezes it and returns a pending approval (HTTP 202) instead of a live card. This holds no matter what runtime calls it.
Set requireToolApproval on the Naive server so write actions pause for review. Pass a function to keep read-only discovery (search/list) flowing while gating everything else:
const mcp = new MCPClient({
  servers: {
    naive: {
      url: new URL(session.mcp.url),
      requestInit: { headers: session.mcp.headers },
      eventSourceInit: { /* …as above… */ },
      // Skip approval for tools the server marks read-only; gate the rest.
      requireToolApproval: ({ annotations }) => !annotations?.readOnlyHint,
    },
  },
});
Tool annotations are advisory hints, not a security boundary. Treat requireToolApproval as a UX/early-interrupt convenience — the real enforcement is Naive’s server-side approval gate below, which applies regardless of prompt or agent configuration. You can also pass requireToolApproval: true to gate every Naive tool.
When a call does reach Naive, the tool result comes back as a pending approval rather than a live card:
{
  "status": "pending_approval",
  "approval_id": "65589c8b-e033-4a65-b16c-379211c94429",
  "action": "cards.create",
  "primitive": "cards",
  "title": "Issue virtual card \"Ads budget\"",
  "message": "This action requires human approval before it executes."
}
The immediate 202 / isPendingApproval payload uses action; approval records from approvals.list() or approvals.get() use action_type. Your app then resolves it out of band — and on approval, Naive replays the frozen action server-side:
import { isPendingApproval } from "@usenaive-sdk/server";

// Find what the agent queued for Alice.
const { approvals } = await client.approvals.list({ status: "pending" });

for (const a of approvals) {
  // ...show a.title / a.action_type to a human in your UI...
  await client.approvals.approve(a.id); // API replays cards.create → real card
  // or: await client.approvals.deny(a.id, { reason: "over budget" });
}
You can also catch the pending state at the SDK call site with isPendingApproval(res), or poll a single approval to completion with client.approvals.wait(approvalId). See Approvals for the full lifecycle (pending → executed / failed / denied).
Approvals are only enforced for agent (API-key / MCP) calls on real tenant users. A human acting in your dashboard, and agent calls on the operator’s own default user, bypass the gate — so end-user agents stay governed while your own automation isn’t slowed down.

Alternative: static tools for single-user automation

If the agent only ever acts as one account — your own back-office automation, a CLI, an internal bot — you don’t need per-request toolsets. Mint a session for the default user, load its tools once with listTools(), and bake them into the agent at construction:
import { Agent } from "@mastra/core/agent";
import { MCPClient } from "@mastra/mcp";

const session = await naive.session({ ttlMs: 24 * 60 * 60 * 1000 }); // default user

const mcp = new MCPClient({
  servers: {
    naive: {
      url: new URL(session.mcp.url),
      requestInit: { headers: session.mcp.headers },
      eventSourceInit: {
        fetch: (input: Request | URL | string, init?: RequestInit) =>
          fetch(input, { ...init, headers: { ...init?.headers, ...session.mcp.headers } }),
      },
    },
  },
});

const agent = new Agent({
  name: "Back-office Agent",
  instructions: "Operate the company's own Naive account.",
  model: "anthropic/claude-sonnet-4-5",
  tools: await mcp.listTools(), // fixed at construction, namespaced naive_*
});
Prefer the loop to run entirely in your backend without MCP? naive.forUser(id).agentTools() returns the same capabilities as a discover-then-run toolset with a handle(name, input) dispatcher you adapt into Mastra’s createTool (convert each tool’s JSON Schema to a Standard Schema such as Zod). Same Account Kit, same approval gates — just in-process instead of over MCP.

What stays enforced

No matter which path you choose, the policy is enforced where it matters — on Naive’s servers, not in your prompt or your agent:
  • Identity — every action runs as a specific tenant user, fully isolated from your other users.
  • Capability bounds — the Account Kit decides which primitives and which apps the agent can touch (allowlist / blocklist / per-tool).
  • Scoped spend — virtual cards are capped per card and per user; the model can’t raise its own limit.
  • Human-in-the-loop — sensitive actions (cards, domains, KYC, formation, connecting an app) freeze as approvals until a human says yes.

Next steps

  • SDK overview — the full Naive client surface
  • Sessions — per-user MCP sessions for any MCP-aware runtime
  • Agent tools — what agentTools() exposes and how handle() works
  • Account Kits — author spend/capability policy
  • Approvals — the human-in-the-loop lifecycle
  • MCP server — how Naive’s hosted MCP server works