Expose your AMS to an agent with an MCP server

Every agent we build needs the same three things from the agency management system: find the account, read the policy, read the activity log. Twelve months ago each of those was a hand-written function wired into one framework. Now the Model Context Protocol is the common way a model calls a tool, the major model vendors and IDEs speak it, and it is a reasonable place to put an AMS boundary.

This tutorial builds a small MCP server over agency data with four tools, all read-only. HawkSoft's Partner API is the worked example because it is the one you can get access to fastest. The same server shape works over an Applied Epic or EZLynx data path; only the client class underneath changes.

You need Python 3.10+, credentials for whichever AMS API your agency is entitled to use, and somewhere to run a process. Nothing here writes to the AMS.

Why a protocol boundary helps

MCP is a spec for how a model host discovers and calls tools over JSON-RPC. The specification is short and worth 20 minutes. Three practical reasons we use it against an AMS:

  • One integration, several consumers. The renewal agent, the certificate agent, and an engineer debugging in an IDE all call the same find_account. When HawkSoft changes a field name, one file changes.
  • The tool list is the permission list. What the server exposes is exactly what the model can reach. That is a sentence you can put in front of an E&O carrier: the agent could not update a policy because no tool existed to update a policy.
  • Logs land in one place. Every tool call is a JSON-RPC message. Log the arguments and the result size and you have a per-call audit trail without instrumenting the model.

The cost is a new process to run and a new attack surface to think about. Both are manageable. Getting the tool boundary wrong is not.

Start read-only, on purpose

We do not put AMS writes behind MCP. A model deciding to call create_activity because a document told it to is the failure mode we spend most of our design time avoiding. Writes go through the approval path instead: the agent drafts, a licensed person approves, and a separate service does the write with its own idempotency key. That split is covered in writing agent output back to the AMS without duplicates.

So the server gets four tools:

ToolInputReturns
find_accountname or DBA fragment, optional city/stateup to 10 candidate accounts with ids and match reasons
get_accountaccount idaccount header, contacts, open items count
list_policiesaccount id, optional line of businesspolicies with term dates, carrier, status
list_activitiesaccount id, since date, optional limitactivity log entries, newest first

That set covers the read half of renewals, certificates, and service-request triage. Resist adding a fifth until an agent has failed for want of it.

The server

The Python SDK's FastMCP gets you a compliant server in a few lines. Type hints and docstrings become the tool schema the model sees, so write the docstring for the model, not for a colleague.

import os
from typing import Optional
from mcp.server.fastmcp import FastMCP
from ams_client import HawkSoftClient, AmsError

mcp = FastMCP("bindmatic-ams-read")
ams = HawkSoftClient(
    base_url=os.environ["AMS_BASE_URL"],
    api_key=os.environ["AMS_API_KEY"],
    timeout=15,
)

MAX_CANDIDATES = 10


@mcp.tool()
def find_account(query: str, state: Optional[str] = None) -> dict:
    """Search accounts by insured name or DBA fragment.

    Returns up to 10 candidates, each with an account_id, the matched
    name, city and state. Never returns a single 'best' answer: if two
    candidates look alike, ask a human which one is meant.
    """
    rows = ams.search_accounts(query=query, state=state, limit=MAX_CANDIDATES)
    return {
        "query": query,
        "count": len(rows),
        "candidates": [
            {
                "account_id": r.id,
                "name": r.name,
                "dba": r.dba,
                "city": r.city,
                "state": r.state,
            }
            for r in rows
        ],
    }


@mcp.tool()
def list_policies(account_id: str, line_of_business: Optional[str] = None) -> dict:
    """List policies on an account, newest term first.

    Includes policy_number, carrier, line_of_business, effective and
    expiration dates, and status. Does not include endorsement text or
    forms; those are documents, not policy fields.
    """
    rows = ams.list_policies(account_id=account_id, lob=line_of_business)
    return {
        "account_id": account_id,
        "policies": [
            {
                "policy_number": p.number,
                "carrier": p.carrier,
                "line_of_business": p.lob,
                "effective": p.effective.isoformat(),
                "expiration": p.expiration.isoformat(),
                "status": p.status,
            }
            for p in rows
        ],
    }


