Announcing CIMD support for MCP Client registration
Learn more

Plaid MCP vs Plaid API for AI Agents (2026)

Hrishikesh Premkumar
Founding Architect

Plaid ships two very different surfaces for an AI agent: a hosted Dashboard MCP server that answers questions about your own Plaid integration, and the REST API that reaches your end users' bank data. Unlike most tools in this series, they barely overlap, and a Plaid agent usually holds three kinds of credential at once. This guide compares what each path lets an agent do, how the team secret and the per-Item access tokens fit together, and how to keep both out of model context across many tenants in production.

TL;DR

  • Plaid's official MCP server is the Dashboard MCP server at https://api.dashboard.plaid.com/mcp/. It has five tools (plaid_debug_item, plaid_get_link_analytics, plaid_get_usages, plaid_list_teams, plaid_get_tools_introduction) for your own team's Item diagnostics, Link conversion analytics, and API usage. It runs on Production data only, Plaid calls it "under active development" with possible breaking changes, and it does not return end users' bank data.
  • The Plaid API is where the bank data lives. Every call carries your team's client_id and an environment-specific secret, plus a per-Item access_token that your app gets by exchanging a Plaid Link public_token (valid 30 minutes, single use). By default that access_token never expires.
  • The two paths use different credentials, but they share a root. The Dashboard MCP token comes from the Client Credentials grant at https://production.plaid.com/oauth/token with the mcp:dashboard scope, and expires after 15 minutes. It is minted with the same Production secret that, together with an access_token, reads any linked bank account. Only the MCP token is narrowly scoped.
  • Use MCP when a developer or support engineer in Claude or ChatGPT is asking why Link conversion dropped or why one Item is stuck in ITEM_LOGIN_REQUIRED. Use the API for any agent that reads balances, transactions, identity, or liabilities, reacts to SYNC_UPDATES_AVAILABLE webhooks, or manages the Item lifecycle.
  • Scalekit's plaid connector exposes 27 prebuilt tools over the Plaid API and keeps each customer's Plaid client ID and secret in the token vault, the plaidsandbox connector adds 5 Sandbox-only tools for testing, and the plaidmcp connector handles Dashboard MCP token minting and refresh, so the MCP vs API decision doesn't change your auth infrastructure.

The decision your Plaid agent forces first

Your agent needs to work with Plaid: summarize an applicant's income deposits and recurring obligations for an underwriter, catch a large debit the moment it posts, nudge a user whose bank login broke, or explain to your support team why connections to one bank failed all morning. Plaid ships a hosted Model Context Protocol (MCP) server and a REST API. Unlike most tools in this series, the two barely overlap. The MCP server answers questions about your Plaid integration. The API answers questions about your end users' money. The auth model underneath each one is also different, and a Plaid agent usually holds three kinds of credential at once. Here's how to pick, and what each choice puts on your plate.

What Plaid MCP and Plaid API actually are

Plaid MCP

The Dashboard MCP server is a remote server hosted by Plaid at https://api.dashboard.plaid.com/mcp/, using the Streamable HTTP transport. Plaid announced it on May 1, 2025 as a way to bring developer tooling into Claude for usage monitoring, Link conversion analysis, and support troubleshooting, and stated that "Claude does not have access to consumer financial data." The docs page still describes it as under active development, warns that breaking changes may occur, and says Plaid provides limited support. There is no GA date.

Auth is OAuth 2.0 Client Credentials: you POST your client_id, your Production secret, and scope: mcp:dashboard to https://production.plaid.com/oauth/token, and get back an access token that lasts 15 minutes plus a refresh token. Your team must be approved for Production with at least one Plaid product. Clients that accept remote MCP URLs, such as Claude.ai and ChatGPT, can connect, and so can the OpenAI Responses API (mcp tool type, token in authorization) and Anthropic's MCP connector in the Messages API (token in authorization_token).

The five tools are plaid_debug_item, which diagnoses one Item from its metadata; plaid_get_link_analytics, for Link funnel, conversion, and error metrics; plaid_get_usages, for product usage and API request volumes; plaid_list_teams, for the teams the token can reach; and plaid_get_tools_introduction. Plaid also ships a separate local MCP server in its AI coding toolkit that runs as a subprocess of Claude, Cursor, Codex, VS Code, or Zed and provides mock data, docs search, Sandbox tokens, and webhook simulation. It is a development aid, not a production surface, so this article compares the Dashboard server.

Plaid API

