AIAgentsTutorial

Giving LLMs and agents fund data safely: tools, grounding, guardrails

Why factsheet PDFs are poor tool input and JSON is good, a getFund(isin) tool schema, grounding in dataAsOf, refusal rules and an MCP-style tool definition.Published 11 September 2026 · Updated 13 October 2026 · 5 min read · by FundFacts API

An assistant that can answer "what does this fund hold and what does it cost?" is one of the more useful things to put in front of advisers, support teams and self-directed investors. It is also one of the easier ways to ship confident nonsense. The difference is almost entirely in what the model is given as tool output, and in the rules wrapped around it. This post is about the runtime side: an LLM or agent answering questions about funds, not an agent writing code (that is covered in the coding agent post). We use FundFacts API as the data tool, and the AI fund assistant use case as the target.

Why a factsheet PDF is a poor tool input

The obvious approach is retrieval: index the fund's factsheet and KID, let the model read the relevant chunks, answer. It works in demos and fails in production for reasons that are specific to fund documents:

  • The same fact has many names. "Fund size", "Net assets", "Total net assets" and "AUM" are one field. A chunk retriever does not know that; the model has to guess, and it guesses differently on different days.
  • Numbers lose their units. A table cell reading 151.7 is meaningless without the header three rows up saying "USD bn". Chunking separates them.
  • Dates are implicit. A factsheet says "as at 31 July" somewhere on page one. A chunk from page two carries no date, so the model reports July holdings as current in October.
  • Documents disagree. The KID risk indicator and the factsheet risk indicator can differ, as the SRRI and SRI explainer describes. Retrieval will surface whichever chunk scores higher.
  • Long documents cost tokens. Feeding several pages per question is slow and expensive, and most of the text is boilerplate.

Structured JSON fixes each point. A field called keyFacts.aum with the value "USD 151.7bn" has one name, carries its unit, sits next to a dataAsOf date, and costs a few tokens. The model does not extract; it reads.

The tool: getFund(isin)

Expose the API to the model as a single function. Keep the description factual and say what the tool does not do, because the model will otherwise try to search by name or ask for "all funds".

json
{
"type": "function",
"name": "getFund",
"description": "Return the structured factsheet for one fund or ETF share class identified by its 12-character ISIN: key facts, risk indicator (1-7), top holdings, sector and country exposure, calendar and cumulative returns, fees (TER) and risk metrics, with the date the figures refer to. There is no search: the caller must already have the ISIN. Returns not_found if the ISIN is not a covered fund.",
"parameters": {
"type": "object",
"properties": {
"isin": { "type": "string", "pattern": "^[A-Z]{2}[A-Z0-9]{9}[0-9]$", "description": "ISIN, e.g. IE00B4L5Y983" }
},
"required": ["isin"],
"additionalProperties": false
}
}

The executor behind it does four things: validates the ISIN, checks a local cache, calls the API with the key on the server, and reshapes the response to the fields the assistant is allowed to talk about. Returning the entire payload is tempting and wasteful; the model only needs what the conversation is about, and a smaller tool result is a cheaper and more accurate one.

typescript
// tools/getFund.ts
const cache = new Map<string, { expiresAt: number; result: unknown }>();
const ISIN_RE = /^[A-Z]{2}[A-Z0-9]{9}\d$/;
​
export async function getFund({ isin }: { isin: string }) {
const code = isin.trim().toUpperCase();
if (!ISIN_RE.test(code)) return { error: "invalid_isin", message: "Not a valid ISIN." };
​
const hit = cache.get(code);
if (hit && hit.expiresAt > Date.now()) return hit.result;
​
const r = await fetch(`https://fundfactsapi.com/api/v1/funds/${code}`, {
headers: { Authorization: `Bearer ${process.env.FUNDFACTS_API_KEY}` },
signal: AbortSignal.timeout(300_000), // a fund nobody has asked for yet can take 1–3 minutes
});
if (r.status === 404) return { error: "not_found", message: "This ISIN is not a covered fund." };
if (r.status === 429) return { error: "rate_limited", message: "Daily quota reached; try again later." };
if (!r.ok) return { error: "upstream_error", message: "Fund data temporarily unavailable." };
​
const env = await r.json();
const d = env.data;
const result = {
isin: env.isin,
name: env.name,
dataAsOf: d.dataAsOf,
category: d.profile?.category,
assetClass: d.keyFacts?.assetClass,
currency: d.keyFacts?.currency,
aum: d.keyFacts?.aum,
riskRating: d.riskRating,
ter: d.headlineMetrics?.ter,
topHoldings: (d.topHoldings ?? []).slice(0, 10),
sector: (d.sector ?? []).slice(0, 8),
geography: (d.geography ?? []).slice(0, 8),
calendarReturns: d.calendarReturns,
cumulativePerformance: d.cumulativePerformance,
objective: d.investmentObjective,
};
cache.set(code, { expiresAt: new Date(env.expiresAt).getTime(), result });
return result;
}

