Skip to main content

Register a managed resource

Use this page to put an API or an MCP server that no connector covers behind the managed gateway. For a product that has a connector, install the connector instead. See Keep connector tools current.

A managed resource is one upstream that the gateway fronts. A REST resource is called at /gateway/<name>/.... An MCP resource is called through POST /gateway/mcp. How the managed gateway works explains what the gateway checks on each call.

Before you start​

  • The Admin API key for your environment and jq.
  • An upstream the environment can reach. The gateway refuses an upstream URL whose host resolves to a private, loopback or link-local address, so on a hosted environment the upstream must be reachable on the public internet.

Set four values.

export POLICYARC_URL="https://your-environment.example"
export POLICYARC_ADMIN_KEY="paste the Admin API key"
export UPSTREAM_URL="https://api.example.com"
export MCP_UPSTREAM_URL="https://mcp.example.com/mcp"

Run the blocks below in order in one shell. Each one checks the answer it gets, and fails if it is not the answer this page describes.

Try it with a local stub​

You can run the page without upstreams of your own. This stub answers both as the REST API and as the MCP server. Only an environment that runs on the same machine and may call host.docker.internal can reach it, such as a development environment in Docker Desktop with PBAC_SAFE_HTTP_ALLOW_INSECURE_LOCALHOST=true. A hosted environment cannot. We ran the page this way, on Docker Desktop for Mac.

The stub answers GET /orders/42 with one order, which Obligations redacts, and serves one MCP tool, lookup_order. Save it as stub.py and run python3 stub.py in its own terminal. It listens on 127.0.0.1 port 8765. Set STUB_PORT for another port. Docker Desktop forwards host.docker.internal to the machine's loopback address. Docker on Linux does not, so there run it as STUB_HOST=0.0.0.0 python3 stub.py, which also lets other machines on your network reach it.

stub.py
import json
import os
from http.server import BaseHTTPRequestHandler, HTTPServer

ORDER = {"id": "42", "item": "pens", "note": "confidential"}
TOOL = {"name": "lookup_order", "description": "Look up an order by id",
"inputSchema": {"type": "object", "properties": {"order_id": {"type": "string"}}, "required": ["order_id"]}}


class Stub(BaseHTTPRequestHandler):
def reply(self, status, payload):
body = json.dumps(payload).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)

def do_GET(self):
if self.path == "/orders/42":
self.reply(200, ORDER)
else:
self.reply(404, {"error": "not found"})

def do_POST(self):
req = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
if req.get("method") == "tools/list":
result = {"tools": [TOOL]}
elif req.get("method") == "tools/call":
order = req["params"]["arguments"]["order_id"]
result = {"content": [{"type": "text", "text": f"order {order}: 2 pens"}], "isError": False}
else:
result = {}
self.reply(200, {"jsonrpc": "2.0", "id": req.get("id"), "result": result})


HTTPServer((os.environ.get("STUB_HOST", "127.0.0.1"), int(os.environ.get("STUB_PORT", "8765"))), Stub).serve_forever()

Then point the page at it.

export UPSTREAM_URL="http://host.docker.internal:${STUB_PORT:-8765}"
export MCP_UPSTREAM_URL="http://host.docker.internal:${STUB_PORT:-8765}/mcp"

Register a REST API​

This call registers an orders API under the name orders. Reads need the scope orders:read. Creating an order needs orders:write.

resp=$(curl -sS -w '\n%{http_code}' -X POST "$POLICYARC_URL/admin/api/managed-resources" \
-H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" \
-H 'Content-Type: application/json' \
-d '{
"name": "orders",
"displayName": "Orders API",
"upstreamType": "REST",
"upstreamUrl": "'"$UPSTREAM_URL"'",
"resourceType": "urn:example:orders",
"defaultScope": "orders:read",
"routes": [
{"pattern": "/orders", "methods": ["GET"]},
{"pattern": "/orders/{orderId}", "methods": ["GET"], "resource_id_param": "orderId"},
{"pattern": "/orders", "methods": ["POST"], "scope": "orders:write"}
],
"upstreamAuth": {"type": "none"}
}')
echo "$resp"
[ "$(tail -n1 <<<"$resp")" = 201 ] || { echo 'Expected HTTP 201' >&2; false; }

The response is HTTP 201 with the stored resource.