The Plaid API is a POST-only JSON API with two environments: Sandbox at https://sandbox.plaid.com and Production at https://production.plaid.com. Almost every endpoint takes a client_id and secret, sent in the body or in the PLAID-CLIENT-ID and PLAID-SECRET headers. The client ID is the same in every environment; the secret is not. Items cannot move between environments, and Sandbox supports only test Items.

Data endpoints also take an access_token for one Item, which is one end user's login at one financial institution. Your app gets it through Plaid Link: create a link_token, open Link in your app, receive a public_token on success, and exchange it with /item/public_token/exchange for the access_token and item_id. Products include Transactions, Auth (account and routing numbers), Balance, Identity, Investments, Liabilities, and Statements.

Comparing them where it matters for agents

What your agent can actually do

Capability
Plaid Dashboard MCP
Plaid API
Accounts and balances
No consumer financial data
Yes, Scalekit plaid_accounts_get (cached, free) and plaid_accounts_balance_get (real time, billed)
Transactions
No
Yes, Scalekit plaid_transactions_sync, plaid_transactions_get, plaid_transactions_recurring_get, plaid_transactions_refresh
Account and routing numbers
No
Yes, Scalekit plaid_auth_get
Identity data and identity match
No
Yes, Scalekit plaid_identity_get, plaid_identity_match
Investments, liabilities, statements
No
Yes, Scalekit plaid_investments_holdings_get, plaid_liabilities_get, plaid_statements_list, plaid_statements_refresh
Link tokens and public token exchange
No
Yes, Scalekit plaid_link_token_create, plaid_link_token_get, plaid_item_public_token_exchange
Diagnose a broken Item
Yes, plaid_debug_item
Limited: Scalekit plaid_item_get returns status, products, and the current error such as ITEM_LOGIN_REQUIRED; you interpret it
Link conversion analytics
Yes, plaid_get_link_analytics
Limited: Scalekit plaid_link_token_get returns one session's events; no funnel aggregates
API usage and request volumes
Yes, plaid_get_usages
Not in the connector
Item lifecycle: remove, rotate token, change webhook
No
Yes, Scalekit plaid_item_remove, plaid_item_access_token_invalidate, plaid_item_webhook_update
Institution lookup and health
Not among the five tools
Yes, Scalekit plaid_institutions_search, plaid_institutions_get_by_id with include_status
Sandbox testing
Production only
Yes, Scalekit plaidsandbox connector, including plaidsandbox_sandbox_item_fire_webhook and plaidsandbox_sandbox_item_reset_login
Event notifications
No triggers
Yes, Item and product webhooks; set per Item with plaid_item_webhook_update or at Link time

Read the table by column, not by row. The MCP column is a developer console. The API column is the product. An agent that touches an end user's bank data has exactly one path, and it is the API.

What the MCP path makes easier

Answering "why did Link conversion at one large bank drop 20 points since Tuesday" on the raw API is not a pagination exercise. It is mostly impossible. The connector's plaid_link_token_get returns the events of one Link session, and only for Hosted Link, Multi-Item Link, or accounts where Plaid has enabled full session data. Aggregating funnels across thousands of sessions means logging Link events yourself from day one and building the analytics.

The Dashboard MCP server has that data already. plaid_get_link_analytics returns opens, institution selections, and handoffs for a date range, and plaid_debug_item explains an Item's state in terms a support engineer can act on. For a developer working an incident in Claude, that is a five-minute conversation instead of a dashboard crawl. Writing the schema is the hard part. Not the API call.

The tradeoff is ownership. Plaid controls the tool names and outputs, says breaking changes may occur, and documents several arguments, such as team_id, only by example. An agent tuned against today's responses can change behavior without a release on your side.

The auth path each one puts you on

On the MCP path, auth is machine to machine. There is no browser consent and no per-person identity: the token represents your Plaid team, and plaid_list_teams returns whichever teams that token can reach. Access tokens last 15 minutes, so a long agent session refreshes several times by POSTing grant_type=refresh_token to the same /oauth/token endpoint. The page does not say whether refresh tokens expire. Sandbox secrets are not accepted, which means the only way to try the server is with Production credentials.

On the direct API path, there are two layers. The first is the team credential: client_id plus a secret per environment, issued in the Plaid Dashboard under Developers > Keys. It is not scoped. Any holder can call any endpoint your team is enabled for. The second is the Item credential: an access_token per bank login, which Plaid documents as non-expiring by default, to be "stored in a persistent, secure manner." It is revoked only by /item/remove, rotated by /item/access_token/invalidate, or broken by the institution. Link tokens last 4 hours for a new Item and 30 minutes in update mode.