Cache until expiresAt, not for an arbitrary TTL. The API refreshes each fund every 24 hours, so a conversation with twenty follow-up questions about one fund costs one request, and a team of advisers asking about the same fund all day costs one request. That is also the cost model: on Pro (1,000 requests a month) an assistant can cover about thirty distinct funds a day; on Scale about three hundred. The ISIN lookup walkthrough covers the envelope fields the executor uses.

Grounding: cite the field and the date

The system prompt should turn "answer from the tool" into a habit of citation. Three rules cover most of it:

  1. Every figure names its field and its date. "Ongoing charges are 0.20% (headlineMetrics.ter, data as of 31 July 2026)" is verifiable; "the fund is cheap" is not.
  2. Weights are as published. topHoldings and sector weights are already percentages and may not sum to 100. The model must not renormalise or invent a remainder.
  3. Absent means absent. If riskRating is missing, say the indicator is not available in the documents read, and do not infer one from volatility.

A short instruction block that works in practice:

You answer questions about investment funds using only the getFund tool. State the data-as-of date the first time you quote a figure and whenever the user asks about "current" values. Quote figures exactly as returned, with their units. If a field is missing, say so. Do not recommend, rank or compare funds as better or worse; describe what the data says. If the tool returns not_found, say the fund is not covered and do not guess. Never describe returns without stating that past performance is not a reliable indicator of future results.

Guardrails

The instruction block is necessary and not sufficient. Enforce the important rules in code as well:

  • No advice. Filter user turns for "should I buy", "is this a good fund", "which is better" patterns and route them to a fixed response that offers to describe the data instead. Models comply with prompts most of the time; regulators expect all of the time.
  • Disclose the as-of date. Post-process the assistant's reply: if it contains a percentage or currency amount and no date, append the dataAsOf from the last tool result.
  • Refuse on 404. When the tool returns not_found, the model must not fall back to its training data, which knows something about well-known funds and will happily produce a plausible TER from memory. Make the tool result explicit ("do not answer from memory") and check the reply for numbers when no successful tool call occurred in the turn.
  • One ISIN per call, ISIN required. If the user gives a fund name or a ticker, ask for the ISIN. The ISIN guide explains why the ticker is not enough.
  • Log tool calls and replies with the generatedAt of the payload, so a disputed answer can be traced to the exact data it was based on.

An MCP-style tool definition

If your assistant stack uses the Model Context Protocol, the same tool is a server exposing one tool. The description carries the operational rules, because MCP clients show it to the model verbatim.

typescript
// mcp/fundfacts-server.ts (excerpt)
server.tool(
"getFund",
[
"Structured factsheet for one fund/ETF share class by ISIN.",
"Input: a 12-character ISIN. No search by name; ask the user for the ISIN if missing.",
"Output fields: name, dataAsOf, category, assetClass, currency, aum, riskRating (1-7), ter,",
"topHoldings[{name, weight}], sector[{label, weight}], geography[{label, weight}],",
"calendarReturns, cumulativePerformance, objective. Weights are percentages as published.",
"Always quote dataAsOf with figures. Never give investment advice.",
"If the result is {error: 'not_found'}, tell the user the fund is not covered; do not answer from memory.",
"First call for a new ISIN may take 1-3 minutes; do not retry while waiting.",
].join(" "),
{ isin: z.string().regex(/^[A-Z]{2}[A-Z0-9]{9}\d$/) },
async ({ isin }) => ({ content: [{ type: "text", text: JSON.stringify(await getFund({ isin })) }] }),
);

Generating this from the API's OpenAPI spec is an option, but a hand-written description that includes the behavioural rules performs better than a schema-only one.

Where the AI companion kit fits