FieldMeaning
nameRequired. The path segment after /gateway/. Also the prefix of every MCP tool name from this resource. policyarc is reserved.
displayNameRequired. The name the Admin Portal shows.
upstreamTypeRequired. REST, MCP or STATIC.
upstreamUrlThe base URL. The gateway appends the request path to it. Required for REST and MCP, and left out for STATIC.
resourceTypeThe resource type policy sees for every call to this resource.
defaultScopeThe scope a route needs when it declares none.
routesThe calls the gateway forwards. When the array has routes, a request that matches none of them gets HTTP 403. When the array is empty or left out, the rule below applies.
upstreamAuthHow the gateway authenticates to the upstream. See Authenticate to the upstream.
An empty routes array forwards everything

If a REST resource has no routes and has a defaultScope, the gateway matches every method and every path and forwards it with only defaultScope required. We created a resource with "routes": [] and "defaultScope": "openall:read", and a token with only that scope sent DELETE /admin/anything to the upstream. Leaving routes out stores an empty array and does the same. Declare a route for each call you mean to expose.

Each route has pattern and methods, and these optional fields.

Route fieldMeaning
scopeThe scope this route needs, instead of defaultScope.
resource_id_paramThe path parameter whose value policy sees as the resource id.
mcpExposes the route as an MCP tool. See Expose a REST route as an MCP tool.

In pattern, {orderId} matches one path segment, and {path:.*} matches the rest of the path, slashes included. The first route that matches wins.

Give a client the scope​

A token needs the resource type and its scope. This client is registered for the examples on this page. Your own clients get the same entitlement.

reg=$(curl -fsS -X POST "$POLICYARC_URL/register" \
-H 'Content-Type: application/json' \
-d '{"client_name": "orders-agent", "redirect_uris": ["https://orders-agent.example/cb"], "token_endpoint_auth_method": "client_secret_basic"}')
export CLIENT_ID=$(jq -r .client_id <<<"$reg")
export CLIENT_SECRET=$(jq -r .client_secret <<<"$reg")

curl -fsS -X PATCH "$POLICYARC_URL/admin/api/policy-data/by-key?dataKey=pbac.operator.controls.client_entitlements&path=client_id.$CLIENT_ID" \
-H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" \
-H 'Content-Type: application/json' \
-d '{"grant_types": ["client_credentials"], "granted_resources": [{"type": "urn:example:orders", "scopes": ["orders:read"]}]}'

The policy engine loads the change within a few seconds, so this block waits before it asks for a token.

sleep 5
TOKEN=$(curl -fsS -u "$CLIENT_ID:$CLIENT_SECRET" "$POLICYARC_URL/token" \
-d grant_type=client_credentials -d scope=orders:read -d resource_types=urn:example:orders |
jq -er .access_token)
export TOKEN

Call it through the gateway​

resp=$(curl -sS -w '\n%{http_code}' "$POLICYARC_URL/gateway/orders/orders/42" \
-H "Authorization: Bearer $TOKEN")
echo "$resp"
[ "$(tail -n1 <<<"$resp")" = 200 ] || { echo 'Expected HTTP 200 from the upstream' >&2; false; }

The gateway forwards GET /orders/42 to your upstream and returns its response. It does not forward your Authorization header. How the managed gateway works lists the other headers it withholds, and it forwards the rest. The check expects HTTP 200, which the stub returns for that path.

These calls show the gateway refusing. We ran each one.

CallResult
POST /gateway/orders/orders with the orders:read tokenHTTP 403 {"error":"Access denied"}. The route needs orders:write.
GET /gateway/orders/adminHTTP 403 {"error":"No matching route"}
GET /gateway/orders/orders with no tokenHTTP 401 with WWW-Authenticate: Bearer resource_metadata="..."

This block makes the three calls and checks each status and body.

expect() {
local want=$1 text=$2; shift 2
local resp; resp=$(curl -sS -D - -w '\n%{http_code}' "$@")
echo "$resp"
[ "$(tail -n1 <<<"$resp")" = "$want" ] && grep -qF "$text" <<<"$resp" ||
{ echo "Expected HTTP $want with $text" >&2; false; }
}
expect 403 'Access denied' -X POST "$POLICYARC_URL/gateway/orders/orders" -H "Authorization: Bearer $TOKEN"
expect 403 'No matching route' "$POLICYARC_URL/gateway/orders/admin" -H "Authorization: Bearer $TOKEN"
expect 401 'resource_metadata=' "$POLICYARC_URL/gateway/orders/orders"