Expiry still happens, just not on your schedule. Institutions in Europe and a small number in the US set consent_expiration_time, and Plaid fires PENDING_EXPIRATION (EU and UK) or PENDING_DISCONNECT (US and Canada) seven days ahead. A bank password change puts the Item in ITEM_LOGIN_REQUIRED until the user completes Link update mode. None of this is token refresh. Every recovery requires the end user.

The Plaid-specific catch is that authorization lives in your database, not in a token. A Plaid access_token is a bearer credential for one person's bank login, and nothing in it says which of your users or tenants it belongs to. What the user can't do, the agent can't do, but only if your code enforces which access_token a given agent run may use. On the MCP path that question never comes up, because no end-user data is reachable.

Both paths still need per-user isolation

In a multi-tenant B2B agent, MCP gives you a credential per tenant and the direct API gives you a credential per tenant. In neither case does the path itself solve storage, rotation, or revocation; those are infrastructure problems regardless of which path you choose.

Plaid adds a second axis. Suppose you run an underwriting platform used by 40 lenders, each with its own Plaid team. That is 40 sets of client_id and Production secret. Underneath them sit the Items: 40 lenders with 3,000 linked applicants each is 120,000 access_tokens, each bound to exactly one applicant at exactly one lender. An agent run for lender A must only ever see lender A's keys and the Items of the applicant it is working on. Mixing up a secret leaks a tenant. Mixing up an access_token leaks a person's bank history.

Recommended reading: access control for multi-tenant AI agents

What you own in production

On the MCP path, Plaid hosts the server, owns the schemas, and handles analytics aggregation. You own the token loop: mint, refresh every 15 minutes, re-mint when refresh fails, and keep the Production secret that does the minting out of every chat client config. You also own the gaps. The server has no write tools, no Sandbox, no triggers, and nothing about end-user data.

On the API path, you own schemas, pagination, retries, webhooks, and the Item store. Plaid's rate limits apply per Item and per client, and return RATE_LIMIT_EXCEEDED with HTTP 429. In Production, /transactions/sync allows 50 calls per minute per Item and 2,500 per minute per client, with only 500 per minute for empty-cursor requests. /accounts/balance/get allows 5 per minute and 30 per hour per Item. /institutions/get allows 50 per minute per client. The per-client limits are shared by every agent and backend job on that Plaid team, so a model that loops on plaid_institutions_get can starve your onboarding flow.

Some calls also cost money. plaid_accounts_balance_get is billed per call and can take 30 seconds or more; plaid_transactions_refresh and plaid_investments_refresh are separately billed add-ons. A model with real-time balance in its tool set will call it when cached balances would have done. That is a bill, not just latency.

Webhooks close the polling gap. Plaid signs each one with a JWT in the Plaid-Verification header. You must check alg is ES256, fetch the public key from /webhook_verification_key/get by the header's kid, verify the signature, reject anything whose iat is older than 5 minutes, and compare the SHA-256 of the raw body against the request_body_sha256 claim, per Plaid's verification guide. Your endpoint has 10 seconds to answer. Failed deliveries retry for up to 24 hours, starting at 30 seconds and quadrupling each time, and 4xx responses other than 408, 425, and 429 are treated as permanent failures. SYNC_UPDATES_AVAILABLE tells you an Item has new transactions; you fetch them with /transactions/sync from your stored cursor, and if pagination fails with TRANSACTIONS_SYNC_MUTATION_DURING_PAGINATION you restart the whole loop from the first page's cursor.

When to use MCP, when to use the API

Use Plaid Dashboard MCP when:

  • A support engineer in Claude asks why one customer's Item keeps failing, and plaid_debug_item explains the error and the fix in the same conversation.
  • A product manager wants Link conversion by institution for last week, compared with the week before, from plaid_get_link_analytics.
  • An engineering lead is checking API request volumes and product usage with plaid_get_usages before a billing review or a capacity change.
  • You are investigating your own integration interactively, with a person reading every answer and no end-user data involved.

