## Thinking Path > - Paperclip manages AI agents and their work. > - Core resolves tool profiles before it prepares runtime MCP assignments. > - A selected profile can contain a rule for each individual tool. > - The resolver and profile summary scan these rules for each catalog row. > - This repeats millions of exact comparisons for a large selected profile. > - This pull request indexes each selector kind once per computation. > - The benefit is less server CPU with the same current access decisions and result contract. ## Linked Issues or Issue Description **What existing behavior does this improve?** Effective tool assignment and profile summary computation for large selected profiles. **Current behavior** Each catalog row scans the profile's includes and excludes. The work grows with catalog size times rule count. The runtime assignment path also computes a profile summary with the same scans. **Proposed behavior** Build five selector sets for each include or exclude group. Match catalog rows with exact set membership. Retain all current database reads and the full result contract. **Reason and benefit** Reduce repeated JavaScript comparisons. On the synthetic PostgreSQL fixture below, median process CPU per resolution falls from 50.068 ms to 10.129 ms. This is independent of [#18371](https://github.com/paperclipai/paperclip/pull/18371), which removes unused administrative results and narrows instruction descriptors. This PR retains those results and does not include that change. ## What Changed - Index application, connection, catalog-entry, tool-name, and risk selectors in the profile owner. - Use indexed matching in effective access resolution and profile summaries. - Add a differential test against the original single-rule matcher. Cover null applications, exact special-character names, unknown selectors, and empty rule groups. - Add a PostgreSQL regression for per-profile exclusion precedence, the union of multiple effective profiles, and current rule removal. - Add a real PostgreSQL resolver benchmark and document reproduction and limits. ## Verification Baseline: `410b0e27015495ab8951768b5b0b94727de33785`. Treatment: `895ee2eec15cf4cdedee4c257fcdddb2a7000a1b`. Node 24.22.2 on macOS arm64. Source modules run with `tsx`; these are local service measurements, not production-build or hosted acceptance. The real owning resolver ran against disposable PostgreSQL with 1,890 active catalog tools, 1,575 individual include/exclude rules, one deny-by-default effective profile, and 12 agents. The installation dependency returns an empty list because the fixture has no installed connections. There are no provider calls. Full returned assignments, profile summaries, rules, bindings, and names are identical to baseline. After two warmups per version, seven alternating samples ran ten resolutions each. Other workers paused CPU measurements and builds. Unrelated host work was not controlled. | Per resolution | Baseline median (range) | Change median (range) | | --- | --- | --- | | Process CPU | 50.068 ms (46.380–58.880) | 10.129 ms (8.089–11.072) | | Elapsed time | 50.362 ms (44.799–106.408) | 8.853 ms (7.719–18.590) | | Queries | 6 | 6 | | Returned database rows | 3,480 | 3,480 | Median process CPU falls 79.8%; median elapsed time falls 82.4%. The long baseline elapsed sample had no matching process CPU increase. Query and row counts do not decrease. Small or broadly allowed profiles can save less. This does not establish whole-run latency or hosted performance. Reproduce with `server/scripts/benchmark-tool-profile-matching.ts`; baseline module preparation is in `doc/mcp-discovery-performance.md`. Passed on the treatment source: ```sh node node_modules/vitest/vitest.mjs run server/src/__tests__/tool-profile-matching.test.ts server/src/__tests__/tool-effective-assignments.test.ts server/src/services/native-runtime/runtime-context.test.ts server/src/__tests__/heartbeat-runtime-mcp-servers.test.ts ``` All 45 tests in four suites passed. `git diff --check` passed. The first PostgreSQL test attempt failed at setup because the sandbox blocked its loopback listener (`EPERM`); the authorized unsandboxed retry passed. No behavior failure was discarded. At measured head `895ee2eec15cf4cdedee4c257fcdddb2a7000a1b`, CI typecheck, build, canary, Runner checks, and all eight browser shards passed. Server general shard 8/12 failed an unchanged background-quietness assertion in `heartbeat-run-dispatch-work.test.ts`: expected 235 select calls, observed 236 after advancing fake timers. Its cause remains unverified; no baseline reproduction was run. That head also failed the module-boundary policy because the benchmark inserted synthetic agents outside the test fixture tree. Follow-up head `e20165b6eb` moves the fixture body byte-for-byte into `server/src/__tests__/helpers/tool-profile-matching-fixture.ts`, following the existing listing benchmark pattern. The production matcher and benchmark measurement loop are unchanged. `node scripts/check-module-boundaries.mjs`, a Node 24/tsx fixture import check, and `git diff --check` pass. The benchmark was not rerun for this extraction. New-head CI and review are pending. Greptile gave the measured head 5/5 with no actionable issues. No deployment or live runtime mutation was performed. ## Risks Exact equality must remain identical to the original matcher, especially for null application selectors and exclusions. The differential and PostgreSQL tests cover these cases. Sets add linear temporary memory in rule count. They live only for the current computation. Current company-scoped state reads, profile precedence, revocation, and runtime assignment digest checks remain in place. Revert this commit to roll back. ## Model Used OpenAI GPT-6.1 Sol (`gpt-6.1-sol`) with source inspection, code execution, tests, and local PostgreSQL benchmarking. The context window size is not available in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
8.1 KiB
MCP discovery resource bounds
Tool discovery shares policy inputs for one listing and projects context IDs in SQL. It does not load task descriptions or run results to decide access. The cache ends with the listing. Tool execution reads current policy and current rate counters.
The control-plane process admits two whole listings at a time and queues at most
32 more. Excess requests receive HTTP 503 with tool_discovery_busy. Disconnected
listing requests leave the queue or stop scheduling new decisions. Already-started
reads drain before the admission slot is released. This applies only to read-only
discovery; a disconnected tool invocation is not automatically cancelled.
Discovery audit rows retain the visible tool count and a SHA-256 digest of sorted names. They do not retain the full name list. Named gateway tokens expire normally; startup and scheduler sweeps delete at most 500 expired tokens per pass using the expiry index. Tokens with no expiry and unexpired tokens remain available. The separate access and activity audit records remain available. Audit inserts resolve the token reference atomically and lock a surviving token row for that statement. If cleanup already removed it, the reference is null and the original token ID remains in audit details. An admitted request can complete; later requests still fail authentication after expiry.
The stateless gateway and runtime-tools MCP endpoints return HTTP 405 with
Allow: POST for GET instead of returning JSON as if it were an SSE stream.
The runtime-tools endpoint still validates its caller before that response.
See the MCP transport contract.
New-tool review projection
Pending-tool review builds covered application and connection sets from each profile's include rules. An included catalog entry covers its connection and its non-null application only while that entry exists in the supplied catalog. Explicit application selectors retain null application matching. A second index records all exact selectors, including exclusions, so already reviewed tools do not appear as new. The catalog filter retains its order, status check, strict review watermark, and returned metadata.
The indexes live only for one projection. Profile listings, new-tool review, and app attention still read current company-scoped database state on each request. This replaces nested catalog-by-rule scans with linear indexing and filtering. It changes neither database query counts nor response contracts.
The PostgreSQL reproduction uses 200 selected profiles, 1,900 active tools, and 76,000 rules. Profiles alternate between 65 and 695 rules. Half reference 695 removed catalog entries, while half select current tools. This exercises both scope checks and explicit-review checks. It compares complete ordered results from the real profile listing and app-attention services before timing them.
Save baseline profiles/projection.ts, profiles/service.ts, and
catalog/attention.ts beside their current versions as .baseline-projection.ts,
.baseline-service.ts, and .baseline-attention.ts. Change the two baseline
consumers' projection imports to .baseline-projection.js with the same relative
path. Then run from server/:
node --expose-gc --import tsx scripts/benchmark-profile-review-projection.ts --baseline-profiles "$PWD/src/services/tool-access/profiles/.baseline-service.ts" --baseline-attention "$PWD/src/services/tool-access/catalog/.baseline-attention.ts"
Remove the three baseline files after the comparison. The probe alternates five samples per implementation and reports process CPU, elapsed time, actual query count, and returned rows. No provider calls or hosted traffic are generated.
Reproduce the measurements
Build workspace dependencies, then run from server/:
node --expose-gc --max-old-space-size=4096 --import tsx scripts/benchmark-tool-gateway-listing.ts --tools 50,300,900 --parallel 1,16 --repeat 3
node --expose-gc --max-old-space-size=4096 --import tsx scripts/benchmark-tool-gateway-listing.ts --tools 900 --connections 4 --parallel 16 --repeat 3
The harness uses a throwaway embedded PostgreSQL database. It makes no model or
provider calls. Each fixture has a 400 KB run snapshot, 140 KB result, approximately
5 KB input schemas, 15 KB connection configurations, one selector per catalog tool,
and 373 policies. Use --implementation /absolute/path/to/tool-gateway.ts to replay
an earlier implementation, with its corresponding policy-service import.
Verification on 2026-10-01
Node 26.4.0 on macOS; forced GC before each measurement; heap/RSS sampled every
2 ms. Baseline: f2e0f196308629ef05c7f65782243e1713b208f0.
Raw measurements retain every sample.
| Scenario | Queries | Peak extra heap | Elapsed time |
|---|---|---|---|
| Baseline, 900 tools, one listing | 11,489 | 1,680–1,698 MiB | 3.54–3.63 s |
| Fixed, 900 tools, one listing | 36 | 37–37 MiB | 0.24–0.24 s |
| Fixed, 900 tools, 16 listings | 576 total | 169–187 MiB | 3.63–3.65 s |
| Fixed, 900 tools, four connections, 16 listings | 624 total | 146–174 MiB | 3.69–3.72 s |
Before the listing optimization, the 50-versus-500-tool regression test failed with 722 versus 6,422 statements. The connection-row duplication test also failed. The follow-up regressions failed for full run-row discovery reads, abandoned requests, full name-list audit records, and expired named tokens. They pass with the fixes. HTTP coverage exercises initialize, GET/SSE rejection, 16 concurrent 500-tool listings, an allowed remote call, policy revocation after discovery, and the denied subsequent call. The remote provider response is deterministic.
The eight runnable MCP browser stories pass against a throwaway production UI and server, including governed execution and approval journeys.
These are fixture measurements, not a replay of the incident database. RSS includes module/runtime overhead and can stay high after V8 frees objects. Query counts depend on connection/profile sets; uncached rate-limit checks add queries. Catalog payload memory and policy-matching CPU still grow with the catalog. The measurements do not establish a safe production heap limit or the share of active installations affected. Verify restart bursts and sustained traffic on the actual deployment before reducing its temporary heap allowance.
Profile selector matching
Effective profile resolution and profile summaries index each profile's include and exclude selectors once per decision. Catalog rows then use exact set membership checks for application, connection, catalog entry, tool name, and risk level. Exclusions still take precedence within a profile; effective profiles still contribute the union of their allowed tools. Null application selectors retain their exact-match behavior.
These indexes live only for the current computation. Each resolver call still reads current company-scoped bindings, profiles, and rules. Query counts and returned database rows are unchanged. This reduces JavaScript matching work when a profile selects many individual tools; it does not cache authorization or remove the runtime assignment digest check.
For a real PostgreSQL comparison, save projection.ts and resolution.ts from
the baseline commit beside their current versions as .baseline-projection.ts
and .baseline-resolution.ts. Change only the baseline resolver's projection
import to ./.baseline-projection.js. Then run from server/:
node --import tsx scripts/benchmark-tool-profile-matching.ts --baseline "$PWD/src/services/tool-access/profiles/.baseline-resolution.ts"
Remove the two baseline files afterward. The probe uses 1,890 catalog tools, 1,575 individual include/exclude rules, one effective profile, and 12 agents. It checks full assignment-result equality, warms both versions, and alternates seven samples of ten resolutions. It reports actual query and returned-row counts, process CPU, and elapsed time. The installation dependency returns an empty list for this fixture, which has no installed connections. It does not measure provider work, whole-run latency, or hosted performance.