Each call writes an audit row with the endpoint /gateway/orders/<path>. A denial carries its reason, such as pdp_deny, no_matching_route or no_token. A pdp_deny row also carries pdp_reason, the reason policy gave.

rows=$(curl -fsS "$POLICYARC_URL/admin/api/audit-logs?size=5" -H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" |
jq -c '.content[] | {endpoint, outcome, policyDecision}')
echo "$rows"
grep -qF '"endpoint":"/gateway/orders/' <<<"$rows" || { echo 'Expected a /gateway/orders/ audit row' >&2; false; }

Each allowed or denied call also has an /introspect row just before it. Its client is mr-rs-orders, the client the gateway uses to ask the authorization server.

Register an MCP server​

An MCP resource has no routes. On create, the gateway calls the upstream's tools/list and stores the tools. Every tool call needs defaultScope.

resource=$(curl -fsS -X POST "$POLICYARC_URL/admin/api/managed-resources" \
-H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" \
-H 'Content-Type: application/json' \
-d '{
"name": "orderbook",
"displayName": "Order book",
"upstreamType": "MCP",
"upstreamUrl": "'"$MCP_UPSTREAM_URL"'",
"resourceType": "urn:example:orderbook",
"defaultScope": "orderbook:read",
"upstreamAuth": {"type": "none"}
}')
echo "$resource"
jq -e '.tools | length > 0' <<<"$resource" >/dev/null || { echo 'Expected the discovered tools under tools' >&2; false; }

The response lists the discovered tools under tools. Grant the client the new type as well. The call replaces the client's entry, so it lists both types.

curl -fsS -X PATCH "$POLICYARC_URL/admin/api/policy-data/by-key?dataKey=pbac.operator.controls.client_entitlements&path=client_id.$CLIENT_ID" \
-H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" \
-H 'Content-Type: application/json' \
-d '{"grant_types": ["client_credentials"], "granted_resources": [{"type": "urn:example:orders", "scopes": ["orders:read"]}, {"type": "urn:example:orderbook", "scopes": ["orderbook:read"]}]}'

Wait a few seconds again, then get a token for orderbook:read.

sleep 5
MCP_TOKEN=$(curl -fsS -u "$CLIENT_ID:$CLIENT_SECRET" "$POLICYARC_URL/token" \
-d grant_type=client_credentials -d scope=orderbook:read -d resource_types=urn:example:orderbook |
jq -er .access_token)
export MCP_TOKEN

Call a tool. The tool name is the resource name, two underscores, and the upstream tool name.

resp=$(curl -fsS "$POLICYARC_URL/gateway/mcp" \
-H "Authorization: Bearer $MCP_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "orderbook__lookup_order", "arguments": {"order_id": "42"}}}')
echo "$resp"
jq -e '.result.isError == false' <<<"$resp" >/dev/null || { echo 'Expected the upstream result' >&2; false; }

With $MCP_TOKEN, the result is the upstream's result. With the orders:read token in $TOKEN, the call still returns HTTP 200, and the result is {"content": [{"type": "text", "text": "HTTP 403: Access denied"}], "isError": true}. The audit row's endpoint is /gateway/orderbook/tools/call:lookup_order.

An MCP resource is not reachable at /gateway/orderbook/.... That path returns HTTP 404 {"error":"Resource is not HTTP-accessible"}. This block checks both answers.

resp=$(curl -fsS "$POLICYARC_URL/gateway/mcp" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "orderbook__lookup_order", "arguments": {"order_id": "42"}}}')
echo "$resp"
jq -e '.result.isError == true and .result.content[0].text == "HTTP 403: Access denied"' <<<"$resp" >/dev/null ||
{ echo 'Expected HTTP 403: Access denied in the tool result' >&2; false; }

resp=$(curl -sS -w '\n%{http_code}' "$POLICYARC_URL/gateway/orderbook/orders/42" -H "Authorization: Bearer $MCP_TOKEN")
echo "$resp"
[ "$(tail -n1 <<<"$resp")" = 404 ] && grep -qF 'Resource is not HTTP-accessible' <<<"$resp" ||
{ echo 'Expected HTTP 404 Resource is not HTTP-accessible' >&2; false; }

Expose a REST route as an MCP tool​