Use the Plaid API when:

  • The agent reads an end user's money: balances before a transfer, 90 days of transactions for an underwriter, recurring outflows for a budgeting assistant, or liabilities for a debt-payoff plan.
  • The agent runs headless: a SYNC_UPDATES_AVAILABLE webhook triggers it to call plaid_transactions_sync and flag a large debit, or an ITEM ERROR webhook with ITEM_LOGIN_REQUIRED triggers plaid_link_token_create in update mode.
  • The workflow is deterministic: offboarding a user with plaid_item_remove, which also ends subscription billing for Transactions, Liabilities, and Investments, or rotating a possibly exposed token with plaid_item_access_token_invalidate.
  • You need Sandbox. The Dashboard MCP server is Production only; the plaidsandbox connector lets you create test Items with plaidsandbox_sandbox_public_token_create and simulate webhooks with plaidsandbox_sandbox_item_fire_webhook.

The credential problem that exists on both paths

Both paths hand you a Plaid credential per tenant. Neither hands you a vault. The underwriting platform above holds 40 Production secrets on either path: on the API path they authenticate every call, and on the MCP path they mint a 15-minute mcp:dashboard token every quarter hour. Every secret must be stored encrypted, kept out of logs, MCP client configs, and LLM context, rotated in the Plaid Dashboard when someone leaves, and removed when a lender churns.

The MCP path does not reduce that count. It adds a token loop on top of it, and the narrow scope of the MCP token does nothing for the secret that minted it. The tempting shortcut is to paste a Production secret into a desktop MCP client so the support team can use Dashboard MCP. That puts an unscoped key, one that reaches every Item's bank data through the API, onto every laptop with the config file.

Scalekit's plaid connector stores each customer's Plaid client ID and secret in the token vault and sends them with every call, and the plaidmcp connector exchanges them for mcp:dashboard tokens and refreshes those when they expire, so the MCP vs API decision doesn't change your auth infrastructure. The team credential never touches the agent runtime or the LLM context. Item access_tokens are a tool input on every Plaid tool, so they stay in your own encrypted store, and the code below keeps them out of the model's view.

Recommended reading: why a token vault matters for AI agent workflows

How to connect a Plaid agent with Scalekit

Scalekit's plaid connector is an API-path connector built by Scalekit with 27 tools: 17 read, 7 write, and 3 destructive. It covers accounts and balances, Auth, Identity and identity match, transactions (sync, date range, recurring, refresh), investments, liabilities, statements, institutions, Link tokens, public token exchange, Items, webhooks, token rotation, and Plaid users. It authenticates with an API key: each customer enters their Plaid client ID and Production secret once, and Scalekit sends them with every call against production.plaid.com. The plaidsandbox connector has the same 27 tools prefixed plaidsandbox_ plus 5 Sandbox-only tools, for 32 in total, and uses the Sandbox secret against sandbox.plaid.com.

Prerequisites and the connection name

In the Scalekit dashboard, go to AgentKit > Connections, create a Plaid connection, and for development a Plaid Sandbox connection. The name you give each connection is the connection_name your code passes, and the string must match the dashboard exactly. This is the most common integration error. Copy SCALEKIT_ENVIRONMENT_URL, SCALEKIT_CLIENT_ID, and SCALEKIT_CLIENT_SECRET from Developers > API Credentials into .env.

Each customer's Plaid team must be approved for Production. Their keys are in the Plaid Dashboard under Developers > Keys: the client ID, which is the same in every environment, and the Production secret. The examples below use one more piece of your own infrastructure: a plaid_items table where your app stores each Item's access_token, item_id, and sync cursor after Link succeeds. In production, encrypt the access_token column with your KMS; the examples read it from SQLite to stay short.

pip install scalekit-sdk-python python-dotenv langchain-openai "pyjwt[crypto]"

Authorize each customer once

get_or_create_connected_account returns the connected account for an identifier. If it is not ACTIVE, get_authorization_link returns a link to a Scalekit-hosted page where the customer's Plaid admin enters their Client ID and Production Secret. Here the identifier is your customer's ID, because the keys belong to their whole Plaid team. The agent never sees them.

import os from dotenv import load_dotenv from scalekit import ScalekitClient load_dotenv() scalekit_client = ScalekitClient( env_url=os.environ["SCALEKIT_ENVIRONMENT_URL"], client_id=os.environ["SCALEKIT_CLIENT_ID"], client_secret=os.environ["SCALEKIT_CLIENT_SECRET"], ) actions = scalekit_client.actions CONNECTION_NAME = "plaid" # must match the connection name in AgentKit > Connections identifier = "customer_lendco" # your app's stable ID for this customer's Plaid team account = actions.get_or_create_connected_account( connection_name=CONNECTION_NAME, identifier=identifier, ) if account.connected_account.status != "ACTIVE": link = actions.get_authorization_link( connection_name=CONNECTION_NAME, identifier=identifier, ) print("Enter the Plaid Client ID and Production Secret at:", link.link) input("Press Enter after saving the Plaid keys...") account = actions.get_or_create_connected_account( connection_name=CONNECTION_NAME, identifier=identifier, ) if account.connected_account.status != "ACTIVE": raise RuntimeError(f"Plaid is {account.connected_account.status}, not ACTIVE")