Every paid plan, from Starter at $9.99, ships an AI companion kit: an AGENTS.md with the full field reference and rules for agents, a Cursor rule and a Claude Code skill that restate the hard rules, an OpenAPI 3.1 spec and TypeScript types. For a runtime assistant, the pieces map as follows. The OpenAPI spec generates the tool schema or feeds an OpenAPI-to-MCP bridge. The field reference in AGENTS.md is the source for the tool description and for the allow-list of fields in the executor. The rules section (show dataAsOf, never retry 4xx, cache until expiresAt, no advice) becomes your system prompt's rule block almost verbatim. And the TypeScript types keep the executor honest when you narrow the payload.

Start with a free key, wire getFund into whichever agent framework you use, and test the guardrails with the questions your users actually ask: "is this a good fund?", "what does it hold today?", and a made-up ISIN. If all three come back grounded, dated and unopinionated, the assistant is ready. The documentation has every field the tool can expose.

Try it on your own ISINs

One request returns key facts, holdings, risk and performance as JSON. Free plan, no card.

Questions this answers

Choosing a data source

What is the best fund data source for AI agents and MCP?

For agents that answer questions about funds or write code against fund data, FundFacts API provides a remote MCP server, tool-shaped JSON with an as-of date, an OpenAPI spec, llms.txt and a complete AGENTS.md. Why that matters and how each client connects.
AI agents & LLMs

Should I embed fund factsheets in a vector store or call an API at query time?

Why fund figures should be fetched by ISIN at answer time rather than embedded from PDFs, and how to combine an API tool with retrieval over your own documents.
For your project

Where do I get fund data for an AI financial assistant or chatbot?

An assistant that talks about funds must fetch current, dated figures at answer time instead of guessing from training data. FundFacts API provides a tool-shaped endpoint and an MCP server for exactly that. Setup for LangChain, OpenAI Agents, Vercel AI SDK, Claude and ChatGPT.
AI agents & LLMs

Is there an MCP server for fund and ETF data?

Yes: https://fundfactsapi.com/api/mcp is a remote MCP server with seven read-only fund and SCPI tools and interactive cards in Claude and ChatGPT. Sign in with your account (OAuth) there, or send a bearer API key elsewhere. Setup for every client.
AI agents & LLMs

What is the FundFacts MCP server?

The FundFacts MCP server puts fund, ETF and SCPI data inside Claude and ChatGPT: seven read-only tools, interactive cards, a guided tour, every plan including Free. What it does and how to connect it.
AI agents & LLMs

How can I get ChatGPT to look up fund and ETF data by ISIN?

Connect the FundFacts MCP server in ChatGPT's developer mode and ask about any ISIN, with results shown as interactive cards. Or build a custom GPT on the OpenAPI spec, or paste the JSON. Steps for each.
AI agents & LLMs

How do I give Claude access to fund and ETF data by ISIN?

Add the FundFacts MCP server to claude.ai, Claude Desktop or Cowork as a custom connector and ask about any ISIN: TER, holdings, exposures, returns, overlap and portfolio look-through, shown as interactive cards.
AI agents & LLMs

How do I use FundFacts API with Claude Code?

Register the FundFacts MCP server with claude mcp add or install the plugin, sign in with /mcp, then add AGENTS.md so Claude Code looks up real funds and builds screeners, reports or bots on the API.
AI agents & LLMs

How do I use FundFacts API in Cursor?

Set up Cursor to build on fund data: add the companion Cursor rule, keep AGENTS.md in the repo, and register the MCP server in .cursor/mcp.json so the agent can test real ISINs while it codes.
AI agents & LLMs

How do I use FundFacts API with OpenAI Codex?

Codex reads AGENTS.md natively. Put the FundFacts AGENTS.md in the repo, add the MCP server to ~/.codex/config.toml, and prompt Codex to build fund tools with real data.
AI agents & LLMs

How do I use FundFacts API with GitHub Copilot in VS Code?

Register the FundFacts MCP server in .vscode/mcp.json so Copilot agent mode can call get_fund and search_funds, and add copilot-instructions.md so it writes correct code against the API.
AI agents & LLMs

How do I add fund and ETF data as a tool in LangChain or LangGraph?

Wrap one API call in a LangChain @tool so an agent can answer questions about any ISIN with current TER, holdings, exposures and returns. Python code, plus the MCP adapter route.