Add an mcp block to a route, and MCP clients see it as a tool. This block turns the /orders/{orderId} route into orders__get_order.

{
"pattern": "/orders/{orderId}",
"methods": ["GET"],
"resource_id_param": "orderId",
"mcp": {
"tool_name": "get_order",
"description": "Get one order by id",
"input_schema": {
"type": "object",
"properties": {"orderId": {"type": "string"}},
"required": ["orderId"]
}
}
}

A tool argument with the same name as a path parameter fills that parameter. We called orders__get_order with {"orderId": "42"}, and the upstream received GET /orders/42. A route without an mcp block is not listed as a tool.

Authenticate to the upstream​

upstreamAuth sets the headers the gateway adds to each upstream request. These types exist in the code.

typeWhat the upstream receives
noneNo credential.
staticThe headers you list. A value can name a stored secret as {{secret.<name>}}.
idp_passthroughThe caller's own token from an identity provider they connected.
entra_refreshA Microsoft Entra token for the caller, from a stored refresh token.
google_saA token from a Google service account with domain-wide delegation, acting as the caller.
user_secretA credential each user enters once, such as an API token. See Per-user upstream credentials.

We ran none and static for this page. Connectors set the other types for you. Store a secret first, then refer to it. The static type never expands environment variables.

curl -fsS -X POST "$POLICYARC_URL/admin/api/secrets" \
-H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" \
-H 'Content-Type: application/json' \
-d '{"name": "orders_api_key", "value": "paste the upstream API key"}'
"upstreamAuth": {"type": "static", "headers": {"X-Api-Key": "{{secret.orders_api_key}}"}}

A secret name starts with a lowercase letter and holds only lowercase letters, digits and underscores. If the name does not exist, the gateway sends the header with an empty value.

What registering a resource also changes​

The create call does more than store the resource. On our 0.65.0 instance it did each of these.

  • It created the resource type with every scope the routes declare, and added it to the pbac.operator.controls.resource_registry row.
  • If the resource is exposed over MCP, it added that type and those scopes to pbac.operator.controls.public_client_policy for both dcr and cimd clients, and an entry for the type with "interfaces": ["mcp"] to the platform-default bindings in pbac.operator.segments. A resource is exposed over MCP when its upstream type is MCP, or when one of its routes has an mcp.tool_name, methods and a pattern. An MCP client that registers itself can then get those scopes over MCP, including write scopes. Limit who receives them with policy. A REST resource with no tool route is not added, so a self-registered client needs an entitlement of its own to call it. Installing a connector follows the same rule.
  • It created a client, mr-rs-<name>, that the gateway uses to call /introspect, and two resource servers for the upstream URL and for <environment URL>/gateway/<name>.

Change or delete a resource​

PUT /admin/api/managed-resources/<name> replaces the resource with the body you send. Read it, change it and send it back, so you keep the fields you did not mean to change.

curl -fsS "$POLICYARC_URL/admin/api/managed-resources/orders" -H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" |
jq '.upstreamAuth = {"type": "static", "headers": {"X-Api-Key": "{{secret.orders_api_key}}"}}' |
curl -fsS -X PUT "$POLICYARC_URL/admin/api/managed-resources/orders" \
-H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" -H 'Content-Type: application/json' -d @-

DELETE /admin/api/managed-resources/<name> removes the resource and returns HTTP 204.

A PUT that gives a REST resource its first tool route adds it to the public default, as a create would. We created a REST resource with no tool route, and neither row named its type. A PUT that added a route with an mcp.tool_name added it to both. Deleting the resource left both entries in place.

If something goes wrong​

What you seeCause
HTTP 400 on createname, displayName or upstreamType is missing. Or the upstream URL is missing, is not http or https, or resolves to a private address. The name policyarc also gives 400. A STATIC resource gives 400 if it has an upstreamUrl or upstreamAuth, has no routes, or has a route without a response whose status is 100 to 599. A response on a route of any other type gives 400.
HTTP 409 on createA resource with that name exists. Use PUT to change it.
HTTP 403 No matching routeThe resource has routes and none matches the method and path. Check methods and pattern.
HTTP 403 Access deniedPolicy refused the call. The audit row's pdp_reason says why. A token without the route's scope is the common case.
HTTP 404 Unknown resourceNo enabled resource has that name.
HTTP 502 Upstream request failedThe gateway could not reach the upstream.