Retrieve the Plaid tools this account can call

This is not a catalog load. The agent asks for the tools this connected account is authorized to call, then narrows them to the six a read-only underwriting assistant needs. Loading all 27 would put plaid_item_remove, plaid_item_access_token_invalidate, and the billed plaid_accounts_balance_get in front of a model whose job is to summarize cash flow.

tools = actions.langchain.get_tools( identifier=identifier, connection_names=[CONNECTION_NAME], tool_names=[ "plaid_item_get", "plaid_accounts_get", "plaid_transactions_get", "plaid_transactions_recurring_get", "plaid_liabilities_get", "plaid_institutions_get_by_id", ], page_size=100, ) tool_map = {t.name: t for t in tools} print(sorted(tool_map))

Run the agent loop

Every Plaid data tool requires access_token. If the model supplies it, the model can pick any token it has seen, and the token lands in the conversation. So the loop rewrites each tool schema before the model sees it: access_token is removed and replaced with an item field whose only allowed values are aliases for the current applicant's Items. Your code resolves the alias to the real token at execution time. The model can name one of this applicant's bank logins; it can't name anyone else's.

import sqlite3 from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage from langchain_core.utils.function_calling import convert_to_openai_tool from langchain_openai import ChatOpenAI DB_PATH = "plaid_items.db" # CREATE TABLE plaid_items (customer_id TEXT, end_user_id TEXT, alias TEXT, # item_id TEXT PRIMARY KEY, access_token TEXT, cursor TEXT, status TEXT) def load_items(customer_id: str, end_user_id: str) -> dict: """Return {alias: {"item_id": ..., "access_token": ...}} for one end user.""" with sqlite3.connect(DB_PATH) as db: rows = db.execute( "SELECT alias, item_id, access_token FROM plaid_items " "WHERE customer_id = ? AND end_user_id = ? AND status = 'active'", (customer_id, end_user_id), ).fetchall() return {alias: {"item_id": item_id, "access_token": token} for alias, item_id, token in rows} def model_schema(tool, aliases: list) -> dict: """Swap the access_token input for an item alias the model can choose.""" spec = convert_to_openai_tool(tool) params = spec["function"]["parameters"] props = params.get("properties", {}) required = [r for r in params.get("required", []) if r != "access_token"] if "access_token" in props: props.pop("access_token") props["item"] = { "type": "string", "enum": aliases, "description": "Which of this applicant's linked bank logins to use.", } required.append("item") params["properties"] = props params["required"] = required return spec end_user_id = "applicant_48213" items = load_items(identifier, end_user_id) if not items: raise RuntimeError(f"No active Plaid Items for {end_user_id}") aliases = sorted(items) llm = ChatOpenAI(model="gpt-4o").bind_tools([model_schema(t, aliases) for t in tools]) messages = [ SystemMessage( "You help an underwriter review one applicant's bank data from Plaid. " f"The applicant's linked bank logins are: {', '.join(aliases)}. " "Call plaid_item_get first for each one; if an Item has an error such as " "ITEM_LOGIN_REQUIRED, report it and skip that Item. Plaid amounts are positive " "when money leaves the account. Dates are YYYY-MM-DD. Never guess account IDs." ), HumanMessage( "Summarize income deposits and recurring obligations from 2026-07-28 to " "2026-10-26: payroll sources with average amounts, recurring outflows over $100, " "and credit card minimum payments due." ), ] while True: response = llm.invoke(messages) messages.append(response) if not response.tool_calls: print(response.content) break for tc in response.tool_calls: tool = tool_map.get(tc["name"]) args = dict(tc["args"]) alias = args.pop("item", None) if tool is None: result = f"Unknown tool: {tc['name']}" elif alias is not None and alias not in items: result = f"Unknown item: {alias}" else: if alias is not None: args["access_token"] = items[alias]["access_token"] result = tool.invoke(args) messages.append(ToolMessage(content=str(result), tool_call_id=tc["id"]))

The tool results themselves carry no access_token: Item, account, transaction, and liability responses identify data by item_id and account_id. Leave plaid_auth_get and plaid_identity_get out of any tool set whose output goes to a model unless the task needs full account numbers or personal data.

