Grounding an AI Coding Assistant in a Codebase It Can’t See

How we built iid-mcp: an MCP server that puts a private SailPoint IdentityIQ codebase in front of Claude Code, Copilot, and Claude Desktop, so they stop guessing.

At Instrumental Identity we write a lot of SailPoint IdentityIQ code. Rules, workflows, tasks, plugins, and XML configs that an importer validates against a DTD with more than a thousand elements. So when the capable LLM coding assistants arrived, we did the obvious thing and asked one to write a rule.

What came back looked completely plausible and was wrong in ways that took a while to see. It called a method that doesn’t exist on SailPointContext. It invented a helper in com.identityworksllc.iiq.common that nobody ever wrote. It set a type=attribute on a <Rule> that the importer rejects on sight. The code compiled in the model’s head and fell over in ours.

The reason is simple. The model has read a lot of public Java, but it has never seen the libraries we actually build against: Instrumental Identity’s iiqcommon, the iiq-common-library, our in-house plugin set, or SailPoint’s own shipped API surface. So it does what models do when they don’t know. It produces a confident average of everything it has seen and presents it as fact. For IIQ work that average is worse than useless, because it’s wrong in ways that take someone who already knows the platform to catch.

The fix isn’t a better prompt. The fix is to stop letting the model lean on memory and hand it the real source instead. That’s what iid-mcp does.

What it is

iid-mcp is a Model Context Protocol server. MCP is the standard that lets an AI client (Claude Code, Claude Desktop, GitHub Copilot in agent mode) call tools you define, over a well-specified protocol, with no bespoke glue per client. You stand up one server, expose a set of tools, and every MCP-aware assistant can use them.

iid-mcp exposes seven tools. Two are live: they reach into Instrumental Identity’s GitLab and search or fetch real library source on demand. The other five are reference tools that serve curated, queryable knowledge built straight from SailPoint’s own shipped artifacts: the database DDL, the Javadoc, the example configs, and the XML DTD.

ToolWhat it does
search_iiq(query, max_results)Source search across every in-scope project, in parallel
fetch_iiq_file(project, path, ref)Fetch one file’s full contents
get_iiq_patterns()Return the hand-curated patterns reference
get_iiq_schema(table?)IIQ database schema: table inventory, or full column detail for one table
get_iiq_api(name?)IIQ + Instrumental Identity Javadoc: package index, a package’s classes, or one class
get_iiq_examples(key?)SailPoint’s bundled config examples by rule type, workflow, form
get_iiq_dtd(element?)The IIQ XML DTD: element index, or one element’s children and attributes

The whole thing is about 1,200 lines of Python plus the generated reference data. It’s small on purpose. The hard part was never the code. It was deciding what to feed the model and how to keep that data honest.

The shape of the problem

There are two different jobs hiding inside “help me write IIQ code,” and they want different tools.

The first is finding real usage. When we’re writing against a helper, we want to see how it’s actually called in code that ships, not how the model imagines it’s called. That’s a search problem against private repos, and it has to be live, because the libraries change on the order of days.

The second is knowing the legal shape of a thing. What columns does spt_identity have on 8.5? What does SailPointContext.getObjects return? Which children is a <Workflow> allowed to have? None of that changes between Tuesday and Thursday. It changes when SailPoint ships a new version. That’s a reference problem, and the right move is to precompute it from the source of truth and serve it fast.

We built both halves into one server because, from the agent’s point of view, they’re the same task: ground this artifact in reality before you generate it. The server’s own instructions even spell out the ordering. Load the patterns, pull the matching example, check the API signature, verify the XML shape against the DTD, and search the real code to confirm. Generate last.

Searching the real source

You configure what the server can see with one environment variable, a comma-separated list of project and group paths. A group path expands to every non-archived project inside it, recursively. The default scope resolves to roughly 38 projects.

search_iiq runs a search against every project in scope at once. We bound the concurrency in two places: how many per-project requests are in flight within a single call, and how many search calls run at once across the whole process. Callers over the limit wait their turn instead of getting rejected.

The most important design decision in the whole project lives here, and we got it wrong the first time. The original search gathered every per-project result and returned the combined hits. Then production logs showed three of four search calls failing outright. The cause was one repo whose search legitimately ran much slower than the rest, slow enough to tip past the timeout under load. That single timeout, propagating up through the code that gathers all the results, took the entire search down with it. The dozens of healthy projects had already returned useful hits, and the caller saw nothing but an exception.

So the rule now is that a per-project failure is data, not a reason to abort. Each project’s search is wrapped to catch its own errors and return them rather than raise. The aggregator splits the outcomes into hits and a structured list of errors. You get the projects that worked plus a note saying which one timed out and why. Partial results stay useful. That same instinct shows up again in the cache and in the transport layer. Infrastructure trouble should degrade the answer, never destroy it.

