Odel
cubiczan chp mcp

cubiczan chp mcp

Local
@icohangar-opsTypeScriptMITUpdated Today

Spend approval gate for AI agents: budget caps, HITL thresholds, signed audit hash.

@cubiczan/chp-mcp

icohangar-ops/cubiczan-chp-mcp MCP server

One-command MCP install for CHP Profile B spend / capital gates and tool-approval receipts (an allowlist is not authorization), plus a structured deny ledger and receipt-gated finance tools.

MCP Registry npm Conformance

Wraps @cubiczan/chp so Cursor, Claude Code, or any MCP client can call evaluate_spend_gate without vendoring protocol code. Engine digests match the normative golden vectors (Profile B 30/30).

How the pieces fit

MCP client (Cursor / Claude / …)
        │  tools/call
        ▼
┌───────────────────────────┐
│  MCP server (transport)   │  ← you are here (@cubiczan/chp-mcp)
│  evaluate_spend_gate      │
│  approve_spend            │
│  evaluate_tool_approval   │  allowlist ≠ authorization
│  issue_approval_receipt   │
│  authorize_tool_call      │
│  request_authorization    │  finance-tool receipt / HITL / deny
│  place_equity_order       │  scoped + receipt-gated (synthetic)
│  wire_treasury_transfer   │
│  rebalance_portfolio      │
│  inspect_audit_ledger     │  CHP-signed deny / authorize / execute
│  chp_content_hash         │
└─────────────┬─────────────┘
              │ depends on
              ▼
┌───────────────────────────┐
│  Published CHP packages   │
│  npm:  @cubiczan/chp                 (Profile B)
│  PyPI: consensus-hardening-protocol  (Profile A)
└───────────────────────────┘

For AGENTS.md + skills + Profile A decision_gate / decision_adversary, use agent-conductor instead.

Install

npm install -g @cubiczan/chp-mcp
# or one-shot
npx -y @cubiczan/chp-mcp

Cursor / Claude Desktop

{
  "mcpServers": {
    "chp": {
      "command": "npx",
      "args": ["-y", "@cubiczan/chp-mcp"]
    }
  }
}

Claude Code

claude mcp add chp -- npx -y @cubiczan/chp-mcp

Tools

ToolMaps toPurpose
evaluate_spend_gateevaluateGateLOCKED / HITL_REQUIRED / BLOCKED + claims + content hash. BLOCKED is also a ledgered policy_deny.
approve_spendapproveHumanHuman lock when HITL_REQUIRED (cannot override hard fails). Optional tool + bound_args mint a signed receipt.
evaluate_tool_approvalevaluateToolApprovalAllowlist is a pre-filter; host-bound fields merge into args_hash; a receipt is still required
issue_approval_receiptissueApprovalReceiptHuman allow/deny → HMAC-signed receipt + decision log
authorize_tool_callauthorizeToolCallConsume a receipt; deny on drift, host-bound override, expiry, replay, or a bad MAC
request_authorizationruntimeMint a receipt bound to a scoped reference tool, or return HITL / structured deny
place_equity_orderreferenceSynthetic equity order — scope trading:equities:place, receipt required
wire_treasury_transferreferenceSynthetic treasury wire — scope treasury:wire, always HITL
rebalance_portfolioreferenceSynthetic rebalance — scope portfolio:rebalance
inspect_audit_ledgerledgerTrailing CHP-chained deny / authorize / execute entries
chp_content_hashcontentHashFloat-aware canonical SHA-256
chp_versionServer + protocol versions + deny reason codes + receipt schema

Example — evaluate a spend

// tools/call evaluate_spend_gate
{
  "action": { "action": "LONG", "asset": "ETH", "notional": 300, "confidence": 0.9 },
  "policy": {
    "max_notional": 500,
    "daily_cap": 2500,
    "hitl_threshold": 250,
    "min_confidence": 0.55,
    "allowed_actions": ["LONG", "SHORT"]
  }
}

Cookbook — Claude / Cursor tool approval

Managed MCP allowlists (Cursor mcpServers, Claude Desktop, Claude Code) only answer “is this tool name installed?”. They do not bind tenant, arguments, risk, or a human decision. This server treats that gap as a hard deny unless a signed approval receipt still matches the call that is about to run.

Receipts are HMAC-SHA256 over CHP canonical JSON (the same payload discipline as Profile B contentHash / audit-ledger sig). The MAC covers:

FieldRole
actorHuman who allowed or denied
toolConcrete tool name (no *)
resourceTenant / resource binding (no *)
args_hashcontentHash(host ∪ model arguments, { floatAware: true })
policy_versionPolicy the human saw
riskPolicy risk for that tool
issued_at / expiryLifetime
decisionallow or deny
nonceSingle-use; replay denies
signatureHMAC-SHA256 hex

