{
  "markdown": "# Aggrete\n\n[![PyPI version](https://img.shields.io/pypi/v/aggrete)](https://pypi.org/project/aggrete/)\n[![Python versions](https://img.shields.io/pypi/pyversions/aggrete)](https://pypi.org/project/aggrete/)\n[![License](https://img.shields.io/pypi/l/aggrete)](https://github.com/aggrete/aggrete/blob/main/LICENSE)\n[![Glama quality](https://glama.ai/mcp/servers/Aggrete/aggrete/badges/score.svg)](https://glama.ai/mcp/servers/Aggrete/aggrete)\n\n<p align=\"center\"><img src=\"docs/demo.gif\" alt=\"Aggrete previews a plan with the check tool, then refuses the fourth call before the upstream is contacted\" width=\"720\"></p>\n\nThe open-source proxy. Product site: https://aggrete.com. This repo is the proxy and nothing else: engine, accumulator, ingest CLI, Helm chart.\n\nAn MCP proxy that enforces a **code of conduct document** across connectors, with\nstate that accumulates per user.\n\nEvery MCP gateway on the market authorizes tool calls and logs them. None of them\nanswer the question that actually matters once an assistant can reach Glean,\nSalesforce, Slack and Drive at once: *is this call, combined with everything this\nperson has already pulled today, something the code of conduct forbids?*\n\nFour individually-authorized questions can assemble a layoff list. No guardrail\nfires, because no single question was sensitive. This proxy is the missing layer.\n\n## Install\n\n```bash\npip install aggrete            # published on PyPI\n# or with uv:\nuv tool install aggrete        # installs the aggrete CLI\nuvx aggrete --demo             # or run it without installing\n\naggrete --config proxy.config.yaml\n```\n\nOr clone this repo to get the demo, sample policy and Helm chart.\n\n\n## What the proxy does\n\n- **Try it in one command:** `aggrete --demo` (or `docker run --rm ghcr.io/aggrete/aggrete --demo`) runs the four-question walkthrough with no config, auth, or network, then drops into an interactive menu so you can try scenarios (a forbidden combination, individual pay, comparing colleagues, the prompt-injection shield, a wall, a blocked store) and watch each decision. Add the same command to an MCP client (`{\"command\": \"uvx\", \"args\": [\"aggrete\", \"--demo\"]}`) and it runs as a real, self-contained demo server: bundled mock hr/finance/ops tools governed by a bundled policy, plus the `check` and `scenarios` tools. Point it at your own servers with `--config` for the real thing.\n- Refuses forbidden calls **before** the upstream is contacted, using a YAML\n  policy and per-user memory that accumulates across calls and sessions.\n- **Ask before you act:** a built-in `check` tool dry-runs a proposed sequence of\n  calls and returns the decision, the rule, the clause and the remediation without\n  fetching anything, and `scenarios` lists things to try. Both are answered by the\n  proxy itself (disable with `builtin_tools: false`).\n- **Tamper-evident audit**: every decision is one hash-chained JSON line. Verify\n  with `aggrete-audit audit.jsonl` (breaks are reported by line number). Optionally\n  forward each row to a SIEM (Splunk/Elastic/Datadog over HTTP, or syslog) as it is\n  written, off the hot path, with `audit_forward:`.\n- **Selective tool exposure**: walls and blocks in the policy hide tools from\n  users who could never call them, so they are never listed.\n- **Output redaction**: `redact:` masks emails, SSNs, card numbers, API keys and\n  bearer tokens in results before they reach the model; hits are counted in the audit.\n- Holds the upstream credentials itself and never forwards the caller's token to\n  an upstream (confused-deputy safe).\n- **On-behalf-of credentials:** mark an upstream `per_user: true` and each caller\n  reaches it with their *own* resolved credential (from a pluggable vault or\n  token-exchange hook), so a person's individual access is carried end to end\n  instead of everyone sharing one master token. The upstream sees Sam, not a\n  shared robot account.\n- **Tool integrity:** fingerprints every upstream tool the first time it is seen\n  and flags any later change to its description or schema (a rug pull), and scans\n  descriptions for hidden instructions (tool poisoning). Alert or block, per\n  `tool_integrity:`. Deterministic, no model in the path.\n- **Rate limiting:** a per-user ceiling on tool calls per window (`rate_limit:`),\n  shared across replicas via Redis. A denial-of-wallet and abuse control.\n- **Inbound secret scanning:** scans tool arguments for credential-shaped strings\n  and blocks (or masks) them before they reach an upstream (`scan_inbound:`), so a\n  leaked key never leaves through a tool call.\n\n**Governing writes (egress).** A tool that acts on the world (create, update,\nupload, post, send, share) is classified as a write and governed as egress: any\nwrite after a session has read untrusted content is refused (the prompt-injection\nshield), and a rule can target writes only with `applies: write`. This is generic\nacross connectors, not Drive-specific. The Google Drive connector exposes governed\n`create_<folder>` tools with `--allow-write`; writes are fenced to the folder like\nreads. Classify your own connectors' write tools with `write_tools:` in the config.\n\nSee [`ROADMAP.md`](ROADMAP.md) for what is shipped, in progress, and planned,\nwith the community requests behind each item.\n\n## Run it\n\n```bash\npython -m venv .venv && .venv/bin/pip install mcp pyyaml pytest\n.venv/bin/python -m pytest tests -q     # tests generated from coc.yaml\n.venv/bin/python demo/run_demo.py       # the four-prompt sequence, end to end\n```\n\nIt first previews the plan with the built-in `check` tool, then runs it for real:\n\n```\n=== ask first: would this plan be allowed? ===\nPlan check: REFUSED.\n  1. hr__recent_joiners     [hr-personnel]  ->  allowed\n  2. finance__budget_roles  [finance-comp]  ->  allowed\n  3. ops__oncall_draft      [ops-rota]      ->  REFUSED   COC-HR-004\n     Personnel records, compensation or budget records, and operational rosters\n     may not be combined to derive ... identifiable individuals.\n     Fix: request a purpose-bound session from HR Privacy ...\n\n=== now run it for real ===\nturn 1  finance__headcount_plan   allowed\nturn 2  finance__budget_roles     allowed   (owner emails redacted)\nturn 3  hr__recent_joiners        allowed   (emails redacted)\nturn 4  ops__oncall_draft         DENIED    COC-HR-004\n```\n\nTurn 4 is denied **before the upstream call**, so the on-call data is never\nfetched. The three domains overlap on the same people, and this call would\ncomplete the forbidden set. `check` reached the same verdict without fetching\nanything. Call `aggrete__scenarios` through the proxy for more to try: individual\npay (`min_group`), comparing colleagues (`self_comparison`), the prompt-injection\nshield (`flow`), and tools hidden behind a wall or block.\n\n## The document is the source of truth\n\n`coc.yaml` holds clause text written by the clause owner, its enforcement, and its\ntests. Engineering owns the compiler, not the policy.\n\n```yaml\n- rule_id: COC-HR-004\n  clause: >\n    Personnel records, compensation or budget records, and operational rosters\n    may not be combined to derive the employment status, performance, or\n    planned departure of identifiable individuals.\n  owner: hr-privacy@example.com\n  enforce:\n    - layer: accumulation\n      action: deny\n      type: domain_join\n      domains: [hr-personnel, finance-comp, ops-rota]\n      require_entity_overlap: true\n      scope: user\n      window: 4h\n  tests:\n    - {name: four_prompt_layoff_list, expect: deny, sequence: [...]}\n```\n\nCI fails any rule without both an allow and a deny test. Clauses that compile to\nnothing are worth finding. Those are the parts of your code of conduct that were\nnever enforceable.\n\n`aggrete-lint coc.yaml --config proxy.config.yaml` catches the fail-open cases the\ntests do not: a high-severity rule that only alerts, a wall whose `until` date has\npassed, an enforce block missing a required field, and rules whose domains no tool\nis mapped to (so the rule can never fire). It exits non-zero on errors, for CI.\n\nRule types: `domain_join`, `entity_budget`, `domain_block`, `self_comparison`,\n`min_group` (a result about fewer than k people is one person's data; pay\ntransparency), `wall` (a domain open only to `allowed_users`, or closed to\n`blocked_users`, optionally `until` a date; privilege, embargoes, investigation\nsubjects). `domain_join` and `domain_block` accept the same `allowed_users`,\n`blocked_users`, `since`, `until` scoping (quiet periods). `self_comparison`\n(the requester's own record plus colleagues' records in one domain. The\nprecondition for \"how do I compare\"; decided post-call, since the colleague\nrecords have to be seen to be counted). `arg_match` decides a call from its\n*arguments*, not just its type: the same tool is fine or forbidden depending on\nwhat it is asked to do. Name tool globs in `tools:` and the argument conditions\nthat must all hold in `deny_when:` (operators: `equals`, `in`, `regex`, `gt`,\n`lt`, `exists`, `missing`).\n\n```yaml\n- rule_id: COC-DATA-010\n  clause: \"Bulk exports are limited to your own team.\"\n  enforce:\n    - type: arg_match\n      tools: [\"*__export*\"]\n      deny_when: [{arg: scope, in: [all, company]}]   # export scope=team is fine\n      action: deny\n```\n\nThe `regex` operator runs your pattern against model-supplied argument values, so\nkeep patterns simple and anchored (avoid nested quantifiers) to sidestep\ncatastrophic backtracking.\n\nThe built-in `check` tool previews `arg_match` rules too: pass an object instead\nof a bare tool name, e.g. `{\"tool\": \"crm__export\", \"args\": {\"scope\": \"all\"}}`, and\nthe dry run reports the decision without fetching anything.\n\nActions: `deny`, `alert`. Start everything at `alert`, tune against real traffic, then flip.\n\n## How it works\n\n```\nclient ──MCP──▶ proxy ──MCP──▶ hr / finance / ops connectors\n                  │\n                  ├─ pre_call   deny before fetching where already decidable\n                  ├─ post_call  extract entities, record, re-evaluate, redact\n                  └─ audit      what was handed over, not just what was asked\n```\n\n- `aggrete/policy.py`. Deterministic evaluation. No model in this path.\n- `aggrete/accumulator.py`. Per-user state, TTL'd. `MemoryStore` for tests,\n  `RedisStore` for deployment, because state must be shared across clients.\n- `aggrete/entities.py`. Pulls stable person IDs out of tool results.\n- `proxy.config.yaml`. Maps tool name patterns to the domains clauses refer to.\n\n### Remote connectors\n\nUpstreams are either local stdio processes (`command:`) or remote MCP\nservers over streamable HTTP (`url:`). The proxy holds the credential for the\nupstream; header values may reference `${ENV_VARS}` so tokens never sit in\nthe YAML. Because the end user never holds that token, the only path to the\nconnector is through the proxy.\n\n```yaml\nupstreams:\n  ops:\n    url: https://mcp.example.com/ops/mcp\n    headers:\n      Authorization: \"Bearer ${OPS_MCP_TOKEN}\"\n```\n\n`tests/test_http_upstream.py` runs the mock `ops` connector over HTTP\n(`demo/mock_server.py --transport streamable-http`) behind the proxy end to end.\n\n\n## Architecture: where the proxy lives and how the pieces connect\n\n```\n  people's assistants                 your network                          your systems\n  (Claude, Copilot, Cursor)   |                                     |\n                              |   mcp.example.com  (this proxy)     |   HR system (Workday)\n   ── HTTPS + OAuth ────────► |   Starlette, streamable HTTP        | ─► Finance (budget lines)\n                              |   identity from the token           | ─► On-call rotations\n                              |   policy: coc.yaml                  | ─► Drive, Slack, CRM ...\n                              |   state: Redis (or memory)          |   (reachable only from the proxy)\n                              |         │ writes                    |\n                              |         ▼                           |\n                              |   audit.jsonl  ◄── read only ──  Aggrete Console (live.example.com)\n                              |   coc.yaml                          HR / Legal / IT, behind SSO or basic auth\n```\n\nThree rules make this safe:\n\n1. **Only the proxy holds connector credentials.** People sign in to the proxy\n   (your IdP via `mode: jwt`, or the built-in sign-in via `mode: builtin` when\n   you have no IdP yet); the proxy signs in to the connectors. Fence the\n   connectors so they accept traffic only from the proxy host.\n2. **The console never touches the connectors.** It reads two files the proxy\n   writes, `audit.jsonl` and `coc.yaml`, on the same host or a shared volume,\n   and it changes nothing the proxy enforces. Put it behind your SSO or, at\n   minimum, HTTP basic auth; it shows who asked what.\n3. **The assistants may only talk to the proxy.** Managed client policy\n   (Claude Code managed settings, Claude Enterprise connectors, Copilot and\n   Cursor org policies) allow-lists `https://mcp.example.com/mcp` and nothing\n   else.\n\nConnecting Claude (claude.ai): Settings → Connectors → Add custom connector →\nURL `https://mcp.example.com/mcp`. Claude discovers the sign-in from the\nproxy's OAuth metadata, registers itself, and sends you to `/signin`. From then\non every question Claude asks on your behalf passes the policy.\n\nSample handbook: `samples/northwind-handbook.docx` (synthetic, tailored to the\nrule types); `coc.yaml` maps to its clauses 7.1 to 7.11 one to one (7.4 and\n7.12 are not enforceable at a data proxy). `aggrete-ingest\nsamples/northwind-handbook.docx` reproduces it. The `samples/` directory also\nhas real public-domain examples (GSA/TTS code of conduct, Indiana state\nemployee handbook); see [`samples/README.md`](samples/README.md).\n\n## Serving it to a whole company: streamable HTTP + OAuth\n\nstdio is for one laptop. For everyone else, run Aggrete as a service and let\nidentity come from the token:\n\n```bash\npython -m aggrete.proxy --config proxy.config.yaml --transport streamable-http --host 0.0.0.0 --port 8080\n```\n\nHTTP mode refuses to start without an `auth:` block. In `jwt` mode it validates\nbearer JWTs from your IdP (issuer, audience, expiry, signature via JWKS,\nrequired scopes) and derives the user from the `email` claim. Configurable\nwith `identity_claim`. Every request without a valid token is a 401 with an\nRFC 9728 `WWW-Authenticate` pointer, and the `user:` line in the config is\nignored entirely. `builtin` mode is a small OAuth server inside the proxy (dynamic client\nregistration, a sign-in page, passcodes from the environment) for teams with\nno IdP yet. `static` mode (fixed tokens) exists for development and the\ntest-suite. The accumulator keys state on the token identity, so the same\nperson hitting Aggrete from Claude Code, Claude.ai and Cursor shares one\nhistory. Which is the point.\n\nRegister it in a client as a remote MCP server at `https://<host>/mcp` with\nthe bearer token your IdP issues; keep the connectors themselves reachable\nonly from the Aggrete host.\n\n## Per-user access (on-behalf-of)\n\nBy default the proxy holds one credential per upstream and every caller shares\nit. Mark an upstream `per_user: true` and each caller instead reaches it with\ntheir *own* credential, resolved per request, so the upstream sees the actual\nperson and their individual permissions, not a shared robot account. The proxy\nstill never puts the caller's own token on the wire; it resolves a separate\ncredential through a hook you control.\n\n```yaml\nupstreams:\n  drive:\n    command: python3\n    args: [-m, aggrete.connectors.drive, --credentials, /opt/aggrete/sa.json, --root, Northwind]\n    per_user: true\n    obo:\n      # Your vault or token-exchange script. Run per (user, upstream) with\n      # AGGRETE_USER and AGGRETE_UPSTREAM in the environment; print JSON:\n      #   {\"env\": {\"GOOGLE_DELEGATED_USER\": \"sam@corp\"}, \"headers\": {...}}\n      command: [/opt/aggrete/obo.sh]\n      # ...or map users statically instead of a command:\n      # users:\n      #   sam@corp: {env: {GOOGLE_DELEGATED_USER: sam@corp}}\n```\n\nThe resolved `env` is merged into a stdio connector's environment; `headers` are\nmerged into an HTTP upstream's request headers (the per-user value wins). With no\n`obo` block, a `per_user` upstream defaults to passing the identity as\n`AGGRETE_ACTING_USER`, so a delegation-aware connector can act as them. A per-user\nupstream opens a fresh connection per call for isolation (connection pooling is a\nplanned optimization); shared upstreams keep the one long-lived session. Every\ndecision still records who the call acted as.\n\n## Inside a gateway you already run\n\nIf agentgateway, IBM ContextForge, Kong or your own gateway is already the\ncontrol plane, don't add a second one. Embed Aggrete:\n\n```python\nfrom aggrete.plugin import PolicyHook, AggreteMiddleware\n\nhook = PolicyHook(\"coc.yaml\", domains={\"hr__*\": \"hr-personnel\", \"ops__*\": \"ops-rota\"},\n                  store=RedisStore(redis_client))\n# as two calls from your plugin system\nv = hook.before(user, tool)             # v.allow, v.message (clause + remediation)\nv = hook.after(user, tool, result_text) # records entities, re-evaluates\n# or as ASGI middleware around any MCP server that answers in JSON\napp = AggreteMiddleware(app, hook, identity=lambda scope: scope[\"state\"][\"user\"])\n```\n\nIdentity is a callable over the request, so it composes with whatever auth\nthe host performs. The middleware refuses at pre-call without forwarding and\ninspects JSON tools/call results for post-call recording.\n\n## Ways to deploy\n\n| Who | How |\n|---|---|\n| One developer | `uvx aggrete --config proxy.config.yaml` (PyPI) or the `.mcp.json` in this repo |\n| A team | `docker run ghcr.io/aggrete/aggrete` with `/etc/aggrete` mounted, or `helm install aggrete deploy/helm/aggrete` (bundled Redis, JWT auth, Ingress) |\n| A company | Helm/Docker behind your IdP, then make `https://aggrete.<corp>/mcp` the *only* MCP server your assistant policies allow (Claude Code managed settings, Claude Enterprise connectors, Copilot/Cursor org policies), with connectors network-restricted to the Aggrete hosts |\n| Existing gateway | `aggrete.plugin` (above) |\n\n\n## Putting a real system behind the proxy: Google Drive\n\n`aggrete/connectors/drive.py` is a Drive upstream the proxy runs itself. How\nit is done, in the order you do it:\n\n1. **A service account, not a person.** In Google Cloud: enable the Drive API,\n   create a service account (say `aggrete-drive`), download its JSON key. The\n   proxy holds the key; nobody's personal Google login is involved, which is\n   what makes the proxy the only road.\n2. **Share the folders, read only.** In Drive, create a root folder (say\n   `Northwind`) with one subfolder per kind of material (`Restructuring plan`,\n   `Legal hold`, `Team documents`) and share the root with the service account\n   email as **Viewer**. Service accounts own nothing; they only see what is\n   shared with them.\n3. **One tool pair per folder.** The connector lists the root's subfolders and\n   exposes `search_<folder>` and `read_<folder>` for each, so the policy can\n   name folders:\n   ```yaml\n   upstreams:\n     drive: {command: python3, args: [-m, aggrete.connectors.drive, --credentials, /opt/aggrete/drive-sa.json, --root, Northwind]}\n   domains:\n     \"drive__*_restructuring_plan\": restructuring-plan   # clause 7.9: embargo until announced\n     \"drive__*_legal_hold\": legal-hold                    # clause 7.3: never for assistants\n     \"drive__*\": drive-general\n   ```\n4. **Results name people.** Every file comes back with `owner_email` and\n   `editor_email`, so the policy's tallies and joins work on Drive results\n   like on HR records.\n5. **Remove the direct road.** Disable the assistant's native Drive connector\n   for governed accounts (Claude Enterprise: managed connectors; personal\n   accounts: remove it). Otherwise the assistant has two ways to Drive and the\n   policy only sees one.\n\n`python -m aggrete.connectors.drive --credentials sa.json --root Northwind --list`\nprints the tools that will be exposed. If the root is not shared yet the\nconnector still starts and exposes a single `status` tool that says what is\nmissing, so the proxy never fails to boot because of Drive.\n\n## Building your own connector\n\nDrive is the reference; the pattern is general. A connector is just an MCP\nserver, and the proxy governs any MCP server, so putting a new system behind the\nproxy is: expose read tools, name write tools with a write verb, and map the\ntools to a policy domain.\n\n`aggrete/connectors/base.py` removes the boilerplate:\n\n```python\nfrom aggrete.connectors.base import Connector\n\nc = Connector(\"crm\")\n\n@c.read(\"search_accounts\", \"Search CRM accounts by name.\")\ndef search(query: str) -> str:\n    return my_crm.search(query)          # a JSON string\n\n@c.write(\"create_note\", \"Add a note to an account.\")\ndef create_note(account_id: str, text: str) -> str:\n    return my_crm.add_note(account_id, text)\n\nif __name__ == \"__main__\":\n    c.run()\n```\n\n```yaml\nupstreams:\n  crm: {command: python3, args: [my_crm_connector.py]}\ndomains:\n  \"crm__*\": crm-accounts\n```\n\n`c.write(...)` refuses a tool name with no write verb, because a mis-named write\nwould slip past egress governance. Full guide with the folder-fencing pattern\nand a copy-paste template: [docs/CONNECTORS.md](docs/CONNECTORS.md) and\n`examples/connectors/knowledgebase_connector.py`.\n\nFor teams that would rather not build and maintain their own, **Aggrete for\nteams** is where supported, certified connectors live: maintained and covered by\nsupport, with Drive shipping and Slack, GitHub, Jira, Salesforce and Workday on\nthe roadmap. The proxy and this SDK stay Apache-2.0.\n\n## Starting from the document you already have\n\n`aggrete/ingest.py` turns a code-of-conduct document into a draft `coc.yaml`:\n\n```bash\npython -m aggrete.ingest handbook.pdf --domains proxy.config.yaml -o coc.draft.yaml\n```\n\nPDFs go to the model as native document blocks; DOCX, Markdown and text as\ntext. The model proposes rules in the exact `coc.yaml` schema with clause text\nverbatim, every action forced to `alert`, and each rule's own tests are run\nthrough the real `Engine` before the file is written. A draft that fails its\ntests is rejected. Clauses no data proxy can enforce (tone, harassment,\nexpenses) are listed separately with the reason. Model set by `AGGRETE_INGEST_MODEL`. Needs `ANTHROPIC_API_KEY`\nor an `ant auth login` profile.\n\n## Purpose binding\n\nA permanent block gets routed around. `engine.grant_purpose(user, rule_id,\npurpose, ttl_s)` opens a scoped window and stamps every retrieval made under it\nwith the stated purpose. Wire it to an approval workflow owned by the clause\nowner named in the rule.\n\n## Honest limitations\n\n- **Entity extraction is the weak point.** `entities.py` works on stable IDs and\n  emails. Tune `IDENTIFIER_KEYS` against your own connectors before trusting any\n  threshold, or Layer 4 will either never fire or fire constantly.\n- **Post-call denial redacts, it does not un-fetch.** The data left the upstream.\n  Prefer rules that can be decided pre-call.\n- **stdio identity is advisory.** The user is whoever launched the process and the\n  config is user-editable. Real enforcement needs streamable HTTP with OAuth, the\n  subject taken from the token, and IdP-level blocking of direct connector grants\n  so this proxy is the only path.\n- **Aggregation cannot be solved, only narrowed.** A user who spaces requests\n  beyond the window, or paraphrases across systems this proxy doesn't front, gets\n  through. This raises the cost and creates the audit trail; it is not a ceiling.\n- **Not a gateway.** No multi-tenancy, no token vault, no HA. For production,\n  port this policy engine onto agentgateway or IBM ContextForge as a plugin\n  rather than running it as your control plane.\n\n---\n\n<sub>mcp-name: io.github.aggrete/aggrete</sub>\n",
  "bytes": 23799,
  "sha": "428361513395a01776cfaf54fe9872de6fedb6227f165c073e0497b9f8432e78",
  "repo_slug": "aggrete/aggrete",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_aggrete_aggrete_1a3db85d/readme"
}