A returned hit carries everything the agent needs to act on it: project, path, ref, line number, the matched snippet, and a URL that deep-links straight to the line in GitLab. The usual loop is to search, read the snippets, fetch the one or two files that matter, and write code grounded in what came back.

A note on how this evolved. The live search described above was the original design, and it’s the one most of this post documents. We’ve since moved the search backend to a local mirror of the scoped repos that syncs hourly, with the search running against those mirrors instead of hitting the remote live on every call. The reasons are in the lessons at the end. The tool interface and the result shape didn’t change, so everything above still describes what an agent sees.

The reference tools

The five reference tools share one idea. SailPoint already ships the ground truth for most of what an agent needs to know. It just ships it in formats built for a human with a browser, not a model with a tool call. So we wrote build scripts that parse those artifacts into markdown shaped for retrieval, and tools that serve slices of it on demand.

Schema (get_iiq_schema). This parses the bundled database DDL, the create scripts and the upgrade patch scripts for Oracle, SQL Server, MySQL, and PostgreSQL. It applies the patches in order and generates a reference covering IIQ 8.4 and 8.5 with per-database type differences and version diffs. It also flags a real footgun. Oracle and PostgreSQL put function-based UPPER() indexes on string columns, so a query that filters without wrapping the bind parameter in UPPER()silently bypasses the index and table-scans. The columns that need it are marked in the reference, so the agent writes the indexed form the first time instead of discovering the slow form in production.

API (get_iiq_api). Javadoc converted to per-package markdown and merged across three sources: SailPoint IIQ 8.5, iiqcommon, and iiq-common-library. That’s about 940 classes across 93 packages. A class lookup spans all three, so the agent doesn’t need to know which library a helper lives in, and per-class sections get extracted on demand so a 569-class package never returns whole in one response.

Examples (get_iiq_examples). SailPoint ships example configs with IIQ. When the agent is about to write a Correlation rule, it gets SailPoint’s own example for that exact type, so the input and output contract and the idiom come from the vendor rather than from the model’s imagination.

DTD (get_iiq_dtd). Every IIQ artifact is XML, validated on import against sailpoint.dtd, which has 1,108 elements on 8.5 and is regenerated straight from the matching jar. A lookup returns the legal children and attributes of an element before the agent generates one. Checking here heads off the single most common authoring failure, which is XML that looks right and gets rejected at import time.

Patterns (get_iiq_patterns). This is the one tool that isn’t generated. It’s a hand-curated reference of the things that come up constantly when writing against these libraries: logging idioms, task base classes, plugin anatomy, and the namespace gotcha where iiqcommon and iiq-common-library share a package prefix but hold different classes. The curation rules are strict and we hold ourselves to them. Every entry is sourced, verified, concise, and current. Stale entries get removed rather than left to rot, because the whole point is that the agent trusts the file and skips re-verifying it. A stale pattern is worse than no pattern. It turns “the model doesn’t know” into “the model is confidently wrong, and we told it to be.”

State, caching, and ops

The design rule is one sentence. State is externalized and the process is killable. There’s no local database, no session store, no on-disk state the server depends on. Everything lives in GitLab and Redis, which makes the container disposable and means moving from a Proxmox VM today to ECS or Fargate tomorrow is a deploy change rather than a code change.

Caching matters because, when search fans out in parallel, the slowest project sets the floor on total search time. Responses get cached in Redis when it’s configured, and the cache degrades gracefully in every direction. With no Redis it becomes a no-op pass-through. With Redis configured but unreachable mid-call, the error gets swallowed and the code falls back to a live fetch. The server never fails a tool call because the cache is unhappy. An optimization that can take down the thing it’s optimizing is a liability.

Observability is structured logging, one JSON object per meaningful event, written to the host’s systemd journal rather than the default Docker log driver. We redeploy on every push to main, and the journal outlives the container, so a week of search-latency and error history survives a redeploy that would otherwise wipe it. Secrets never reach the logs. Tokens are recorded as a present-or-absent boolean and Redis credentials are redacted.

The production server sits behind a Cloudflare Tunnel and Cloudflare Access. Browsers get gated by M365 Entra SSO, and programmatic clients use per-user service tokens so revocation is per-user rather than all-or-nothing. GitLab CI runs lint, test, build, and deploy on every push to main, backed by a test suite that mocks GitLab and Redis so it’s deterministic and never touches the network.

One design change is worth recording. The server originally spoke MCP over SSE, a long-lived stream that went stale through Cloudflare on container restart or after an idle stretch. The connection looked alive and was dead. Streamable HTTP fixes this by construction, using short-lived per-call requests against a single endpoint so there’s nothing held open to go stale. We switched production over entirely and left SSE in the codebase behind a one-line switch.

What this taught us about building with an AI assistant

