Developers
MCP for developers
Put an MCP server behind OpsAI so every tool call is checked against identity, policy and risk before it reaches the tool, and recorded after.
Two directions
- OpsAI in front of your server
- A tool call is evaluated before it reaches the tool. This page.
- OpsAI as a server
- An assistant reads the estate. Read-only, deliberately. Covered in docs.
Why a tool call needs governing
A tool description is a prompt. It is not a control.
MCP made it trivial to give an assistant real capabilities, and the thing deciding whether a capability should be used is a sentence written for a human to read. That works until an assistant is persuasive, mistaken, or being manipulated by content it was asked to summarise.
A tool that reads is an exposure. A tool that writes is an action in exactly the sense the rest of this product means — it changes something in a system of record, and somebody has to answer for it afterwards. The fact that it arrived over MCP rather than from an agent framework changes nothing about that.
So MCP does not get its own policy engine here. The same bounds apply, the same risk function scores it, and the same three outcomes come back. What is different is only where the boundary sits.
Governing the tool call is the only version of this that does not depend on the assistant behaving.
A check inside the assistant is a check the assistant can be argued out of. A check at the boundary is one it cannot see and cannot route around.
What happens to a tool call
Five stops, and the last one includes the calls that were refused.
Identical to the path any other action takes. That is the point — an MCP tool call is not a special case, it is an action that arrived over a different transport.
An assistant calls a tooluntrusted
It has chosen a tool and arguments from a description. Nothing has been checked yet, and the description is a prompt rather than a contract.
The caller is identifiedattested
Which assistant, acting for which person, under whose authority. An MCP session carries an identity or it cannot be governed at all.
The call is evaluated as an actionno model call
A tool call that writes is an action. Same bounds, same risk scoring, same three outcomes — nothing about MCP gets a separate policy engine.
The tool runs, or it does notdecided
An authorized call reaches your server with a grant scoped to it. A refused call never arrives, and the assistant receives a refusal naming the failing check.
The call is recorded either waysealed
Including the refusals. A tool call an assistant attempted and was stopped from making is the most useful line in an incident review.
- The identity problem
- An MCP session has to carry who it is acting for. Without that, a tool call is anonymous and no policy can be applied to it — there is no authority chain to resolve and nobody to hold accountable.
- The argument problem
- A bound reads the arguments, not the tool name. Governing issue_refund as a name is useless; governing it with a subject and an amount is a control.
- The refusal problem
- A refused tool call has to come back as a usable answer, or the assistant retries in a loop. The refusal names the failing check so it can be relayed to the person instead.
How to wire it
Evaluate inside the tool handler, before the tool does anything.
Your MCP server keeps its own shape. The only change is that a handler which writes asks first, and returns the refusal as its result rather than throwing.
import { OpsAI } from '@opsai/sdk';
const opsai = new OpsAI({ apiKey: process.env.OPSAI_API_KEY });
server.tool(
'issue_refund',
'Issue a refund against an order.',
{ order: z.string(), amount: z.number() },
async ({ order, amount }, { session }) => {
// The session must carry who the assistant is acting for.
// An anonymous tool call cannot be governed.
const decision = await opsai.actions.evaluate({
agent: session.assistantId,
onBehalfOf: session.userEmail,
action: 'issue.refund',
subject: order,
amount: { currency: 'INR', value: amount },
idempotencyKey: `mcp:${session.id}:${order}`,
});
if (decision.outcome !== 'authorized') {
// Return it as a RESULT, not an exception. An assistant that
// receives an error retries; one that receives a reason relays it.
return {
content: [{
type: 'text',
text: `Refused by ${decision.bound}: ${decision.reason}. ` +
`A person needs to approve this.`,
}],
isError: false,
};
}
await razorpay.refunds.create({ order, amount }, decision.grant);
return { content: [{ type: 'text', text: `Refunded. Record ${decision.evidence}.` }] };
},
);Three things that make this work in practice
- Refusals are results, not errors
- An assistant that gets an exception assumes something broke and tries again. One that gets a sentence explaining the refusal tells the person, which is the outcome you wanted.
- The idempotency key includes the session
- Assistants retry more than code does. Keying on session and subject makes a second attempt provably the same request rather than a second refund.
- Read tools are governed too
- A tool returning customer records is an export path. That is governed at the data boundary by destination rather than by the tool description.
The other direction
OpsAI also exposes a server, and it is read-only on purpose.
Useful for asking an assistant why something was refused, what the current posture is, or which systems are unattributed. It cannot approve anything, and that is not a gap waiting to be filled.
Exposing authorization over MCP would mean an assistant could be talked into granting something — which is precisely the risk the rest of this page exists to remove. Reading is safe because a read cannot change state; approving is not.
{
"mcpServers": {
"opsai": {
"command": "npx",
"args": ["-y", "@opsai/mcp-server@latest"],
"env": { "OPSAI_API_KEY": "sk_read_..." }
}
}
}Where to go
Take the one tool on your server that writes, and evaluate it.
Most MCP servers have exactly one or two handlers that change something and a long tail that only reads. Governing the short list is a morning's work and it is where all the exposure is.