Set CHP_RECEIPT_KEY (or AUDIT_LEDGER_KEY) in the MCP server env. Without it the process falls back to a documented insecure default — fine for the local cookbook, not for production.

Example policy: examples/tool-approval-policy.json. stripe.create_charge is on the allowlist and still cannot run without a receipt bound to acct_live_acme and the exact charge args. Host-injected tenant/index bindings use examples/host-injected-policy.json (see the host-injected args cookbook below).

{
  "mcpServers": {
    "chp": {
      "command": "npx",
      "args": ["-y", "@cubiczan/chp-mcp"],
      "env": { "CHP_RECEIPT_KEY": "replace-me" }
    }
  }
}

1. Allowlist alone — denied

Claude/Cursor has stripe.create_charge enabled. That is not a grant.

// tools/call evaluate_tool_approval
{
  "call": {
    "tool": "stripe.create_charge",
    "resource": "acct_live_acme",
    "arguments": { "amount": 2500, "currency": "usd", "customer": "cus_123" }
  },
  "policy": { "$ref": "examples/tool-approval-policy.json" }
}

Result: RECEIPT_REQUIRED, deny_code: "allowlist_is_not_authorization".

Calling authorize_tool_call with the same payload and no receipt returns DENIED / allowlist_is_not_authorization.

2. Human allow — then authorize

// tools/call issue_approval_receipt
{
  "actor": "cfo@acme.example",
  "decision": "allow",
  "reason": "invoice INV-104 matches amount",
  "ttl_seconds": 120,
  "call": { /* same as above */ },
  "policy": { /* same as above */ }
}

The response includes receipt (take the whole object) and decision_log (actor, decision, args hash, nonce). Pass that receipt into authorize_tool_call with the same call. Result: AUTHORIZED.

3. Human deny

Issue with "decision": "deny". The decision is logged. Authorizing with that receipt returns DENIED / human_denied. A deny receipt cannot be flipped to allow by editing decision — the MAC breaks.

4. Changed arguments after approval — denied

Approve { "amount": 2500, ... }, then authorize with { "amount": 2500000, ... }. Result: DENIED / changed_arguments. Key order does not matter; the hash is CHP canonical. The original receipt remains valid for the args that were approved (until expiry or a successful consume).

5. Expired receipt — denied

Issue with ttl_seconds: 30. After the expiry instant, authorize_tool_call returns DENIED / expired_receipt. The nonce is consumed so a clock rewind cannot resurrect it.

6. Replayed receipt — denied

A successful AUTHORIZED consume burns the nonce. Presenting the same receipt again returns DENIED / replayed_receipt.

7. Ambiguity — denied

These never produce a usable allow receipt:

  • resource: "*", any, all, or an empty string
  • missing arguments
  • policy without a concrete version
  • actor / tool wildcards
  • extra keys on a receipt (strict parse)

Fail-closed: deny_on_ambiguity cannot be turned off.

Cookbook — host-injected args + gateway _meta

Semantic Kernel and other hosts need to pass index, key, and tenant without letting the model choose them (SO-style routing). Putting those fields on the tool schema so the LLM can “decide” is the bug. An MCP allowlist does not fix it: the tool name can stay allowlisted while the model swaps index_name to another tenant.

The host (or a gateway in front of this server) injects bound fields. This package hashes host ∪ model arguments into the receipt and denies when the model overrides a host-bound field. The allowlist is still only a pre-filter.

Contract — _meta.cubiczan (no hard dependency)

@cubiczan/governed-mcp-gateway already injects identity on every tools/call and SSE frame:

{
  "_meta": {
    "cubiczan": {
      "principal": {
        "id": "agt_search",
        "kind": "agent",
        "orgId": "org_acme",
        "displayName": "Search Runner"
      }
    }
  }
}

This server does not import that package. It reads the same envelope. Hosts MAY add host_bound next to principal. A trusted gateway should overwrite _meta.cubiczan so the model cannot self-attest.

{
  "_meta": {
    "cubiczan": {
      "principal": { "id": "agt_search", "kind": "agent", "orgId": "org_acme" },
      "host_bound": { "tenant_id": "acme", "index_name": "prod-docs" }
    }
  }
}

Library callers can also pass host_bound on the proposed call (explicit keys overlay _meta). Policy examples/host-injected-policy.json declares host_bound_fields so index_name and tenant_id must be host-injected and concrete. If tenant_id is declared and omitted, _meta.cubiczan.principal.orgId may fill it.

model args ──┐
             ├─ override check ─→ deny host_bound_override
host_bound ──┘         │
                       ▼
              merged args → args_hash → receipt MAC
                       │
allowlist ──── pre-filter only (never a grant)

1. Host injects index + tenant — allowlist still denied

The model chose query / top_k. The host chose the index.