A note on where we’re coming from. Our day job is identity and access management. SailPoint rules in BeanShell, IIQ XML, and the glue code and infrastructure that hold an identity practice together. We know our way around a deployment pipeline. What we don’t do every day is write async Python application services from scratch, with their concurrency models and the internals of an SDK we’d never used. iid-mcp is exactly that kind of service, and a lot of it got built with an AI coding assistant handling the parts we’d otherwise have spent weeks learning. That’s the honest story of this project. The assistant let us operate a couple of steps outside our usual lane, and it never once removed the need to understand what we were looking at. Most of what follows is some version of that same tension.

The most expensive mistakes the assistant made were not wrong lines of code. They were moments where it reasoned with total confidence about the wrong thing. Asked to confirm a memory fix had worked, it pulled up the running server’s memory and pronounced it healthy. The server was the wrong process. The code we’d changed runs in a separate, short-lived sync job the server never touches. Another time, asked to confirm there was enough disk headroom, it read df output from the GitLab box when the storage in question lives on a completely different machine. A third time, a set of log queries came back empty and looked like proof the feature was broken, when the queries were simply aimed at the wrong systemd unit. Every individual fact in those answers was correct. Every conclusion was useless, because the assistant had the wrong picture of which box and which process it was even talking about. This is the failure someone in our position is least able to catch, because catching it depends on knowing how the system is wired, and that knowledge is exactly what you’re leaning on the assistant to provide. What we settled on is unglamorous. Make it name the specific target every time, the actual container or host or unit, and verify against that rather than against a plausible-looking neighbor.

A related thing we had to learn the hard way is that “it passed” and “it’s actually fixed” are two different claims, and the assistant will quietly merge them if you let it. The gaps turned up everywhere once we knew to look for them. Tests passed on a laptop and failed in CI because the CI image had no git, then later no ripgrep, then once because the assistant was running against an old system Python when the project needed a newer one. A one-line config patch reported success while changing nothing, because the pattern it was matching against didn’t appear in the file. A command “succeeded” against a stale cached container image because nothing had pulled the new one. A pipeline ran green without deploying our change at all, because the file we’d edited fell outside the path allowlist that triggers a rebuild. None of that is exotic. It’s the ordinary texture of real systems. The assistant is genuinely good at producing the next step and genuinely poor, left to its own devices, at noticing the step accomplished nothing. We stopped trusting exit codes and green checkmarks as proof of anything and started checking the end state directly. Grep the file for the flag. Read the running config. Look at which image is actually live.

For all of that, the single most useful thing the assistant did on this whole project was give up on its own idea when the evidence went against it. Our search tool was timing out on the largest repo and silently returning fewer results than it should. The assistant’s first instinct was to add a retry. But before writing any code it went and pulled a month of production logs, and the logs showed every timeout pinned at exactly the same ceiling rather than scattered the way flaky failures scatter. So it dropped the retry idea on its own, because retrying something that’s reliably slow just spends the time over again and fails the same way. That habit of measuring before building is what set up the part that mattered. When we floated the idea of mirroring the repos locally and searching them on the box instead of hitting the remote live, the assistant tested it against the same evidence and concluded it didn’t just paper over the timeout, it removed the entire category of problem. The takeaway we’d hand anyone like us is that the assistant is at its best as a thinking partner you force to look at real data, and at its worst when you let it reason from whatever it already assumes. Make it pull the logs.

The guardrails that stopped the assistant turned out to be a feature rather than a nuisance, and we’d keep them strict. More than once it tried to push straight to main, which auto-deploys to production, and got blocked because we’d approved a commit but never approved a deploy. Both times that was the right call. When you’re handing work to a tool in a stack you don’t have muscle memory for, the line you most want enforced is the one in front of the irreversible things. Deploys. Deletes. Permission changes. Keeping those behind a deliberate human yes is what makes it safe to let the assistant move fast on everything else.

The honest summary, from people who know their infrastructure but don’t write services like this from scratch every day, is that the assistant raised our ceiling enormously. We shipped a real, tested, deployed Python service well outside our usual lane, and most of it was sound on the first pass. What it never did was raise the floor on judgment. It will reason with total confidence from a wrong model of your system, report a pass that only holds somewhere that isn’t production, and declare a command a success when it did nothing at all. The less the work looks like your day job, the harder all of that is to spot. Grounding the model in real source is the entire point of this tool, and it turns out to be the same discipline you have to apply to the assistant itself. Don’t let it run on what it remembers or assumes. Make it look at the actual thing, every time.

What’s next

IIQ is the first product, not the only one. The package layout already reserves space for sibling toolsets, SailPoint ISC first and later Evolveum Midpoint and Fischer Identity, that plug into the same scope, cache, transport, and observability machinery. Each new product brings its own paths and its own reference data and reuses the rest.

For now it does the job it was built for. Claude Code sessions write IIQ code against real library source instead of a plausible hallucination of it, the cost of a wrong line shows up while you’re writing rather than at import time, and nobody has to be the person who catches the method that doesn’t exist. That last part was the whole point.

Scroll to Top