if __name__ == "__main__":
    mcp.run(transport="stdio")

Three things in there are deliberate.

The docstring says what the tool does not return. list_policies gives fields, not endorsement wording, because an agent that assumes it has seen the forms will happily assert a blanket additional insured endorsement exists. Tell it where the edge is.

find_account returns candidates, never a winner. Disambiguation is scoring work with an abstain threshold, and it belongs in your matching pipeline, not in a model's head. We describe that pipeline in matching an inbound request to the right AMS account.

The result is a dict with a count. Models handle explicit empties better than they handle a bare [], and it costs you nothing.

Cap what comes back

An activity log on a 15-year account can run thousands of entries. Return all of them and you blow the context window, pay for it, and make the model worse. Cap at the tool, not in the prompt.

ACTIVITY_HARD_LIMIT = 50


@mcp.tool()
def list_activities(account_id: str, since: str, limit: int = 25) -> dict:
    """Activity log entries for an account since an ISO date (newest first).

    limit is capped at 50. If truncated is true, narrow the date range
    rather than asking for more.
    """
    limit = max(1, min(limit, ACTIVITY_HARD_LIMIT))
    rows, total = ams.list_activities(account_id, since=since, limit=limit)
    return {
        "account_id": account_id,
        "since": since,
        "returned": len(rows),
        "total_matching": total,
        "truncated": total > len(rows),
        "activities": [
            {
                "date": a.date.isoformat(),
                "type": a.type,
                "user": a.user,
                "summary": a.summary[:400],
            }
            for a in rows
        ],
    }

Truncate the free-text summary too. Activity notes are pasted email threads more often than not, and the whole thread is rarely the signal.

Errors the model can act on

A stack trace teaches a model nothing. Convert AMS failures into a short, honest string and let the agent decide whether to retry, abstain, or hand off.

from mcp.server.fastmcp.exceptions import ToolError


def guarded(fn):
    def wrapper(*args, **kwargs):
        try:
            return fn(*args, **kwargs)
        except AmsError as e:
            if e.status == 404:
                raise ToolError("No such account in this AMS. Do not guess an id.")
            if e.status == 429:
                raise ToolError("AMS rate limit hit. Stop and retry this account later.")
            if e.status >= 500:
                raise ToolError("AMS unavailable. Leave the item unprocessed.")
            raise ToolError(f"AMS rejected the request: {e.code}")
    return wrapper

"Leave the item unprocessed" is the instruction you want on a 500. An agent that improvises around an unavailable AMS is worse than an agent that stops.

Treat AMS text as untrusted input

Everything this server returns can contain text an outside party wrote: a broker's email pasted into an activity note, an insured's DBA, a document filename. That text arrives in the model's context labelled as a tool result, which is exactly the position an injected instruction wants to be in. The controls we use are in defending a document agent against prompt injection; the MCP-specific version is short:

  • Return data in structured fields, never as prose the model reads as narration.
  • Keep the write tools out of the same server, so no injected sentence has a lever to pull.
  • Log every tool call with arguments and the caller's session, and sample them weekly against what the agent then did.

Access, per system

The protocol is the easy part; entitlement is not.

  • HawkSoft publishes a Partner API, and getting a sandbox is the fastest of the three.
  • Applied Epic access runs through Applied's developer and vendor certification track, and the review takes months. Plan the work around a data path you already have while it runs, and do not let anyone tell an agency the certification is done before it is.
  • EZLynx exposes integration endpoints under agreement; scope depends on what the agency's own contract covers.

In every case the credential belongs to the agency, in the agency's own single-tenant AWS account, and it should be read-only if the API supports that distinction. Ours do not get to be more privileged than the CSR whose work they support.

What this does not solve

An MCP server makes AMS data reachable. It does not make an agent correct. Matching still needs scoring and an abstain rule, drafts still need a licensed person to approve them, and accuracy still needs to be measured on your own closed files before anything goes live: see scoring a silent run before go-live. The server is plumbing, and plumbing is worth doing once, properly.

We build these boundaries inside the agency's own AWS account, with the client owning the code. If you are standing one up on Epic, HawkSoft, or EZLynx and want a senior engineer on it, contact us and say which AMS you are on.