Skip to main content
An MCP server is a third party that an assistant gets access to. That is the whole security problem in one sentence, and every decision on this page follows from it. Omniflow sits on both sides of the protocol. It is a client of servers you register, and it is a server that a client of yours can connect to. The threat model is inverted between the two, and the conclusions are not symmetric.
Every connection is off until an owner turns it on, reaches only the hosts and tools that owner named, and appears in the audit trail whether it succeeds or is refused.
Neither side is mounted in this build. The registry, the allowlists, the schema validation, the limits, and the audit surface are real and are what a connection will be driven through, and the client and server packages are implemented and unit-tested — but no process serves the first-party MCP server and no feature calls a registered tool, because the AI features that would do so are not mounted either. See AI providers and local models for the same account of the model side.

Omniflow as a client

Discovery is not authorisation

Registering a server and enabling its tools are two different decisions. Discovery reads what a server advertises — its tools, resources, prompts, and protocol version — and stores it so you can look at it. Nothing becomes callable until you allowlist it individually. A rediscovery refreshes descriptions and schemas and leaves your decisions alone. If it re-enabled a tool you had switched off, discovery would be a way to widen access without anybody agreeing to it.

Every tool maps to a permission you already have

A tool is unusable until you map it to an Omniflow permission, and the check runs against the asking operator’s own grant rather than the server’s authority. A support operator cannot reach a tool mapped to finance.write, through the assistant or through anything else. The panel refuses a permission that does not exist, so a typo becomes a message about the field rather than a tool nobody can invoke.

Arguments and results are both validated

Every tool input and output is checked against the JSON Schema the server declared, in the subset Omniflow can actually enforce. A schema using a construct outside that subset makes the tool unusable at discovery time, with the reason shown, rather than being silently unenforced when it is called. Validating the way in and not the way out would leave the interesting direction unchecked: the result is the part an attacker controls. A result that does not match its declared contract is refused rather than passed on — that is the case where a server has been replaced. Objects are closed by default. An argument the tool never declared is not forwarded, because a client that forwards unknown arguments is how a tool-confusion attempt adds one.

Nothing external is written without a person

A tool you marked as a write requires an explicit confirmation and a stated reason on every call. The preview shows the server, the endpoint, the tool, the validated arguments, the permission the call is authorised by, and whether anything changes outside Omniflow — because a confirmation that does not say what will happen is a click-through. The write flag is yours to set rather than read from the server’s own annotation. A server that wanted to avoid a confirmation prompt would simply not set the hint.

Everything a server returns is data

Tool results are fenced, labelled as data, and scanned for the shapes an indirect prompt injection takes: instruction overrides, role claims, tool directives, exfiltration attempts, and hidden text. Findings are shown to you and recorded. The scan is the secondary defence. The structural one is that the content is presented to a model as a quotation rather than as instruction, which changes what it is rather than trying to guess what it says. Content carrying the fence delimiter has it removed — an escape a model might undo is not a boundary — and zero-width characters are stripped, because they exist here to hide instructions from the person reviewing the same text the model reads. A server’s own instructions field is captured for display and never forwarded to a model. A remote party that can write your system prompt controls your assistant.

Limits and egress

Egress is checked before every request, not once at registration. A hostname resolves to a different address than it did an hour ago, and a server that re-points at a cloud metadata endpoint after approval is the textbook version of this attack. Loopback, private, and link-local addresses are refused unless you explicitly allow a private network — which a self-hosted server legitimately needs, and which is off by default because it is also how a connection becomes an internal port scanner. The check runs twice. Before the request, the hostname is resolved so a refusal comes back as a message you can act on. Then the dialer checks the address it is actually connecting to, after resolution, so a DNS answer that changes between the two cannot slip through; IPv4-mapped IPv6 spellings of an internal address are judged as that address. A redirect is followed only to the endpoint’s own host or a host on the allowlist, over HTTPS unless the private-network opt-in is on, and each hop is dialled through the same check. Environment HTTP proxies are not used for these connections. Plaintext endpoints are refused by the Go validation and by a table constraint. A rule that lives only in application code is a rule one migration script can step around.

When a server misbehaves

Repeated transport failures open a circuit breaker, and calls are refused immediately until a cooldown lets one probe through. The point is not to protect the remote server: it is that an operator waiting on a ticket gets “this connection is down” in milliseconds rather than a twenty-second timeout per attempt. A tool that ran and returned an error does not trip the breaker. That is a working connection giving an answer, and counting it would take a server offline for correctly refusing a bad argument.

Omniflow as a server

The first-party server exposes permission-scoped admin tools, resources, and operational documentation. Every call is authorised as a person. A connection carries a token that resolves to an operator, and a tool that operator could not use in the panel is a tool they cannot use here. There is no service identity with its own permissions; one would be a way around RBAC with a nicer interface. Tools an operator cannot use are not listed. A listing that advertises what somebody cannot have teaches an assistant to keep asking. Read-only unless you say otherwise, twice. Mutating tools need AllowMutations and the specific tool named in the allowlist. “Connect an assistant to your admin panel” should not be one switch that also means “and let it issue refunds”. Every mutation is idempotent by key. A mutating tool requires an idempotency key, and a repeat with the same key returns the first result without running anything. Streamable HTTP retries, and a retried transport must not turn one refund into two. The same key with different arguments is refused: that is a caller bug, not a retry, and returning the first result would silently answer a question nobody asked.

What is recorded

Connection changes, discoveries, tool calls, confirmations, results, failures, and refusals. A trail that only records what happened cannot answer “did anyone try?”, which is the question asked after an incident. Each entry carries the operator, the arguments as sent, whether it was confirmed, the stated reason, the response size, the duration, and any injection findings in the result — so an answer that turns out strange has a record of the material that produced it.

Where to configure it

Settings → AI and MCP. Registering a server, allowlisting its tools, mapping each to a permission, marking which ones write, and reading the activity trail all happen there. Credentials are write-only: the field is always empty because the API never returns a value, and leaving it empty keeps the stored one.