Agentic Workforce ME Developer PortalDocs 1.1 · Widget 0.1.0

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.get and orders.refund beat a generic sql.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 on prefix.*, 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_key and 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 } with isError: true rather 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.

backend-integration-node/mcp-server.mjsJavaScript
// 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,
        });
      }
    }
  };
}

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.

successJSON
{ "content": [ { "type": "text", "text": "{\"ok\":true,\"refund\":{\"id\":\"rf_1\",\"amount_minor\":12900},\"replayed\":false}" } ] }
business error (model recovers)JSON
{ "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.

Shell
# 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", … } ] }
SettingValue for a server your backend hosts
transporthttp — Streamable HTTP (the only transport a remote server can use; stdio is for local processes).
endpointPublic HTTPS URL of the MCP route, reachable from the platform’s workers. In local development use a tunnel.
auth_kindbearer (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.

Shell
# 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>"}'
agent manifest — toolsJSON
{
  "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.

Shell
# 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 workflow agent node whose child pauses for approval fails the workflow instead; gate a workflow step with a gate node — 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/test is your smoke test; the MCP Inspector (npx @modelcontextprotocol/inspector) lets you call tools by hand with the bearer header set.

Endpoints

POST/v1/mcp-servers201Register your MCP server.
Auth
API key + X-Tenant-Id (tenant admin)
Errors
UNAUTHORIZED TENANT_REQUIRED FORBIDDEN VALIDATION SLUG_TAKEN

Request

FieldTypeDescription
slugstringTenant-unique. Manifests and policies reference it.
namestringDisplay name.
transport`http` | `stdio`Use http (Streamable HTTP) for a server your backend hosts.
endpointstringThe MCP URL, e.g. https://api.acme.com/mcp.
auth_kind`none` | `bearer` | `api_key` | `oauth`How the gateway presents the connection credential: bearer/oauthAuthorization: Bearer <token>, api_keyX-Api-Key: <key>.
enabledbooleanDefault true.
metaobjectFree-form, non-secret.

Response

FieldTypeDescription
iduuidServer id (for connections, grants and tests).
POST/v1/mcp-servers/:id/testConnect through the gateway and list the server’s tools — the fastest way to see what agents will see.
Auth
API key + X-Tenant-Id (tenant admin)
Errors
UNAUTHORIZED TENANT_REQUIRED FORBIDDEN NOT_FOUND MCP_UNREACHABLE

Request

FieldTypeDescription
connection_iduuid?Credential to use; else an existing connection.

Response

FieldTypeDescription
okbooleanHandshake + tools/list succeeded.
tools[]{ name, description }Discovered tools.
  • An unreachable or misconfigured server is 502 MCP_UNREACHABLE with the transport error in errors.
POST/v1/connections201Store the credential your MCP server expects. Encrypted (AES-256-GCM) at rest, never returned, decrypted only inside the Tool Gateway per call.
Auth
API key + X-Tenant-Id (tenant admin)
Errors
UNAUTHORIZED TENANT_REQUIRED FORBIDDEN VALIDATION NOT_FOUND

Request

FieldTypeDescription
mcp_server_iduuidThe server this credential is for.
namestringDisplay name.
credentialsobject{ token } for bearer/oauth, { api_key } for api_key (value is also read).
credential_metaobjectNon-secret label shown in the console.
node_iduuid?Scope to an org node (nearest-scope binding).
  • PATCH /v1/connections/:id with credentials rotates the secret in place.
POST/v1/tool-grants201Allow (or deny) tools for an agent, an org node or the whole tenant. Default is deny.
Auth
API key + X-Tenant-Id (tenant admin)
Errors
UNAUTHORIZED TENANT_REQUIRED FORBIDDEN VALIDATION NOT_FOUND

Request

FieldTypeDescription
source`mcp` | `builtin` | `a2a` | `connector`mcp for your server.
mcp_server_iduuidRequired when source is mcp.
tool_patternstringExact tool name, prefix.*, or *.
effect`allow` | `deny`Deny wins over allow.
agent_id / node_iduuid?At most one; omit both for tenant-wide.
connection_iduuid?Pin the credential this grant uses.
  • Effective access = manifest tools.mcp[].allow ∩ grants. The manifest can narrow, never widen.