Backend integration
Expose your systems with MCP
Build an MCP server your backend owns, register it with encrypted credentials, grant tools to agents and gate privileged tools with approval policies.
Agents act on your systems through tools, and the way to give an agent tools that live in your backend is the Model Context Protocol: your service exposes an MCP server over Streamable HTTP; the Agentic Workforce ME Tool Gateway connects to it with a credential you store once, discovers the tools, validates every call against your schema, records it as a run step and — where you say so — pauses for a human first. The demo portals expose their whole domain this way (accounts, invoices, complaints, cases) with @modelcontextprotocol/sdk and the same transport shown here.
1. Design the tools
- Few, task-shaped tools.
orders.getandorders.refundbeat a genericsql.query. The model reads the name, the description and the input schema — write them for a new colleague. - Namespaced names. Use
<domain>.<verb>. The platform matches grants and approval policies on the tool name, on<server-slug>.<tool>, and onprefix.*, so a consistent prefix lets one line grant or gate a whole family. - Typed inputs. Declare a JSON Schema (Zod in Node, type hints in Python). The gateway validates arguments before calling you, and an approver sees the same schema when editing a request.
- Idempotent mutations. Every write takes an
idempotency_keyand replays the original result on a repeat. Models retry, workflows retry, and a replayed trigger will call you again. - Errors as results. Return
{ error, message }withisError: truerather than throwing: the model reads it and recovers (asks, adapts, gives up gracefully). Transport errors and exceptions become failed steps. - Read-only where possible. Mark hints (
readOnlyHint,destructiveHint) and keep reads and writes as separate tools so approval policies can gate only the writes.
2. Build the server
Node: McpServer + StreamableHTTPServerTransport from @modelcontextprotocol/sdk, mounted on one Express route, a fresh server/transport per request (sessionIdGenerator: undefined — stateless, nothing to hijack), a bearer token compared in constant time. This is exactly the file the runnable example ships, and it is the same construction the demo portals use.
// The surface the platform's agents act against: an MCP server over Streamable HTTP,
// stateless (a fresh server + transport per request, no sessions), protected
// by a bearer token that lives platform-side only as an encrypted connection.
// Same SDK and transport the demo portals use.
import { createHash, timingSafeEqual } from 'node:crypto';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { z } from 'zod';
/** Constant-time bearer comparison (hash first so lengths never leak). */
function tokenMatches(header, expected) {
if (typeof header !== 'string' || !header.startsWith('Bearer ')) return false;
const presented = createHash('sha256').update(header.slice(7)).digest();
const wanted = createHash('sha256').update(expected).digest();
return timingSafeEqual(presented, wanted);
}
const text = (value) => ({ content: [{ type: 'text', text: JSON.stringify(value) }] });
const toolError = (code, message) => ({
content: [{ type: 'text', text: JSON.stringify({ error: code, message }) }],
isError: true,
});
function buildServer(store, log) {
const server = new McpServer({ name: 'acme-orders', version: '1.0.0' });
server.registerTool(
'orders.get',
{
description:
'Fetch one order by id (ord_…): customer, total, currency, status and refunds so far. The order system is the source of truth.',
inputSchema: { order_id: z.string().min(3) },
},
async ({ order_id }) => {
const order = store.getOrder(order_id);
log('orders.get', order !== null, { order_id });
if (order === null) return toolError('NOT_FOUND', `No order matches ${order_id}`);
return text(order);
},
);
server.registerTool(
'orders.refund',
{
description:
'Refund part or all of an order. amount_minor is in minor units (fils); the refund never exceeds the remaining balance. Idempotent per idempotency_key — reuse the same key when retrying. This moves money: it is gated by a human approval on the platform.',
inputSchema: {
order_id: z.string().min(3),
amount_minor: z.number().int().positive(),
reason: z.string().min(3).max(500),
idempotency_key: z.string().min(8).max(120),
},
},
async ({ order_id, amount_minor, reason, idempotency_key }) => {
const result = store.refund(order_id, amount_minor, reason, idempotency_key);
log('orders.refund', result.error === undefined, { order_id, amount_minor });
if (result.error !== undefined) return toolError(result.error, result.message);
return text({ ok: true, ...result });
},
);
return server;
}
/**
* Express handler for `POST /mcp`. Mount after `express.json()` so the parsed
* body can be handed to the transport.
*/
export function createMcpHandler({ bearerToken, store, log = () => {} }) {
return async (req, res) => {
if (!tokenMatches(req.headers.authorization, bearerToken)) {
log('mcp.auth', false, {});
res.status(401).json({
jsonrpc: '2.0',
error: { code: -32001, message: 'Unauthorized: bearer credential required.' },
id: null,
});
return;
}
const server = buildServer(store, log);
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
});
res.on('close', () => {
void transport.close();
void server.close();
});
try {
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (error) {
log('mcp.error', false, {
message: error instanceof Error ? error.message : String(error),
});
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: { code: -32603, message: 'Internal error' },
id: null,
});
}
}
};
}# pip install "mcp[cli]" uvicorn MCP Python SDK v2 (Python 3.10+), Streamable HTTP
import hmac
import os
from typing import Annotated
from pydantic import AnyHttpUrl, Field
from mcp.server import MCPServer
from mcp.server.auth.provider import AccessToken, TokenVerifier
from mcp.server.auth.settings import AuthSettings
from mcp.server.transport_security import TransportSecuritySettings
from mcp.types import ToolAnnotations
PUBLIC_MCP_URL = os.environ["PUBLIC_MCP_URL"] # https://api.acme.example/mcp
SERVICE_TOKEN = os.environ["MCP_BEARER_TOKEN"] # what the platform-side connection holds
class ServiceTokenVerifier(TokenVerifier):
"""The platform presents the connection credential as Authorization: Bearer <token>."""
async def verify_token(self, token: str) -> AccessToken | None:
if hmac.compare_digest(token, SERVICE_TOKEN):
return AccessToken(token=token, client_id="hive", scopes=["orders"])
return None
mcp = MCPServer(
"acme-orders",
token_verifier=ServiceTokenVerifier(),
auth=AuthSettings(
issuer_url=AnyHttpUrl("https://auth.acme.example"), # advertised in RFC 9728 metadata
resource_server_url=AnyHttpUrl(PUBLIC_MCP_URL),
required_scopes=["orders"],
),
)
ORDERS = {"ord_1001": {"id": "ord_1001", "status": "delivered", "total_minor": 12900, "refunds": []}}
@mcp.tool(name="orders.get", annotations=ToolAnnotations(read_only_hint=True, open_world_hint=False))
def orders_get(order_id: Annotated[str, Field(min_length=3)]) -> dict:
"""Fetch one order by id (ord_…): status, total in minor units and refunds so far."""
order = ORDERS.get(order_id)
if order is None:
return {"error": "NOT_FOUND", "message": f"No order matches {order_id}"}
return order
@mcp.tool(name="orders.refund", annotations=ToolAnnotations(destructive_hint=True, idempotent_hint=True))
def orders_refund(order_id: str, amount_minor: Annotated[int, Field(gt=0)], reason: str,
idempotency_key: Annotated[str, Field(min_length=8)]) -> dict:
"""Refund part of an order. Idempotent per idempotency_key. Gated by a human approval on the platform."""
order = ORDERS.get(order_id)
if order is None:
return {"error": "NOT_FOUND", "message": f"No order matches {order_id}"}
for r in order["refunds"]:
if r["idempotency_key"] == idempotency_key:
return {"ok": True, "refund": r, "replayed": True}
refunded = sum(r["amount_minor"] for r in order["refunds"])
if refunded + amount_minor > order["total_minor"]:
return {"error": "AMOUNT_EXCEEDS_BALANCE", "message": f"Refundable balance is {order['total_minor'] - refunded}"}
refund = {"id": f"rf_{len(order['refunds']) + 1}", "amount_minor": amount_minor,
"reason": reason, "idempotency_key": idempotency_key}
order["refunds"].append(refund)
return {"ok": True, "refund": refund, "replayed": False}
# A Starlette ASGI app with the endpoint at /mcp. stateless_http: a fresh transport per
# request (no sessions to hijack); json_response: plain JSON replies. Behind a real
# hostname the transport answers 421 until that host is allow-listed.
app = mcp.streamable_http_app(
stateless_http=True,
json_response=True,
transport_security=TransportSecuritySettings(allowed_hosts=["api.acme.example", "api.acme.example:*"]),
)
# uvicorn server:app --port 4100 → register {PUBLIC_MCP_URL} on the platform with auth_kind "bearer"Python uses the official mcp package (v2, Python 3.10+): MCPServer with @mcp.tool(name=…), streamable_http_app(stateless_http=True) for an ASGI app served by uvicorn, and a TokenVerifier for bearer authentication. References: tools, ASGI / Streamable HTTP, authorization. The Node transport is documented in the TypeScript SDK.
Authentication
The platform presents the connection credential on every request: Authorization: Bearer <token> when the server’s auth_kind is bearer or oauth, X-Api-Key: <key> for api_key. Generate a long random token per tenant, compare it in constant time, answer 401 with a JSON-RPC error body when it is missing, and serve only over HTTPS. The token never appears in prompts, run state or the API: the platform keeps it AES-256-GCM encrypted and decrypts it only inside the Tool Gateway for the duration of a call.
What a result looks like
Return content as text (JSON-encoded is fine — the example does this) and set isError for business failures. The platform records a redacted preview of both arguments and result on the run step and gives the model the full text.
{ "content": [ { "type": "text", "text": "{\"ok\":true,\"refund\":{\"id\":\"rf_1\",\"amount_minor\":12900},\"replayed\":false}" } ] }{ "content": [ { "type": "text", "text": "{\"error\":\"AMOUNT_EXCEEDS_BALANCE\",\"message\":\"Refundable balance is 0 AED\"}" } ],
"isError": true }3. Register it in the platform
Three admin calls: the server (slug, endpoint URL, how to authenticate), the connection (the token, stored encrypted) and a test that connects through the gateway and lists tools — the fastest way to see what an agent will see.
# 1. The server (auth_kind decides how the gateway presents the credential)
curl -X POST https://console.agenticworkforce.me/v1/mcp-servers \
-H "Authorization: Bearer $HIVE_API_KEY" -H "X-Tenant-Id: $HIVE_TENANT_ID" \
-H "Content-Type: application/json" \
-d '{"slug":"acme-orders","name":"Acme orders","transport":"http",
"endpoint":"https://api.acme.example/mcp","auth_kind":"bearer"}'
# → 201 { "id": "<server_id>", … }
# 2. The credential — encrypted at rest, never returned, decrypted only per tool call
curl -X POST https://console.agenticworkforce.me/v1/connections \
-H "Authorization: Bearer $HIVE_API_KEY" -H "X-Tenant-Id: $HIVE_TENANT_ID" \
-H "Content-Type: application/json" \
-d '{"mcp_server_id":"<server_id>","name":"acme-orders bearer",
"credentials":{"token":"'$MCP_BEARER_TOKEN'"},"credential_meta":{"kind":"service bearer"}}'
# → 201 { "id": "<connection_id>", … }
# 3. See what agents will see
curl -X POST https://console.agenticworkforce.me/v1/mcp-servers/<server_id>/test \
-H "Authorization: Bearer $HIVE_API_KEY" -H "X-Tenant-Id: $HIVE_TENANT_ID" \
-H "Content-Type: application/json" -d '{"connection_id":"<connection_id>"}'
# → { "ok": true, "tools": [ { "name": "orders.get", … }, { "name": "orders.refund", … } ] }| Setting | Value for a server your backend hosts |
|---|---|
transport | http — Streamable HTTP (the only transport a remote server can use; stdio is for local processes). |
endpoint | Public HTTPS URL of the MCP route, reachable from the platform’s workers. In local development use a tunnel. |
auth_kind | bearer (recommended) · api_key · oauth (a static OAuth access token) · none. |
connection.credentials | { "token": "…" } for bearer/oauth, { "api_key": "…" } for api_key. Rotate with PATCH /v1/connections/:id. |
Operationally the gateway pools one MCP client per (server, connection), caches tools/list for five minutes, times a tool call out after 60 s and drops idle clients after five minutes. A new tool therefore appears to agents within five minutes of deploying it; the slug you chose is how manifests and policies refer to the server.
4. Grant tools to agents
Nothing is callable until granted. A tool grant allows (or denies) a pattern for one agent, an org node, or the whole tenant; deny beats allow; the most specific grant wins. Optionally pin the connection_id the grant should use. The agent’s manifest then lists the server and may narrow further with allow — it can never widen past the grants.
# 4. Grant the tools to ONE agent (default is deny). Deny grants win over allow.
curl -X POST https://console.agenticworkforce.me/v1/tool-grants \
-H "Authorization: Bearer $HIVE_API_KEY" -H "X-Tenant-Id: $HIVE_TENANT_ID" \
-H "Content-Type: application/json" \
-d '{"agent_id":"'$AGENT_ID'","source":"mcp","mcp_server_id":"<server_id>",
"tool_pattern":"orders.*","effect":"allow","connection_id":"<connection_id>"}'{
"tools": {
"builtin": [],
"mcp": [
{ "server": "acme-orders", "allow": ["orders.get", "orders.refund"] }
]
}
}Publish the agent after changing its manifest; runs use the published version. The console’s agent editor shows the effective tool set (manifest ∩ grants) so you can confirm orders.get and orders.refund made it through.
5. Gate privileged tools with approval policies
A tool that moves money or changes state irreversibly should pause for a person. An approval policy matches a tool pattern (optionally for one agent) and declares the risk, how many distinct approvers are needed, which role may decide and how long the request stays open. When the agent calls a matching tool the run is checkpointed and interrupted; nothing executes until someone approves, edits or rejects — see Human-in-the-loop approvals.
# 5. Make orders.refund privileged: the run pauses until an admin approves
curl -X POST https://console.agenticworkforce.me/v1/approval-policies \
-H "Authorization: Bearer $HIVE_API_KEY" -H "X-Tenant-Id: $HIVE_TENANT_ID" \
-H "Content-Type: application/json" \
-d '{"name":"Refunds need a human","agent_id":"'$AGENT_ID'","tool_pattern":"orders.refund",
"source":"mcp","risk":"high","required_approvals":1,"approver_role":"admin","sla_minutes":1440}'- Patterns: exact name (
orders.refund), server-qualified (acme-orders.orders.refund), prefix (orders.*) or*. Most specific wins: agent-scoped over tenant-wide, source-pinned over any source, exact over prefix over*. - The same gate can be expressed in the manifest (
hitl.rules) — policies are the tenant-governance layer that applies regardless of who edits the agent. - Gated tools belong in direct agent runs (
POST /v1/agents/:id/runs, threads, agent-targeted triggers). A workflowagentnode whose child pauses for approval fails the workflow instead; gate a workflow step with agatenode — see where policies pause.
Local development
- The platform’s worker must reach your MCP endpoint. Run a tunnel (
cloudflared tunnel --url http://localhost:4100,ngrok http 4100) and register its HTTPS URL; re-run your setup script when the URL changes. - Against a self-hosted stack on the same machine, plain-http private hosts are rejected by the SSRF guard unless allow-listed in the deployment’s environment (
A2A_ALLOW_INSECURE_HOSTS). Production endpoints are always public HTTPS. POST /v1/mcp-servers/:id/testis your smoke test; the MCP Inspector (npx @modelcontextprotocol/inspector) lets you call tools by hand with the bearer header set.
Endpoints
- Auth
- API key +
X-Tenant-Id(tenant admin) - Errors
UNAUTHORIZEDTENANT_REQUIREDFORBIDDENVALIDATIONSLUG_TAKEN
Request
| Field | Type | Description |
|---|---|---|
slug | string | Tenant-unique. Manifests and policies reference it. |
name | string | Display name. |
transport | `http` | `stdio` | Use http (Streamable HTTP) for a server your backend hosts. |
endpoint | string | The MCP URL, e.g. https://api.acme.com/mcp. |
auth_kind | `none` | `bearer` | `api_key` | `oauth` | How the gateway presents the connection credential: bearer/oauth → Authorization: Bearer <token>, api_key → X-Api-Key: <key>. |
enabled | boolean | Default true. |
meta | object | Free-form, non-secret. |
Response
| Field | Type | Description |
|---|---|---|
id | uuid | Server id (for connections, grants and tests). |
- Auth
- API key +
X-Tenant-Id(tenant admin) - Errors
UNAUTHORIZEDTENANT_REQUIREDFORBIDDENNOT_FOUNDMCP_UNREACHABLE
Request
| Field | Type | Description |
|---|---|---|
connection_id | uuid? | Credential to use; else an existing connection. |
Response
| Field | Type | Description |
|---|---|---|
ok | boolean | Handshake + tools/list succeeded. |
tools[] | { name, description } | Discovered tools. |
- An unreachable or misconfigured server is 502
MCP_UNREACHABLEwith the transport error inerrors.
- Auth
- API key +
X-Tenant-Id(tenant admin) - Errors
UNAUTHORIZEDTENANT_REQUIREDFORBIDDENVALIDATIONNOT_FOUND
Request
| Field | Type | Description |
|---|---|---|
mcp_server_id | uuid | The server this credential is for. |
name | string | Display name. |
credentials | object | { token } for bearer/oauth, { api_key } for api_key (value is also read). |
credential_meta | object | Non-secret label shown in the console. |
node_id | uuid? | Scope to an org node (nearest-scope binding). |
PATCH /v1/connections/:idwithcredentialsrotates the secret in place.
- Auth
- API key +
X-Tenant-Id(tenant admin) - Errors
UNAUTHORIZEDTENANT_REQUIREDFORBIDDENVALIDATIONNOT_FOUND
Request
| Field | Type | Description |
|---|---|---|
source | `mcp` | `builtin` | `a2a` | `connector` | mcp for your server. |
mcp_server_id | uuid | Required when source is mcp. |
tool_pattern | string | Exact tool name, prefix.*, or *. |
effect | `allow` | `deny` | Deny wins over allow. |
agent_id / node_id | uuid? | At most one; omit both for tenant-wide. |
connection_id | uuid? | Pin the credential this grant uses. |
- Effective access = manifest
tools.mcp[].allow∩ grants. The manifest can narrow, never widen.