// tools/call evaluate_tool_approval
{
  "call": {
    "tool": "search.azure_ai",
    "resource": "tenant:acme",
    "arguments": { "query": "Q3 revenue", "top_k": 5 },
    "_meta": {
      "cubiczan": {
        "principal": { "id": "agt_search", "kind": "agent", "orgId": "org_acme" },
        "host_bound": { "tenant_id": "acme", "index_name": "prod-docs" }
      }
    }
  },
  "policy": { "$ref": "examples/host-injected-policy.json" }
}

Result: RECEIPT_REQUIRED, deny_code: "allowlist_is_not_authorization". args_hash is contentHash of { query, top_k, tenant_id, index_name } — not the model object alone.

2. Model changes a host-bound field — denied

Same host _meta, but the model adds "index_name": "other-index".

"arguments": { "query": "Q3 revenue", "top_k": 5, "index_name": "other-index" }

evaluate_tool_approval, issue_approval_receipt (decision: "allow"), and authorize_tool_call all return DENIED / host_bound_override. Matching the host value is not an override.

3. Human allow — then authorize the merged args

Issue a receipt for the host-injected call. Authorize with the same arguments and _meta. Result: AUTHORIZED. Change query after approve → changed_arguments. Change index_name in model args → host_bound_override. Omit declared host fields → ambiguous.

4. Semantic Kernel / host wiring

Do the routing in the host, not the model: disable auto-invoke, then inject index/tenant (or put a gateway in front that writes _meta.cubiczan.host_bound) before evaluate_tool_approval / authorize_tool_call. Secrets such as API keys belong in the host or the gateway vault — not in the tool schema the LLM sees.

Cookbook — deny telemetry and receipts

MCP denials are usually a bare error string. That string is gone when the client disconnects. This server treats a refuse as a structured event that must hit a CHP-signed ledger before the caller sees it.

Finance tools (place_equity_order, wire_treasury_transfer, rebalance_portfolio) are synthetic — no live venue or bank rail — and use a separate kind: "authorization" receipt bound to tool, scope, and args hash. That is not the same object as a chp.tool_approval_receipt.

Reason codes

CodeWhen
policy_denyHard CHP rule failed (max_notional, daily cap, …)
expiredReceipt expires_at is in the past
replayReceipt already consumed by a successful execute
args_changedTool, scope, or args hash no longer matches the receipt
missing_receiptNo receipt, or the content hash does not verify
ambiguous_policyUnknown tool, scope mismatch, or incomplete policy

Signing is the existing Profile B primitives: contentHash on the receipt / ledger payload, chainHash between ledger rows. Set CHP_AUDIT_LEDGER to a JSONL path (default ./data/chp-audit.jsonl), or :memory: for tests.

1. Request a bound receipt

Under the HITL threshold the gate auto-locks and mints a receipt. At or above it, pass approver (or call approve_spend with tool + bound_args).

// tools/call request_authorization
{
  "tool": "place_equity_order",
  "args": {
    "symbol": "AAPL",
    "side": "BUY",
    "quantity": 10,
    "notional": 300,
    "confidence": 0.9
  },
  "approver": "cfo@example.com"
}

Treasury wires use hitl_threshold: 0. A request without approver returns HITL_REQUIRED and no receipt — that is the approval gate, not a weather-API demo.

2. Execute only with that receipt

receipt is optional on the wire so a missing token is a logged missing_receipt deny, not a schema 400 that never hits the ledger.

// tools/call place_equity_order
{
  "symbol": "AAPL",
  "side": "BUY",
  "quantity": 10,
  "notional": 300,
  "confidence": 0.9,
  "receipt": { "kind": "authorization", "receipt_id": "…", "content_hash": "…" }
}

Change notional or quantity after approve → args_changed, and the ledger has the deny. Call again with the same receipt → replay. Call with no receipt → missing_receipt. All three are durable.

3. Inspect the chain

// tools/call inspect_audit_ledger
{ "limit": 20 }

Each row carries content_hash and sig = chainHash(prev_sig, { seq, ts, event, content_hash }). chain.ok is false if anyone rewrote history.

Tests

npm test

This Cubiczan mirror may omit GitHub Actions; run the suite locally. npm test builds, then runs node --test dist/*.test.js (approval receipts + host-injected bindings) and node --import tsx --test test/**/*.test.ts (deny ledger). Invariants covered: an unlogged deny is impossible (ledger failure throws instead of returning a deny object); changed args after approve deny; a receipt is required for every gated reference tool; allowlist is not authorization; host-bound tenant/index cannot be overridden by the model; receipt args_hash covers host ∪ model args.

Related

Package / repoRole
@cubiczan/chpProfile B library (this server’s dependency)
consensus-hardening-protocolProfile A + normative spec
@cubiczan/agent-conductorFull MCP: contracts, skills, Profile A gates
@cubiczan/governed-mcp-gatewayHTTP MCP control plane
@cubiczan/codesentinel-mcpCodebase health MCP
cubiczan-resilience / @cubiczan/resilienceShared retry / timeout / audit primitives

Licence

MIT.