Deterministic calls with execute_tool

For pipelines, skip the model. execute_tool runs one tool with a fixed input and returns data plus an execution_id you can log. This handler runs when your webhook endpoint receives a Plaid webhook for one customer. It verifies the Plaid-Verification JWT, then handles two events: SYNC_UPDATES_AVAILABLE, where it pages through plaid_transactions_sync from the stored cursor and flags debits of $500 or more, and an ITEM ERROR with ITEM_LOGIN_REQUIRED, where it creates an update-mode Link token so your app can ask the user to reconnect.

/webhook_verification_key/get is not a connector tool, so the handler calls it through the Scalekit API proxy with actions.request; Scalekit adds the customer's Plaid keys.

import hashlib import hmac import json import time import jwt # pyjwt[crypto] LARGE_DEBIT = 500.00 _key_cache = {} def plaid_verification_key(kid: str, customer_id: str) -> dict: if kid not in _key_cache: resp = actions.request( connection_name=CONNECTION_NAME, identifier=customer_id, method="POST", path="/webhook_verification_key/get", body={"key_id": kid}, ) if resp.status_code != 200: raise PermissionError(f"Key lookup failed: {resp.status_code}") _key_cache[kid] = resp.json()["key"] return _key_cache[kid] def verify_plaid_webhook(raw_body: bytes, verification_jwt: str, customer_id: str) -> None: header = jwt.get_unverified_header(verification_jwt) if header.get("alg") != "ES256": raise PermissionError("Plaid webhooks must be signed with ES256") jwk = plaid_verification_key(header["kid"], customer_id) if jwk.get("expired_at"): raise PermissionError("Verification key has expired") public_key = jwt.algorithms.ECAlgorithm.from_jwk(json.dumps(jwk)) claims = jwt.decode(verification_jwt, key=public_key, algorithms=["ES256"]) if time.time() - claims["iat"] > 300: raise PermissionError("Webhook is more than 5 minutes old") body_hash = hashlib.sha256(raw_body).hexdigest() if not hmac.compare_digest(body_hash, claims["request_body_sha256"]): raise PermissionError("Body does not match request_body_sha256") def item_by_id(item_id: str, customer_id: str): with sqlite3.connect(DB_PATH) as db: return db.execute( "SELECT end_user_id, access_token, cursor FROM plaid_items " "WHERE item_id = ? AND customer_id = ?", (item_id, customer_id), ).fetchone() def save_cursor(item_id: str, cursor: str) -> None: with sqlite3.connect(DB_PATH) as db: db.execute("UPDATE plaid_items SET cursor = ? WHERE item_id = ?", (cursor, item_id)) def sync_transactions(customer_id: str, item_id: str, access_token: str, start_cursor: str) -> list: for _attempt in range(3): cursor, added = start_cursor, [] try: while True: tool_input = {"access_token": access_token, "count": 500} if cursor: tool_input["cursor"] = cursor page = actions.execute_tool( tool_name="plaid_transactions_sync", tool_input=tool_input, connection_name=CONNECTION_NAME, identifier=customer_id, ) print(page.execution_id, item_id) added.extend(page.data.get("added", [])) cursor = page.data["next_cursor"] if not page.data.get("has_more"): save_cursor(item_id, cursor) return added except Exception as exc: if "TRANSACTIONS_SYNC_MUTATION_DURING_PAGINATION" not in str(exc): raise # Plaid requires restarting the whole loop from the original cursor raise RuntimeError(f"Sync for {item_id} kept changing during pagination") def handle_plaid_webhook(raw_body: bytes, verification_jwt: str, customer_id: str) -> None: verify_plaid_webhook(raw_body, verification_jwt, customer_id) event = json.loads(raw_body) row = item_by_id(event.get("item_id", ""), customer_id) if row is None: return end_user_id, access_token, cursor = row item_id = event["item_id"] if event["webhook_type"] == "TRANSACTIONS" and event["webhook_code"] == "SYNC_UPDATES_AVAILABLE": for txn in sync_transactions(customer_id, item_id, access_token, cursor): if txn["amount"] >= LARGE_DEBIT and not txn.get("pending"): print(f"Flag {end_user_id}: {txn.get('merchant_name') or txn['name']} " f"{txn['amount']:.2f} {txn.get('iso_currency_code')} on {txn['date']}") elif event["webhook_type"] == "ITEM" and event["webhook_code"] == "ERROR": if (event.get("error") or {}).get("error_code") != "ITEM_LOGIN_REQUIRED": return link = actions.execute_tool( tool_name="plaid_link_token_create", tool_input={ "client_name": "LendCo", "client_user_id": end_user_id, "country_codes": ["US"], "access_token": access_token, # update mode: no products "webhook": "https://api.lendco.example/plaid/webhook", }, connection_name=CONNECTION_NAME, identifier=customer_id, ) print(link.execution_id, "update-mode link_token for", end_user_id, link.data["expiration"])

