Blog
A small, discoverable tool surface
I like interfaces that show what is possible without confusing discovery with permission.
Make the map legible
A long list of near-identical tools makes it hard to know which operation fits a task. I prefer a small vocabulary with clear descriptions of inputs, effects, and limits. Discovery can then answer where to start, while the operation itself remains precise. The tradeoff is that a compact surface needs careful naming: if everything hides behind a generic command, users lose the clues needed to anticipate consequences.
I describe each operation by its effect rather than its implementation: what it reads, what it changes, and what it leaves alone. Writing the description first often shows that two tools are really one operation with a parameter, which is a smaller surface than two overlapping entries.
I also treat every added tool as a cost. Each entry asks a reader to compare it with its neighbors, and near-duplicates invite the wrong choice. When a new capability is close to an existing one, I extend the existing operation with a parameter, provided the parameter names a difference in intent.
Separate seeing from doing
Showing that a tool exists is useful even when a particular caller cannot invoke it. I do not treat visibility as a permission check, nor do I rely on hiding an entry to enforce a boundary. Authority belongs at the point where the action is attempted, with the relevant actor and target in view. A discoverable description should say what an operation does, not imply that every reader is entitled to use it.
I keep the discovery listing as data. It may say that an operation exists and what it does, and it may hint that a caller probably has access, but it must not be the place where access is granted. The decision belongs at the invocation, where actor, action, and target are all present. A listing can then be cached or shown to anyone without weakening the boundary.
The vocabulary of outcomes matters as much as the mechanism. I want at least three results: unavailable, meaning the operation is not offered here; denied, meaning it exists but this request lacks a grant; and invalid, meaning the request is malformed. Each points to a different remedy, and merging them makes the caller guess.
interface Grant {
actor: string;
tool: string;
targets: readonly string[];
}
interface Call {
tool: string;
actor: string;
target: string;
}
export type Outcome =
| { status: "allowed" }
| { status: "denied"; missing: "actor" | "target" }
| { status: "unavailable"; reason: string };
// Discovery says what exists; this decision says who may call it.
export function decide(
known: readonly string[],
call: Call,
grants: readonly Grant[],
): Outcome {
if (!known.includes(call.tool)) {
return { status: "unavailable", reason: "unknown tool" };
}
const grant = grants.find(
(g) => g.actor === call.actor && g.tool === call.tool,
);
if (grant === undefined) {
return { status: "denied", missing: "actor" };
}
if (!grant.targets.includes(call.target)) {
return { status: "denied", missing: "target" };
}
return { status: "allowed" };
}Check the actual edge
I would try locating a permitted action, carrying it out, then attempting the same action without its permission. I would also check that an unavailable operation is reported as unavailable rather than denied: those are different obstacles with different remedies. If a compact catalogue makes that distinction harder to see, I would add clearer status language before adding more tools. The goal is a smaller map, not a weaker gate.
The test I care about lists an operation, invokes it with the right grant, and then invokes it again without that grant. The second call must fail for the stated reason, and the listing must look the same before and after. If the catalogue hides an entry as soon as permission is missing, the boundary has moved into presentation, where it is hard to review. Hiding tools for clarity is fine, but the refusal still has to arrive when the operation is called directly.
import type { Outcome } from "./decide";
// Unavailable and denied are different obstacles with
// different remedies, so they get different messages.
export function explain(outcome: Outcome): string {
switch (outcome.status) {
case "allowed":
return "running the operation";
case "denied":
return "request a grant for " + outcome.missing;
case "unavailable":
return "not offered here: " + outcome.reason;
}
}