Start with the failure, because it is quiet and it is already happening. A platform team wires an assistant into an internal API, ships it, and it works. Six weeks later a second assistant arrives bundled with the productivity suite licence, and a business unit has independently bought a third for its own workflow. All three need the same API. All three get their own integration, written by a different team, with its own credential handling, its own idea of which operations are dangerous, and its own logs in its own format. Nothing has broken. There is simply nowhere left that can answer the question an auditor will eventually ask, which is who was permitted to do what, and how you know.
Most organisations are about to discover that they have standardised on three assistants at once: one for engineering, one that arrived with the productivity suite, and one a business unit bought. The naive response is three integrations. The correct response is one governed toolset that all three consume.
The system described here is a Humint Labs build. We designed it, we run it, and it sits in front of an enterprise conversational AI platform's management surface: projects, conversational flows, intents, endpoints, knowledge stores, connections, snapshots, users. Anything that can reach that surface can create and change systems that answer real customers. Nothing in the surface itself decides whether it should. This piece is about the layer that does, and the design arguments behind it. The companion exhibit sets the system out in summary, including its measured behaviour.
Three assistants, four consumers
In practice the count was four, not three, and the fourth is the one that breaks the comfortable assumption underneath the other three.
Our consumers are a coding assistant used by engineers, a productivity suite assistant used by operations and business staff, a bespoke internal agent built for one workflow, and the platform's own agent runtime calling tools inside a live conversation. Four consumers, four personas, four different kinds of work. Two of them are independent third-party assistant vendors with their own client implementations. That plurality is the whole argument. It is not a hypothetical about future assistants. It is the shape of the estate now.
The fourth consumer matters most for design. An engineer at a keyboard can be shown a confirmation prompt. The platform's own runtime is calling tools mid-conversation, with a customer waiting, and there is no human on that turn at all. Any governance design that quietly assumes a person is present to approve something is not a governance design. It is an interface convention that happens to hold for three consumers out of four.
If you integrate per consumer, you do not get four integrations. You get four copies of every decision that matters. Four opinions on which operations are consequential. Four credential paths to review. Four audit formats to join together at the moment you least want to be writing joins. They agree on the day they ship and they drift from the week after, because each is maintained by the team that owns its assistant rather than the team that owns the risk.
The number that matters is not how many assistants you have. It is how many places the authorisation decision lives. One toolset does not make that number small as a matter of taste. It makes it one by construction.
Where the boundary belongs
So the boundary is a single server that speaks the Model Context Protocol, sits in front of the management surface, and holds everything the consumers are not trusted to hold: identity resolution, entitlement, tenancy, credentials, tiering and audit. The consumers hold none of it. They get a list of tools and a transport.
The layers below run from the assistant to the platform surface, with everything a consumer is not trusted to hold sitting in the governance layer, once.
- Consumers are the four clients calling the toolset, two of them third-party assistant vendors.
- Protocol edge applies per-client adapters for name limits, schema strictness and list changes.
- Governance holds identity, toolset composition, per-call policy and confirmation, the layer that carries the risk once.
- Tool layer exposes task-shaped tools, each with one category and one tier.
- Tenancy binds each session to one tenant, with credentials resolved per request.
- Audit is written on the request path and traced to a column store.
It is worth being unromantic about the protocol. MCP is a discovery and serialisation convention, and a good one, but it is not the governance. It gives you a standard way to advertise tools, describe their inputs and carry a call. Everything that makes the arrangement safe is yours to build and would have been yours to build over any transport. What the protocol buys you is that the governance you build is consumed identically by clients you did not write, which is exactly the property that lets one toolset serve four consumers instead of one.
The failure this design is aimed at is not a breach. It is divergence. Two integrations, both correct on the day they were written, that disagree six months later about whether restoring a snapshot over live configuration requires an approval.
Tool design is API design
The obvious way to build the server is to wrap each endpoint as a tool and stop. It is fast, it is complete, and it produces a toolset that no agent uses well and no security team signs off on.
The reason is that tool design is API design for a consumer with an unusual set of properties. It cannot read your documentation. It cannot be trained on your local conventions. It will not raise a ticket when a name is ambiguous. It will make a confident guess, call the neighbouring tool, and return something that reads as correct. Every hard thing in this build follows from designing for that consumer rather than for a developer with a browser tab open.
Granularity is where this bites first, and it bites in both directions. Tools that map one-to-one onto endpoints are too fine. What an operator describes as a single outcome, standing up a working conversational flow with its intents attached and an endpoint wired to it, is an ordered sequence of calls underneath. Expose the endpoints and you have pushed that ordering into the model, where it is inferred rather than enforced. Twenty chained calls is twenty chances to drop an argument, twenty round trips of latency, twenty entries of context spent on plumbing, and a set of partial-failure states nobody designed: created the flow, failed to attach the endpoint, no transaction to roll back.
Go too coarse and you get the opposite failure, which is harder to see because it looks like a well-behaved agent. A tool that does one large thing cannot do the slightly different thing that was actually asked for, so the model either declines or reaches for whatever is adjacent and does something nobody wanted.
The rule we settled on is to shape each tool around the smallest unit of work an operator would name out loud. If someone would say "create the flow with these intents and attach the endpoint" as one instruction, that is one tool, and the ordering lives inside it where it can be enforced and rolled back. If they would say it as two instructions, it is two tools. This is not a precise rule. It is a usable one, and it moves the argument from taste to something you can settle by listening to how the work is actually described.
Two properties then get declared rather than documented: the functional category a tool belongs to, and its authorisation tier. Declared at registration means a tool cannot ship without them. Documented means a tool ships without them and someone notices in review, or does not.
// Generic registration shape. Category and tier are structural fields,
// not conventions, so an untiered or uncategorised tool cannot reach the
// registry at all.
export type Tier = "read" | "configure" | "consequential";
export interface ToolDefinition<Input, Output> {
/** One canonical verb, one noun per resource. No synonyms admitted. */
name: string;
/** Exactly one functional category. Never a list. */
category: CategoryId;
tier: Tier;
/** What the tool does. */
summary: string;
/** What it does not do. Stated here because near-miss selection is the
failure mode, and negative space is what prevents it. */
notFor: string;
input: JsonSchema<Input>;
handler: (input: Input, ctx: CallContext) => Promise<Output>;
}
export function register<I, O>(def: ToolDefinition<I, O>): void {
assertCanonicalName(def.name); // rejects synonyms and unknown verbs
assertSingleCategory(def.category);
assertTierDeclared(def.tier);
registry.add(def);
}Descriptions carry one more obligation that conventional API docs do not. They have to state what the tool does not do, in the description itself, because the negative space is what stops a near-miss selection. A description that only says what a tool does competes with every neighbouring description that also says what it does.
Discoverability at scale
This is the part almost nobody writes about, and at scale it is the part that decides whether the toolset works.
At a few dozen tools, naming sorts itself out. At hundreds of tools across dozens of functional categories, the model cannot see them all, and the constraint arrives well before the context window does. Selection accuracy degrades first: every new tool competes with everything already registered, and the failure is silent. There is no error and no refusal. There is a confident call to a neighbouring tool whose description also matched.
Three mechanisms carry the load, and none of them is a better prompt.
Category structure comes first. Every tool belongs to exactly one functional category, never a list, and categories are how the surface is described to the model rather than an afterthought in the docs. Alongside that sits naming discipline severe enough to feel unreasonable: one canonical verb set, one noun per resource, and no synonyms admitted. If the surface calls something a connection, nothing anywhere calls it a link.
Progressive disclosure comes second. A session does not open with everything. It opens with the category index, a search tool, and the tools for whatever the session is actually doing, and it expands on demand. The model asks what exists before it asks for it.
Search over the toolset comes third, and it is the mechanism that makes the other two usable. Tool selection at this scale is a retrieval problem wearing a different hat, so it gets treated like one: lexical matching over names and descriptions, semantic matching over the descriptions, results returned with category and tier attached so the model can see the shape of what it found before it commits.
// The three tools every session starts with, whatever else it is granted.
// Everything beyond this set is disclosed on request.
const BOOTSTRAP = ["list_categories", "search_tools", "activate_category"];
// Search returns enough metadata for the model to judge a candidate before
// it commits: which category it belongs to, what tier it carries, and what
// it is explicitly not for.
interface ToolSearchResult {
name: string;
category: CategoryId;
tier: Tier;
summary: string;
notFor: string;
/** Fused lexical and semantic rank. Descriptions are the retrieval
corpus, which is why they are written for retrieval. */
score: number;
}
async function searchTools(
query: string,
ctx: CallContext,
limit = 8,
): Promise<ToolSearchResult[]> {
// Search only ever ranges over what this caller is entitled to see, so
// discovery can never surface a tool the session could not call anyway.
const visible = registry.visibleTo(ctx.identity);
return fuse(lexical(visible, query), semantic(visible, query)).slice(0, limit);
}None of that is verifiable by reading it, which is why routing gets treated as a regression test rather than a design claim. A held-out set of real operator requests is replayed, and the tool the model reaches for is compared against the tool the request meant. Silent failure has to be made loud somewhere, and this is the somewhere.
Authorisation before code
Part of the answer here was not code at all, and it came first.
Before any tool was written, the surface was sorted into three authorisation tiers, read, configure and consequential, and the list of consequential operations was agreed with the people who would carry the risk of it being wrong. Which operations are consequential is a policy decision. Deleting a flow, restoring a snapshot over live configuration and removing a user's access are not consequential because of anything in their HTTP methods. They are consequential because of what it costs when they happen at the wrong moment. Writing the server first would have encoded one engineer's assumption about that faster, and made it harder to change.
Enforcement is layered rather than singular, and the layers do different jobs.
Composition. The toolset a session sees is assembled from the caller's own identity, so a tool the caller is not entitled to is absent rather than present and refusing. That is a deliberate choice: a visible tool the model cannot use is a retry loop, and a retry loop against a consequential operation is a bad thing to have built.
Per-call policy evaluation. Every invocation is evaluated again at the moment it is made, against the caller, the tenant, the tier and the arguments. Composition alone is not enough, because a session can outlive a role change, and the interesting failures happen in that window.
Human confirmation on consequential calls. The call is prepared and returned for a decision rather than executed, and it runs only on the approving turn, with the approver recorded. This is the layer the platform's own runtime does not get, because there is no human on that turn. It is simply never granted consequential tools, which is a cleaner answer than inventing an approval flow with nobody in it.
// Three enforcement mechanisms, applied in order. They are layers, not
// alternatives: composition decides what exists, policy decides what runs,
// confirmation decides what runs now.
export async function authorise(
def: ToolDefinition<unknown, unknown>,
args: unknown,
ctx: CallContext,
): Promise<Decision> {
// 1. Composition already happened at session establishment: tools this
// identity cannot use were never registered into the session, so an
// ungranted tool is absent rather than refused.
// 2. Per-call policy. Re-evaluated every invocation because a long-lived
// session can outlive a directory role change.
const verdict = await policy.evaluate({
identity: ctx.identity, // resolved from the directory, per request
tenant: ctx.tenant, // bound to the session, never an argument
tool: def.name,
tier: def.tier,
args: redactForPolicy(args),
});
if (!verdict.allowed) return { kind: "denied", reason: verdict.reason };
// 3. Human confirmation on consequential calls. The call is prepared and
// handed back rather than executed. Consumers with no human on the
// turn are never granted consequential tools in the first place.
if (def.tier === "consequential" && !ctx.confirmation) {
return { kind: "awaiting_confirmation", prepared: describeEffect(def, args) };
}
return { kind: "allowed" };
}Identity is the spine underneath all three. The server does full single sign-on: authentication against Microsoft Entra ID both directly and federated through Amazon Cognito, so a consumer arriving through the AWS-side identity path and a consumer arriving directly resolve to the same person with the same entitlements. Roles and permissions defined in the directory carry through to what the toolset exposes and what it will execute. Nothing about entitlement is redeclared in the server's own configuration, which matters for one specific reason: removing someone's role in the directory removes their reach, without a deployment and without anyone remembering that a second list exists.
Tenancy and credentials
One operator legitimately holds credentials for several organisations. One server process serves several operators. That combination is why tenancy had to be an isolation problem before it was a convenience one.
The rule is that a session is bound to exactly one tenant, resolved from the caller's identity at session establishment. Tenant is never an ordinary tool argument. If it were, addressing another organisation would be a decision the model is capable of expressing, and anything the model can express it will eventually express wrongly, usually while doing something else entirely. Switching tenant is a new session, not a parameter.
Credentials follow the same logic. They are resolved per request from the caller's identity against a secret store, they are never placed in a tool argument, and they never enter the model's context. What the model sees is that a tool worked or did not.
The audit record
An audit trail is not a log file. The test is not whether the information was recorded. It is whether you can reconstruct, months later, what an agent did and why, in a form somebody reviewing it will accept.
That means each record has to carry the human identity behind the call rather than the service principal that made it, the tenant, the tool, its tier, the arguments with secrets and personal information redacted, the policy decision including refusals and who approved anything that needed approving, the outcome, and a trace identifier that ties the call back to the request that prompted it.
// One row per attempted call, written on the request path. Refused,
// timed-out and awaiting-confirmation calls produce a row exactly like a
// successful one, because those are the rows a review asks about.
export interface AuditRecord {
traceId: string; // joins operator turn, selection, policy, call
spanId: string;
occurredAt: string; // ISO 8601, UTC
// Who, not what. The human behind the call, never the service principal
// the call happened to travel on.
actor: { subject: string; directoryId: string; via: "direct" | "federated" };
tenant: string; // bound to the session, not supplied by the model
tool: string;
category: CategoryId;
tier: Tier;
arguments: Redacted<Record<string, unknown>>;
decision:
| { kind: "allowed" }
| { kind: "denied"; reason: string; policy: string }
| { kind: "awaiting_confirmation" }
| { kind: "confirmed"; approver: string; approvedAt: string };
outcome: { status: "ok" | "error" | "timeout"; code?: string; durationMs: number };
// What the model could see when it chose. Without this the record says
// what happened but not why that tool and not its neighbour.
selection: { visibleTools: number; fromSearch: boolean; query?: string };
}It is written on the request path, not asynchronously afterwards. A record written after the fact is a record of the calls that got far enough to succeed, which is precisely the wrong sample. Refusals, timeouts and policy denials are the interesting rows.
The genuinely hard part is the why, not the what. The call itself is one row. Intent is a join: the operator turn that prompted it, the tools that were visible to the model at that moment, the policy decision that let it through, and the result that came back. Reconstructing that after the fact from platform activity logs is guesswork with good manners. Capturing it at the time as one trace is a design decision you have to make before you need it.
Observability and audit are wired into Langfuse, which stores to ClickHouse by default, and both halves of that sentence earn their place. Tracing gives you the span structure the reconstruction needs: the model turn, the tool selection, the policy decision and the platform call, joined under one trace. A column store gives you the query pattern audit actually has, which is analytical rather than transactional. Every consequential call in one tenant last quarter, with its approver, is a question you want answered in seconds by people who are not going to write a log-aggregation query to get it.
What differs between clients
Serving several clients from one registration is where the marketing and the specification disagree with each other, and both of them are wrong in the same direction.
Less differs than the marketing suggests. The protocol carries, the tools resolve, and a toolset built once genuinely is consumed by clients you did not write. More differs than the specification implies. Clients disagree on tool-name length limits and permitted characters. They disagree on schema strictness, and some reject constructs the specification allows, which quietly makes the union type in your input schema a portability problem. They differ in how aggressively they truncate descriptions, which matters when your descriptions are carrying the negative space that prevents near-miss selection. They differ on parallel tool calls, on session lifetime, and on how they handle a tool list that changes mid-session, which is a direct constraint on progressive disclosure.
The answer is to absorb all of it at the edge. One registration, one set of tiers, one audit format, and per-client adapters that deterministically shorten names and downgrade schemas on the way out. The moment you fork the toolset per client you have built the thing this whole design exists to prevent, and you have built it somewhere you will not look.
If you are building one
A short list, in the order the decisions actually have to be made.
- Settle the consequential list before you write a tool and settle it with the people who carry the risk rather than the people writing the code. It is the one decision that gets more expensive with every week it waits.
- Shape tools around outcomes an operator would name out loud, and put the ordering inside the tool where it can be enforced.
- Treat naming as a public API because that is what it is. One verb set, one noun per resource, no synonyms, and descriptions that say what a tool is not for.
- Make the toolset searchable before it is large. Retrofitting discovery onto a flat surface of several hundred tools is a rewrite of every description you have.
- Bind tenancy to the session and resolve credentials per request. Never let either become an argument the model can reach.
- Write audit on the request path with a trace identifier that survives the join from operator turn to platform call.
The reusable part of all this is not the tool surface. That was specific to one platform and it took the time it took. The reusable part is everything around it: the tiering conversation, the naming discipline, the discovery layer, the identity boundary, the audit shape. That pattern has been worth more to us than the server it was built for, because it is what makes the next one an exercise in scaffolding rather than a new argument about who is allowed to do what.
If you have three assistants and are about to authorise three integrations, the decision in front of you is not a technical one yet. It is where the authorisation lives. Answer that once and the integrations become a transport problem. Answer it three times and you will answer it a fourth time, under pressure, after something has already happened.