Respond to Plaid with a 2xx before the sync runs, and run the handler from a queue: Plaid gives your endpoint 10 seconds, and a long transaction history takes more than that. Deduplicate on item_id plus webhook_code while a sync for the same Item is in flight. Two other tools belong only in pipelines like this one, behind a human or policy check: plaid_item_remove takes an access_token and permanently disconnects the Item when a user leaves, and plaid_item_access_token_invalidate returns new_access_token and kills the old one immediately, so write the new value before anything else runs. The Plaid Dashboard MCP server has no equivalent for any of these steps.

Multi-tool, multi-tenant Plaid agents on a virtual MCP server

The plaid connector has 27 tools. At roughly 200 tokens each, that is about 5,400 tokens of tool definitions before the agent does any work. The bigger issue with Plaid is not token count. It is overreach and cost: a support agent that holds plaid_item_remove can disconnect a customer's bank on a bad inference, and one that holds plaid_accounts_balance_get will spend money on every "what's my balance" question. The fix is not better prompting. It is surface reduction. A Scalekit virtual MCP server declares exactly which tools an agent role can see, and session tokens keep each run bound to one customer's connected accounts. Docs: set up a virtual MCP server.

Create the server once per agent role

This role handles one job: when an applicant's bank connection breaks, check the Item, check the institution's health, create an update-mode Link token, and tell the loan officer in Slack.

from scalekit.actions.models.mcp_config import McpConfigConnectionToolMapping vmcp = actions.mcp.create_config( name="plaid-connection-repair", description="Diagnoses broken Plaid Items, creates update-mode Link tokens, and notifies Slack", connection_tool_mappings=[ McpConfigConnectionToolMapping( connection_name="plaid", tools=[ "plaid_item_get", "plaid_institutions_get_by_id", "plaid_link_token_create", ], ), McpConfigConnectionToolMapping( connection_name="slack", tools=["slack_send_message"], ), ], ) print(vmcp.config.id, vmcp.config.mcp_server_url)

Mint a session token per customer, per run

One server definition serves every customer. Before each run, check that the customer's accounts are active and mint a short-lived session token scoped to them. Any MCP client that speaks Streamable HTTP can connect; this example uses langchain-mcp-adapters (pip install langchain-mcp-adapters) and passes the token as a Bearer header.

import asyncio from datetime import timedelta from langchain_mcp_adapters.client import MultiServerMCPClient config_id = vmcp.config.id mcp_server_url = vmcp.config.mcp_server_url customer_id = "customer_lendco" accounts = actions.mcp.list_mcp_connected_accounts( config_id=config_id, identifier=customer_id, include_auth_link=True, ) not_ready = [ a for a in accounts.connected_accounts if str(a.connected_account_status).upper() != "ACTIVE" ] for a in not_ready: print(f"{a.connection_name} is {a.connected_account_status}: {a.authentication_link}") if not_ready: raise RuntimeError("Connect the accounts above before starting this run") session_token = actions.mcp.create_session_token( mcp_config_id=config_id, identifier=customer_id, expiry=timedelta(minutes=30), ).token async def load_tools(): client = MultiServerMCPClient( { "scalekit": { "transport": "streamable_http", "url": mcp_server_url, "headers": {"Authorization": f"Bearer {session_token}"}, } } ) return await client.get_tools() mcp_tools = asyncio.run(load_tools()) print([t.name for t in mcp_tools])

The agent sees four tools instead of 27, and the session token carries this customer's Plaid keys, not anyone else's. The endpoint is static; the identity is per customer. The access_token input is still part of each Plaid tool's schema over MCP, so pass the loaded mcp_tools through the same model_schema rewrite and alias lookup from the agent loop before binding them to a model; the virtual server scopes the tenant, and your alias map scopes the end user. Unlike Plaid's own MCP server, this endpoint can carry end-user tools for the one role that needs them and leave them out of every other role. Dashboard MCP tools are not yet listed on Scalekit's plaidmcp connector, which shows them as coming soon; until then, Scalekit holds the mcp:dashboard token for that connection and calls go through actions.request, as the connector docs describe.

