Public MCP Server
The public MCP server is the endpoint noBGP hosts for you:
https://mcp.nobgp.com/mcp
It speaks for your account — every network and organization you belong to. Point an AI client at it and it can list your fleet, create networks, provision and register nodes, publish services, run commands, move files, and watch events, on machines behind NAT, CGNAT and firewalls that ssh, scp and curl cannot reach.
This page is the guide — what the endpoint is, how to connect to it, and how it authenticates. For the tools themselves, see the MCP Reference, which covers this surface and the local server side by side.
Connect a client
- Claude Desktop — add noBGP as a custom connector
- Claude Code — an
httpserver entry in.mcp.json - ChatGPT — the pre-configured Custom GPT, or noBGP as a Developer Mode connector
What only this surface can do
A node's local server exposes a deliberate subset of the same tools. The things it deliberately cannot do all live here:
- Create and destroy —
network_create,network_delete,provision_node,deprovision_node,register_node,task_stop(router 0.4.149+), which stops a provisioned machine and keeps its node, andtask_deadline_set(router 0.4.125+), which decides how long a provisioned machine keeps running and therefore keeps billing - Organizations and billing —
org_create,org_update,org_sso_setup,org_sso_set_enforced - Publish services —
service_publish,service_update,service_delete,service_share - Widen a node's reach —
node_label,node_grant,node_revoke - Rename a node —
node_rename(router 0.4.120+): renaming a peer is organization administration, and it frees the name that peer held - Reconfigure a node —
node_config_get,node_config_set: they write the file holding a node's own veto settings, so a node must not point them at a peer at any tier - See across networks —
network_directory, and any call naming a network other than one node's own
A granted node is bounded to its own network and cannot grant or label, so it can never widen its own reach. That boundary is the reason both surfaces exist.
Authentication
All endpoints require a Bearer token.
- MCP (
https://mcp.nobgp.com/mcp): OAuth 2.0 Authorization Code Flow with PKCE. Sign in with any provider offered on the noBGP login page (Google, GitHub, and any SSO connection your organization has configured). Token refresh is automatic; session management is per-conversation. - REST / OpenAPI: supply the same Bearer token via the
Authorizationheader.
Discovery
A client that already knows how to sign in — Claude Desktop, Claude Code, ChatGPT — needs nothing from this section. A client you are writing yourself discovers where to sign in from the endpoint itself, and this is the contract it can rely on.
An unauthenticated request to a protected path answers 401 with a WWW-Authenticate header naming the metadata document for that endpoint:
WWW-Authenticate: Bearer realm="nobgp",
resource_metadata="https://mcp.nobgp.com/.well-known/oauth-protected-resource/mcp",
authorization_uri="https://signin.nobgp.com/oauth2/authorize"
Fetching that document returns RFC 9728 protected-resource metadata:
{
"resource": "https://mcp.nobgp.com/mcp",
"authorization_servers": ["https://signin.nobgp.com"],
"bearer_methods_supported": ["header"]
}
Four things are worth knowing about it:
authorization_serversnames exactly one server, and it is never the MCP host. noBGP's sign-in service is the authorization server; the router only accepts the tokens it issues. Client registration, authorization and token exchange all happen at the entry in this list — read it rather than assuming the endpoint you called also issues tokens. Router 0.4.49 removed a second entry that pointed back at the MCP host, where no such endpoints exist.- The router serves no
/.well-known/oauth-authorization-server. It is a resource server: it accepts tokens, it does not issue them. Any authorization-server metadata you need comes from the issuer named above, at that issuer's own address. resourceis the canonical URI, with no trailing slash. You are expected to compare it against the URL you are calling as a plain string, sohttps://mcp.nobgp.comandhttps://mcp.nobgp.com/are not interchangeable. The bare/.well-known/oauth-protected-resourceanswers with the former.- The document is per-endpoint.
/.well-known/oauth-protected-resource/mcpdescribeshttps://mcp.nobgp.com/mcp;/.well-known/oauth-protected-resource/api/v1/toolsdescribes the REST path. The401challenge always names the one that matches what you called, so following it is simpler than constructing it.
Scopes
Each tool advertises the scope family it belongs to via x-nobgp-auth-scopes in the OpenAPI schema and GET /api/v1/tools. Scopes are advisory classification for clients; authorization is enforced by organization role (see the role notes on each tool). A denied call returns forbidden (HTTP 403).
| Scope | Tools |
|---|---|
| (none) | whoami, feedback_submit, node_label, node_grant, node_revoke, node_config_get, node_config_set, command_subscribe, the event tools (fs_subscribe, presence_subscribe, event_tail, event_unsubscribe, event_publish, event_subscriptions), and the SSO/billing tools |
network.read | network_directory |
network.write | network_create, network_delete |
org.write | org_create |
node.register | register_node |
provisioning.write | provision_node, deprovision_node, task_stop, task_deadline_set |
service.write | service_publish, service_update, service_delete, service_share |
service.read | service_check |
shell.exec | command |
fs.read | file (read/list/stat), fs_read, fs_list, fs_stat, fs_glob, fs_grep, fs_grep_subscribe |
fs.write | file (write/edit/delete/mkdir), fs_write, fs_edit, fs_delete, fs_mkdir |
fs.read + fs.write | fs_copy, file (copy) — it reads one end and writes the other |
net.read | net_peers, net_interfaces, net_metrics, net_routes, net_dns, node_logs |
members.manage | org_update |
Transport adapters
The same canonical tools registry is reachable over three adapters. Parameters and response shapes are identical — only framing, auth carrier, and streaming delivery differ.
MCP — /mcp
JSON-RPC 2.0 over Streamable HTTP. Tools advertised via tools/list; invoked via tools/call. Streaming tools deliver chunks as notifications/progress messages; one-shot tools return their full response in the tools/call result.
A failed tools/call carries the same structured {code, message, call_id, retryable, details} body REST returns, in structuredContent, from router 0.4.85 — before that only the message prose reached the client. See the note under Error Handling.
REST — POST /api/v1/tools/{name}
Request struct as JSON body. Success envelope:
{ "data": { "...typed response..." }, "message": "human-readable summary" }
data is the same result envelope MCP returns — call_id, op, done, duration_ms, the subject, and the tool's payload under its own key — so a tool answers identically on both adapters (router 0.4.83+).
Error envelope:
{ "error": { "code": "not_found", "message": "...", "call_id": "…" } }
retryable and details appear only when there is something to say — retryable is sent when it is true and omitted otherwise, so treat its absence as do not retry.
Streaming tools (fs_grep) negotiate via Accept: text/event-stream and respond with SSE events: chunk, progress, done, error.
OpenAPI — GET /api/v1/openapi.json
OpenAPI 3.1 spec served live from any router replica. Use it as a build input for SDK generation. Every tool appears under /api/v1/tools/{name} with full request/response schemas.
Tool index — GET /api/v1/tools
Returns a brief index (name, description, mode, capabilities) for quick introspection.
Transfer URLs — /xfer/<token>
Router 0.4.87+ — the route shipped in 0.4.84 but no URL could actually be minted on it until 0.4.87, see Moving bytes without reading them. A read or write asking for transfer: "url" mints a short-lived URL on this route, and it is deliberately not under /api/v1: that stack caps a body at 1 MiB behind JSON middleware, which is the ceiling a transfer URL exists to escape. It carries no Authorization header — the token in the path is the credential, so treat the URL as a secret.
GETdownloads. It advertisesAccept-Ranges, and the SHA-256 arrives as anX-Nobgp-Sha256trailer, since it does not exist until the stream ends. The capability is spent only on a delivery that reached EOF, so a shortRangerequest leaves it live.PUTuploads, up to 8 GiB per body; past that, useContent-Rangeacross several requests. Resume survives a dropped client connection but not a router restart, and a response that cannot resume says so by name withresume_offset: 0.- A refusal names which kind it is, because this route cannot infer it: an authorization failure (
token_unknown,token_expired,token_spent,token_revoked,wrong_method) means mint another URL, while a transfer failure (node_offline,path_gone, …) means fall back to the session protocol — a new URL would fail identically. - A capability is a live check rather than a signed claim: the minting caller's role is re-read at redemption, so a URL minted before a membership was revoked dies with the membership.
Response compression
Router 0.4.85+. Both /mcp and /api/v1 negotiate gzip. Send Accept-Encoding: gzip and an eligible response comes back Content-Encoding: gzip; send nothing, or gzip;q=0, and you get exactly what you asked for. Every response carries Vary: Accept-Encoding, whether or not you used it. Most HTTP clients do all of this for you — this section is for one that does not.
It is worth asking for: the tool list is the largest thing this endpoint serves, and description prose compresses to roughly an eighth of its size. It buys bandwidth only — a model still pays for the same text after decompression, so this is not a way to make a tool list cheaper to read.
What is and is not compressed is decided from the response itself, never from the route you called:
- Only
application/json. Streaming replies are not: SSE rides these very same URLs —/api/v1/tools/<name>servestext/event-streamonAcceptalone, and/mcpdoes for every POST — so a handler that has begun delivering incrementally is never buffered into a compression window. Compressing by path would have turned every stream into a batch. - Only past ~1400 bytes. Below one network segment there is no round trip to remove, and gzip's own framing makes a small body slightly larger — a 101-byte error envelope measured at 102 compressed.
- Never on file bytes. The
/xfer/route and the WebDAV/storage-tree routes are excluded outright: those carry opaque user content, which is often already compressed, where gzip only adds a percent. Base64 file content inside a JSON tool result is a different case and is compressed like any other JSON.
Only gzip is offered. deflate is ambiguous on the wire and every client that offers it also offers gzip.
Execution modes
one_shot— request in, response out. Used by every read tool and most write tools.server_stream— long-running tool that emits incremental progress events (SSE on REST, MCP progress on MCP). Currently onlyfs_grep.
Whether fs_grep matches actually arrive incrementally depends on the node: a search that has to run as the node's configured user — the usual case, since registration sets user to the installing account — is performed by the agent's privilege-dropped file worker, which answers with one reply per request. The response shape and the SSE framing are the same either way; the matches simply land when the walk finishes instead of as they are found. On agent 0.4.34 that case was refused outright; upgrade to 0.4.35 if fs_grep fails on a node.
A few tools (command, file read/write) are technically one_shot but expose a session-based workflow: the first call returns a session id, subsequent calls pass that id to continue. See Cross-replica session forwarding.
fs_glob (doublestar pattern match) is available via the versioned REST surface at POST /api/v1/tools/fs_glob and is not registered over MCP. It is a one-shot call; nothing about the transport prevents it, so this may change.
Its matches are bounded by the node's allow-roots at every directory the walk enters, and a refused directory is pruned. Following symlinks does not widen that: agents before 0.4.37 could descend through a link and report entries whose real path lay outside the roots entirely, which was only ever a list of names fs_read refused on the next call.
fs_grep is on MCP. Its matches are always in the response; a progressToken additionally pushes each one as it is found — see fs_grep.
org_update is likewise REST-only — it is intended for dashboard/management clients rather than the conversational MCP toolset. So are the remaining organization-membership, audit, and billing operations (org_members_list, org_invite_*, org_member_*, org_leave, org_transfer_ownership, org_audit_list, create_checkout, create_billing_portal, set_spend_cap, from router 0.4.103 compute_usage, and from router 0.4.114 billing and credit); their behavior is described on the Organizations and Plans & Billing pages.
billing reads one organization's billing summary — the plan and its status, whether it is set to end at the period end, the payment method on file — not always a card — and a page of invoices with links to each one. It is Owner-only and read-only, and it changes nothing: use create_billing_portal to change any of it. That the billing tools stay off MCP is a deliberate authority decision rather than a transport limit — an invoice list and a saved card belong on a screen their owner opened, not in an AI client's tool list.
credit reads or changes one organization's usage credit — the balance, the standing top-up rule, and whether the balance may be spent at all. It is Owner-only, and it stays off MCP for the sharper version of the same reason: charging a card belongs to a screen its owner is looking at. From router 0.4.118 its status reply carries credit_billed, which said whether the deployment pays usage past the allowance from that balance at all; from router 0.4.132 it is always true — usage is never invoiced, so a balance is the only way it is ever paid for — and the field is kept only so an existing client keeps reading an answer. From router 0.4.127 op=purchases adds the period's purchase attempts, newest first — the only customer-visible record that a card was charged, and the only place a failed purchase is visible — and the status reply also says how much money is in flight and since when.
compute_usage reports one organization's provisioned-compute usage for a billing period: billed hours and cost per class, the included allowance, and what is left of it. It takes org_id and an optional period (YYYY-MM, current period if omitted), and any member of the organization may read it — unlike its billing siblings above, which are Owner-only. Billed minutes are what it reports, because those are what the invoice charges; a task's elapsed time is rounded up to whole minutes with a one-minute minimum.
Rate limiting
Requests may return rate_limited with a Retry-After hint — back off and retry.
The per-call limits apply to this endpoint from router 0.4.91; before that they were enforced on the REST and /actions surfaces only, so a client that never met one may start to. A call refused on the target-node axis still reaches the audit log — the node is resolved before its budget is checked, so the attempt is filed against that node's organization, which is what makes who is hammering this machine answerable. A refusal on the caller axis happens before any node is resolved, so it has no organization to be filed against and does not appear in a per-organization log.
The ceilings, from router 0.4.92, on two independent axes. A call needs a token on both:
| Axis | Sustained | Burst | Counts |
|---|---|---|---|
| Caller | 600 calls / minute | 120 | every tool call you make |
| Target node | 600 calls / minute | 120 | calls that name one node, across every caller pointed at it: file and the seven per-op fs_* tools, the net_* diagnostics, node_logs, node_config_get / node_config_set. command is not on this axis — a shell session is bounded by the caller axis alone |
They are sized to cut a runaway loop without touching ordinary work: an assistant paging a 10 MB file through 32 KiB fs_read calls makes about 320 of them, so the first 120 go straight through and the rest clear in roughly half a minute, while a loop spinning as fast as the network allows is slowed by one to two orders of magnitude. For a single caller the target axis is effectively inert — the caller axis binds first — and it starts to matter when several people or assistants converge on one machine.
⚠ Until 0.4.92 the shipped numbers were zero, which meant no limit at all. The buckets existed and were described as the protection against a runaway client, but nothing set a number, so the only real bound was the network. If you self-host a router, this is the release where the defaults became real ones; they remain overridable per deployment.
⚠ A node's local MCP server spends its owner's caller budget, since it acts with that account's authority. A granted node running a busy loop and its owner's own assistant draw on the same 600 — which is part of why the number is generous rather than tight.
A rate_limited error includes details.retry_after_ms. Streaming tools (fs_grep) also count against a global in-flight slot pool (default 8 concurrent streams) — exhaustion surfaces as resource_exhausted (HTTP 429) rather than rate_limited. The pool frees up as in-flight streams finish, so this case is safe to retry shortly, and from router 0.4.83 the error says so: it is the one resource_exhausted that carries retryable: true. The other producer of that code — a plan cap, a lapsed subscription, an empty credit balance — carries retryable: false, because retrying it never clears it. Read the flag rather than the code.
Audit logging
The router emits one structured audit event per tool invocation, capturing who called the tool, which tool ran, the target it acted on, when it ran, and the outcome. Tool arguments are not captured — they can contain secrets (tokens, keys, sensitive paths), so only that narrow summary is logged. Events are written to the per-organization audit log, which Owners and Admins read in the app.
Which of those events land in the per-organization log changed in router 0.4.98: calls that address a node — command, file and the per-op fs_* tools, node_logs, node_config_get / node_config_set, the net_* diagnostics, and the four subscribe tools — are recorded from that release, where before it held only the calls that changed something. Such an entry is filed against the organization that owns the node, not the caller's own. Organizations → Audit log has the full list of what is and is not recorded.
⚠ Router 0.4.98 persisted no entries at all, whichever surface the call arrived on. Every insert was refused by a defect in the audit record's own shape, so that release's log is empty for every organization — including the changes the log had always held. Fixed in router 0.4.99; the gap cannot be backfilled. Tool calls themselves ran and returned normally throughout.
⚠ Calls made over MCP were not covered until router 0.4.91. The audit hook sat on the dispatch path shared by the REST and /actions surfaces and was never reached by /mcp, which carries essentially all of the traffic — so an organization's audit log recorded its REST callers and not its MCP ones. Both doors now go through the same hook, and calls that never reached a handler (an unknown tool name, arguments the schema rejected) are recorded too. A tool name that is not one the router serves is written as <unregistered> rather than echoed back, so the log stays groupable; the caller, the timestamp and the error code still say that an unknown tool was asked for. Entries written before 0.4.91 are unaffected and cannot be backfilled.
Versioning
The router and the agent are versioned independently. Tool schemas are forward-compatible: response struct field additions land without a major bump and are absorbed by the loose-output-schema in the MCP adapter, so cached clients don't break on new fields.
Field renames or type changes are breaking and require a coordinated agent + router release. CI diffs a committed OpenAPI snapshot on every change to catch unintended drift.
⚠ Router 0.4.83 made two such changes on purpose, and both need a client reconnect rather than a code change: every tool result moved into one envelope with the tool's own payload nested under a named key, and admin became a string enum on the tools that run something. A client reading fields off the top level of a result has to follow the nesting; a client sending admin: true does not have to change anything, since the boolean is still accepted.
For the authoritative machine-readable schemas, fetch /api/v1/openapi.json live from any router replica.
When your client's tool list is stale
Nothing forces an MCP client to refetch tools/list. The endpoint is stateless, so there is no server→client channel to push a notifications/tools/list_changed on, and the protocol's cache hint is the only thing that ever brings a connected client back for a fresh list. A client holding a snapshot from an older router build fails in two ways that both read as a bug in the tool:
- A parameter its schema never listed gets sent untyped, and Claude-family clients serialize an untyped value as a string.
admin: "true"was then rejected withtype: true has type "string", want "boolean"— an error that blames the type and never mentions the cache. - A parameter removed since the snapshot —
session.username, gone in router 0.4.33 — is rejected as an unknown property, with nothing saying the whole snapshot predates the removal. - A parameter whose type changed since the snapshot.
adminbecame a string enum in router 0.4.83, so a client holding an older list keeps sending the JSON booleantrueagainst a field now published as a string. - A whole tool added since the snapshot, which raises no error anywhere. A tool missing from your list is one you never call, so nothing is ever rejected and no message can carry a fix. This is the failure mode with no error channel at all: measured 2026-08-20, a session connected minutes before a deploy could not see three tools that deploy had just added, and had no way to learn they existed.
The first three are mitigated at the boundary now:
- Booleans and their string spellings are coerced, in both directions.
"true","false","1"and"0"on a boolean-typed parameter become the boolean; from router 0.4.83 a JSON boolean on a string-typed parameter becomes"true"or"false", which is what keeps every cached client'sadmin: trueworking. This is booleans only, and deliberately so: the string form of a boolean is unambiguous, while a stringified array or integer is not, so those still fail — the fix for them is a refreshed schema, not a lenient server. Typed label values andcommand.envare left alone, so a genuine string that happens to read as a boolean is never rewritten. - Every argument-validation rejection names the actual fix, in a postscript carrying the router version you are talking to. So a call that coercion cannot save costs one failed call instead of a wrong mental model built around a field that appears not to exist.
Both only apply to calls the validator rejects; an accepted call is untouched. Ordinary mistakes — naming both node_id and node_name, say — stay unadorned, because those are not stale caches. ⚠ A coerced call is one call, from router 0.4.94. Coercion works by retrying the rejected call once with the corrected argument, and until that release the retry crossed the rate limit, the audit log and usage counting a second time — so every admin: true from a client holding a pre-0.4.83 schema spent two of your 600 calls a minute and left a phantom failed entry in the audit log beside the successful one. Nothing about the call's own result changed; the accounting around it did.
The fourth has nothing to reject, so it is answered by a field you can read on purpose: whoami returns router_build from router 0.4.86, the build serving your calls. Compare it with the build your tools/list came from — if the router is newer, refetch the list before concluding a capability does not exist. The server says the same thing once per session in its instructions.
Reconnecting your client is the real fix. A router deploy disconnects MCP clients, and most refetch the tool list on reconnect — though from router 0.4.77 a client honouring the cache hint below may keep serving its stored list for up to a minute after reconnecting, so the window for a misleading invalid_args is now "reconnect, plus up to 60 seconds" rather than "reconnect".
How long you may cache the tool list
From router 0.4.77 the tools/list and server/discover responses carry a real cache hint: ttlMs of 60 seconds, scoped private. Before it the router left ttlMs at 0, which the protocol defines as immediately stale — so every conforming client was being told to refetch a ~210 KB tool list each turn, and to refetch it again the moment a deploy reconnected it.
- Sixty seconds is a staleness budget, not a bandwidth setting. It is sized against the fastest thing that legitimately changes the list you are shown — gaining access to the provisioning tools on this endpoint, or a grant tier changing on a node's local server — so a stale list costs you at most a minute of out-of-date advertising.
privatemeans no shared cache may reuse it. The list differs per caller, so a proxy must never serve one account's view to another.- Staleness never costs you authority. Permission is re-read on every call, so a list a minute out of date can only produce a refusal that names its fix — never a capability you no longer hold.
- A client that ignored
ttlMs: 0will ignore 60 s too. For those, the error postscript above is still the channel that reaches them; the cache hint is a permission to cache, not a bound on it. - The server now says outright that it will never notify you (router 0.4.83). It advertises
tools.listChangedas false in its capabilities, where the SDK had been inferringtruefrom the fact that tools exist. The endpoint is stateless, so there is no server→client channel anotifications/tools/list_changedcould travel on — the claim was never true, and a client that trusted it was waiting for a message that could not arrive. The cache lifetime above is the only thing that refreshes a connected client, which it already was.
Next Steps
- MCP Reference - Every tool on both surfaces, with parameters and response shapes
- Local MCP Server - The other surface: a node serving MCP on
127.0.0.1 - Use Cases & Examples - The tools in action
- Core Concepts - The architecture underneath
Additional Resources
- MCP Specification - Learn about the protocol
- noBGP Web Dashboard - Alternative management interface
- OpenAPI Schema - machine-readable schema for every tool