Observability: every Plaid action tied to a customer

Every execute_tool call returns an execution_id, and every execution is attributed to a connected account, which maps to one of your customers. Plaid's write tools return a request_id, and every Plaid response names the item_id. Log all three together with your own end_user_id, and the question "which agent run created an update-mode Link token for applicant 48213, and for which bank" is a lookup, not an investigation.

Attribution matters more on Plaid than on most APIs, because Plaid can't do it for you. Every API call for a customer runs under the same client_id; Plaid sees the team, not which of your users or agent runs asked. The Dashboard MCP server has the same property: it acts as the team. Your execution log is the only place that records which run touched which person's bank data. A shared credential across customers breaks even that: one set of Plaid keys serving several lenders means every action is attributed to the same team, and a leaked secret reaches every Item behind it. Shared credentials work for a single user. They do not survive a second user.

Recommended reading: audit trails for agent auth

Which one to build against

If your agent helps your own engineers and support staff understand your Plaid integration, debugging Items, reading Link conversion, and watching API usage with a person in the loop, Plaid's Dashboard MCP server is a good fit: it is hosted, it already holds analytics the API never exposes, and no end-user data can leak through it. Expect changes while Plaid develops it. If your agent reads balances, transactions, identity, or liabilities, reacts to webhooks, or manages the Item lifecycle, build against the Plaid API, because no other path reaches that data. Either way, you hold a Plaid secret per tenant that the MCP token does nothing to narrow, plus a growing set of non-expiring Item access_tokens that your code must bind to exactly one end user, and that's what needs production-grade infrastructure.

FAQs

What does Plaid's MCP server actually do?

Plaid's official server is the Dashboard MCP server at https://api.dashboard.plaid.com/mcp/. It has five tools, plaid_debug_item, plaid_get_link_analytics, plaid_get_usages, plaid_list_teams, and plaid_get_tools_introduction, for your own team's Item diagnostics, Link conversion analytics, and API usage. It runs on Production data only, is described by Plaid as under active development, and does not return any end user's bank data.

Can an AI agent read bank transactions through Plaid MCP?

No. Consumer financial data is only reachable through the Plaid API, where every data call carries a per-Item access_token obtained by exchanging a Plaid Link public_token. The MCP server is a developer console; an agent that touches balances, transactions, identity, or liabilities has exactly one path, and it is the API.

How is the Plaid Dashboard MCP token issued?

Through an OAuth 2.0 Client Credentials grant: you POST your client_id, your Production secret, and scope mcp:dashboard to https://production.plaid.com/oauth/token and receive an access token that expires after 15 minutes plus a refresh token. The MCP token is narrowly scoped, but it is minted with the same Production secret that, together with an access_token, reads any linked bank account.

Do Plaid access tokens expire?

By default a Plaid Item access_token never expires. It is revoked only by /item/remove, rotated by /item/access_token/invalidate, or broken by the institution. Some institutions set consent_expiration_time and Plaid fires PENDING_EXPIRATION or PENDING_DISCONNECT seven days ahead, and a bank password change puts the Item in ITEM_LOGIN_REQUIRED until the user completes Link update mode. Every recovery requires the end user.

How do you keep Plaid access tokens out of the model's context?

Rewrite each tool schema before the model sees it: remove the access_token input and replace it with an item alias whose only allowed values are the current applicant's linked bank logins. Your code resolves the alias to the real token at execution time, so the model can name one of this applicant's Items but can never name anyone else's, and no token lands in the conversation.

How does Scalekit connect an AI agent to Plaid?

The plaid connector exposes 27 tools over the Plaid API and keeps each customer's client ID and Production secret in the token vault, the plaidsandbox connector adds 5 Sandbox-only tools for testing, and the plaidmcp connector handles Dashboard MCP token minting and refresh. A virtual MCP server narrows the tools per agent role, session tokens bind each run to one customer, and every execution returns an execution_id.

Build your Plaid agent with Scalekit

Browse the Scalekit Plaid connector and the Plaid connector docs with the full tool list. For testing, use the Plaid Sandbox connector; for Plaid's own server, see the Plaid Dashboard MCP connector docs. See Scalekit pricing, or talk to us.

No items found.
On this page
Share this article
Scalekit
Tenant isolation

Acquire enterprise customers with
‍zero upfront cost.

Every feature unlocked. No hidden fees.