You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
MCP: aggregate_records silently drops an aggregation entry's filter, so a conditional count answers the unconditional total (REST honours it) #28550
QA-source: #28545 · ai.mcp-stdio-fail-closed · observed beside the clauses
The MCP aggregate_records tool accepts a per-aggregation filter and throws it away, so the answer is computed over every row the caller can see. The REST query door honours the same key. An agent asking "how many active projects?" through MCP gets the total project count, with no error and no warning. Found by an off-clause probe and reproduced twice, on the HTTP MCP door. The stdio transport registers the same tool from the same file.
Reproduction (stock showcase at eeec88d1; an admin API key)
POST /api/v1/mcptools/callaggregate_records with {"objectName":"showcase_project","aggregations":[{"function":"count","alias":"n","filter":{"status":"active"}},{"function":"count","alias":"all"}]} → rows: [{"n":5,"all":5}].
POST /api/v1/data/showcase_project/query with the same aggregation body → records: [{"n":2,"all":5}].
An unknown key in an aggregation entry (for example "bogusKey":1) is accepted silently too.
Expected: MCP honours the aggregation's filter as REST and the engine do, or refuses the unknown key by name, as strictToolInput already does for unknown top-level keys. Actual: the key is stripped and the unconditional aggregate is served.
Not a leak: with a key whose holder cannot read budget, a per-aggregation filter on budget was dropped the same way (MCP answered the unfiltered count), while REST refused the predicate with 403 PERMISSION_DENIED. The dropped filter reveals nothing, but MCP and REST give different answers to the same question.
Mechanism
packages/mcp/src/mcp-http-tools.ts, around lines 1181–1190. Each aggregation entry is a non-strict z.object({ function, field, alias }), so Zod strips filter (and any other unknown key) before the call reaches the engine. strictToolInput guards only the tool's top-level keys.
Read fresh, and reproduced twice. MCP aggregate_records strips a per-aggregation filter, so an agent gets the unconditional count with no error. REST answers the same question correctly. A wrong answer served to AI callers in silence is graded p2.
The fix is the card's: honour the filter, or refuse an unknown key by name. Strict entry objects close both. The test pins MCP and REST giving one answer, and covers the permission-refused field case.
Claim: PM loop round 4 · card thread · 2026-11-01T06:43Z
Session: session_0186vfjHHMsRXC9EUT9hTFMo (the seat); the card thread is session_01FEkQ5kNo4JeL2JoFcPq4SH
Account: os-project-manager (the seat's linked user; the card's assignee)
Branch: claude/project-thread-rgk04t (the card thread's harness-pinned branch; it names no issue, so this claim is the identity record)
Worktree: the card thread's own container checkout
Domain: domain:server
Seat: domain:server#2
Ruling-ref: the maintainer, 2026-11-01T03:46Z: 「server 车道现在就是你负责,任务有一大堆啊,都可以派,包括 p3」, with 2026-10-26T10:29Z 「p1 p2 处理完了,就可以处理 p3」. Also the maintainer, 2026-11-01T06:40Z: 「任务很多啊 is:issue state:open label:pm:queue label:domain:server」. Triage 6554150040: "honour the filter, or refuse an unknown key by name. Strict entry objects close both. The test pins MCP and REST giving one answer, and covers the permission-refused field case." ⛔ No second filter evaluator in packages/mcp.
File surface: packages/mcp/src/mcp-http-tools.ts (the aggregate_records aggregation entry schema), the shared REST aggregation entry schema if reused, tests, and a changeset.
Container & model: M, mode:thread, model: opus (--tier names no path mandate: PM's call, default), effort: high
Clause-②: yes (widening) — the aggregation entry accepts filter. It also refuses an unknown entry key, which is a narrowing, so a contract review at CONTRACT_REVIEW_TIER is owed before the queue. The dev confirms the arm against clause2-line.mjs.
Metadata-protocol: none (expected; the dev measures and declares)
Responsibility: the code is the MCP tool layer's aggregate_records input · the platform path is the engine aggregate both MCP and REST call · reached by an AI agent asking a conditional count over MCP
Thread-read: none
Premise: holds (read 2026-11-01T06:43Z at origin/main: the aggregation entry is a non-strict z.object({ function, field, alias }), so filter is stripped); the dev re-measures first
Serial constraints cleared: none; no open PR touches packages/mcp/src/mcp-http-tools.ts at this read
Resume-from: none — fresh from origin/main
State: → pm:dispatched, assigned to os-project-manager, recorded by this claim.
domain:server#2 · os-project-manager · session_01FEkQ5kNo4JeL2JoFcPq4SH
os-dev-report
{
"issue": 28550,
"status": "done",
"branch": "claude/project-thread-rgk04t",
"pr": "#28600",
"session": "session_01FEkQ5kNo4JeL2JoFcPq4SH",
"premise_still_valid": true,
"summary": "Premise held at origin/main a2e7556: the aggregate_records entry was a non-strict z.object({ function, field, alias }), so an entry filter and any unknown entry key were stripped before the handler (measured: MCP {n:5,all:5} vs REST {n:2,all:5} on the showcase). The entry is now built from AggregationNodeSchema.shape (function, field, alias, filter — the node the REST door parses through FindDataRequestSchema) and closed through strictToolInput, so filter is forwarded unchanged to engine.aggregate on both transports (wireBridgeTools is the one composition) and an undeclared entry key is refused by name. No filter evaluation was added in packages/mcp; the permission case comes from the engine path (MCP and REST both answer 403 PERMISSION_DENIED for a filter on an unreadable field).",
"tests": "Unit packages/mcp/src/mcp-http-tools.aggregation-entry.test.ts 9/9 at head; against base source 7 failed / 2 controls passed. Dogfood packages/qa/dogfood/test/showcase-mcp-aggregate-filter.dogfood.test.ts 5/5 at head; against an mcp dist built from base source (ablation-dist-preflight --absent on marker, then restored, rebuilt, preflight present) 4 failed / control passed. Ablations via scripts/ablation-replace.mjs, each restored to blob 72a5e1b6fd32 == HEAD with git diff HEAD empty: filter member renamed away -> 3 red / 6 green; entry made non-strict -> 4 red / 5 green (unit file imports src, no build between). pnpm --filter @objectstack/mcp typecheck exit 0; pnpm --filter @objectstack/mcp test: Test Files 48 passed (48), Tests 612 passed (612). Head 3284b2f.",
"gates": "dispatch-gates --commands: 63 families run, exit captured before pipes; --ran: 63 run, 0 NOT-MEASURED. Re-reads after container prerequisites: check-plugin-teardown-shape --self-test exit 3 -> fetch 621a487 -> exit 0; check:dual-build-cjs-loads exit 3 (PREREQUISITE NOT MET, seven untouched packages without dist) -> built them -> exit 0. Hand gates: check-platform-checklist --changed-from origin/main exit 0; check-commit-card-trailers --range origin/main..HEAD exit 0; eslint --no-inline-config packages/mcp + the new dogfood file exit 0. CI families: in_progress at report time, read off PR #28600 check-runs by the seat.",
"dogfood_battery": "3284b2fd · os-verify-lock VERDICT command-exit 0 · Test Files 365 passed | 3 skipped (368) · Tests 2939 passed | 28 skipped (2967) · no failure",
"mcp_calls": "0",
"api_writes": "2 — relay pr_create (POST repos/objectstack-ai/objectstack/pulls via fleet-write run 41285522645, PR #28600, body read back byte-identical, no assignees, no labels); relay os-dev-report comment on #28550 via post-stamped.mjs. git push of the branch is not a REST write.",
"open_questions": [],
"out_of_scope_findings": [
"carrier: none · noted in PR Acceptance notes, not filed: the REST query door still strips an unknown aggregation-entry key (AggregationNodeSchema is non-strict), so MCP is now the stricter door on that one point; no wrong answer results, the order kept REST unchanged."
]
}
Landed (Fixes): merged as 20dcc075. Each aggregate_records aggregation entry is now the spec's AggregationNodeSchema members, closed. A per-aggregation filter reaches the engine on both MCP transports, so MCP and REST give one answer. A filter on a field the caller cannot read answers 403 PERMISSION_DENIED, as REST does. Any other entry key is refused by name.
Unchanged: entries without filter, the tool's top-level aliases, and the REST query door.
Activity
objectstack-fleet commentedon Nov 1, 2026
triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U
Path: 可对外的 API | none | P②
Triage: p2 ·
bug·domain:server·area:ai·pm:queue· 2026-11-01T05:55Z⛔ Not a claim.
aggregate_recordsstrips a per-aggregationfilter, so an agent gets the unconditional count with no error. REST answers the same question correctly. A wrong answer served to AI callers in silence is graded p2.filter, or refuse an unknown key by name. Strict entry objects close both. The test pins MCP and REST giving one answer, and covers the permission-refused field case.Generated by Claude Code
objectstack-fleet commentedon Nov 1, 2026
domain:server#2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo
Claim: PM loop round 4 · card thread · 2026-11-01T06:43Z
Session:
session_0186vfjHHMsRXC9EUT9hTFMo(the seat); the card thread issession_01FEkQ5kNo4JeL2JoFcPq4SHAccount:
os-project-manager(the seat's linked user; the card's assignee)Branch:
claude/project-thread-rgk04t(the card thread's harness-pinned branch; it names no issue, so this claim is the identity record)Worktree: the card thread's own container checkout
Domain:
domain:serverSeat:
domain:server#2Ruling-ref: the maintainer, 2026-11-01T03:46Z: 「server 车道现在就是你负责,任务有一大堆啊,都可以派,包括 p3」, with 2026-10-26T10:29Z 「p1 p2 处理完了,就可以处理 p3」. Also the maintainer, 2026-11-01T06:40Z: 「任务很多啊 is:issue state:open label:pm:queue label:domain:server」. Triage
6554150040: "honour thefilter, or refuse an unknown key by name. Strict entry objects close both. The test pins MCP and REST giving one answer, and covers the permission-refused field case." ⛔ No second filter evaluator inpackages/mcp.File surface:
packages/mcp/src/mcp-http-tools.ts(theaggregate_recordsaggregation entry schema), the shared REST aggregation entry schema if reused, tests, and a changeset.Container & model:
M,mode:thread,model: opus(--tiernames no path mandate: PM's call, default),effort: highClause-②: yes (widening) — the aggregation entry accepts
filter. It also refuses an unknown entry key, which is a narrowing, so a contract review atCONTRACT_REVIEW_TIERis owed before the queue. The dev confirms the arm againstclause2-line.mjs.Metadata-protocol: none (expected; the dev measures and declares)
Responsibility: the code is the MCP tool layer's
aggregate_recordsinput · the platform path is the engine aggregate both MCP and REST call · reached by an AI agent asking a conditional count over MCPThread-read: none
Premise: holds (read 2026-11-01T06:43Z at
origin/main: the aggregation entry is a non-strictz.object({ function, field, alias }), sofilteris stripped); the dev re-measures firstSerial constraints cleared: none; no open PR touches
packages/mcp/src/mcp-http-tools.tsat this readResume-from: none — fresh from
origin/mainState: →
pm:dispatched, assigned toos-project-manager, recorded by this claim.Generated by Claude Code
objectstack-fleet commentedon Nov 1, 2026
domain:server#2 · os-project-manager · session_01FEkQ5kNo4JeL2JoFcPq4SH
os-dev-report
{
"issue": 28550,
"status": "done",
"branch": "claude/project-thread-rgk04t",
"pr": "#28600",
"session": "session_01FEkQ5kNo4JeL2JoFcPq4SH",
"premise_still_valid": true,
"summary": "Premise held at origin/main a2e7556: the aggregate_records entry was a non-strict z.object({ function, field, alias }), so an entry filter and any unknown entry key were stripped before the handler (measured: MCP {n:5,all:5} vs REST {n:2,all:5} on the showcase). The entry is now built from AggregationNodeSchema.shape (function, field, alias, filter — the node the REST door parses through FindDataRequestSchema) and closed through strictToolInput, so filter is forwarded unchanged to engine.aggregate on both transports (wireBridgeTools is the one composition) and an undeclared entry key is refused by name. No filter evaluation was added in packages/mcp; the permission case comes from the engine path (MCP and REST both answer 403 PERMISSION_DENIED for a filter on an unreadable field).",
"tests": "Unit packages/mcp/src/mcp-http-tools.aggregation-entry.test.ts 9/9 at head; against base source 7 failed / 2 controls passed. Dogfood packages/qa/dogfood/test/showcase-mcp-aggregate-filter.dogfood.test.ts 5/5 at head; against an mcp dist built from base source (ablation-dist-preflight --absent on marker, then restored, rebuilt, preflight present) 4 failed / control passed. Ablations via scripts/ablation-replace.mjs, each restored to blob 72a5e1b6fd32 == HEAD with git diff HEAD empty: filter member renamed away -> 3 red / 6 green; entry made non-strict -> 4 red / 5 green (unit file imports src, no build between). pnpm --filter @objectstack/mcp typecheck exit 0; pnpm --filter @objectstack/mcp test: Test Files 48 passed (48), Tests 612 passed (612). Head 3284b2f.",
"gates": "dispatch-gates --commands: 63 families run, exit captured before pipes; --ran: 63 run, 0 NOT-MEASURED. Re-reads after container prerequisites: check-plugin-teardown-shape --self-test exit 3 -> fetch 621a487 -> exit 0; check:dual-build-cjs-loads exit 3 (PREREQUISITE NOT MET, seven untouched packages without dist) -> built them -> exit 0. Hand gates: check-platform-checklist --changed-from origin/main exit 0; check-commit-card-trailers --range origin/main..HEAD exit 0; eslint --no-inline-config packages/mcp + the new dogfood file exit 0. CI families: in_progress at report time, read off PR #28600 check-runs by the seat.",
"dogfood_battery": "3284b2fd · os-verify-lock VERDICT command-exit 0 · Test Files 365 passed | 3 skipped (368) · Tests 2939 passed | 28 skipped (2967) · no failure",
"mcp_calls": "0",
"api_writes": "2 — relay pr_create (POST repos/objectstack-ai/objectstack/pulls via fleet-write run 41285522645, PR #28600, body read back byte-identical, no assignees, no labels); relay os-dev-report comment on #28550 via post-stamped.mjs. git push of the branch is not a REST write.",
"open_questions": [],
"out_of_scope_findings": [
"carrier: none · noted in PR Acceptance notes, not filed: the REST query door still strips an unknown aggregation-entry key (AggregationNodeSchema is non-strict), so MCP is now the stricter door on that one point; no wrong answer results, the order kept REST unchanged."
]
}
Generated by Claude Code
objectstack-fleet commentedon Nov 1, 2026
domain:server#2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo
Landing record · #28550 · PR #28600
domain:serverseat 2 ·session_0186vfjHHMsRXC9EUT9hTFMo· 2026-11-01T08:13Zgit ls-remote origin 'refs/heads/gh-readonly-queue/main/pr-28600-*'git merge-base --is-ancestor 20dcc075 origin/maingit grep -c aggregationEntrySchema origin/main -- packages/mcp/src/mcp-http-tools.ts20dcc075. Eachaggregate_recordsaggregation entry is now the spec'sAggregationNodeSchemamembers, closed. A per-aggregationfilterreaches the engine on both MCP transports, so MCP and REST give one answer. A filter on a field the caller cannot read answers 403PERMISSION_DENIED, as REST does. Any other entry key is refused by name.filter, the tool's top-level aliases, and the REST query door.Release: claim
6554573354(session_0186vfjHHMsRXC9EUT9hTFMo, branchclaude/project-thread-rgk04t) · landed.pm:dispatchedremoved; assignee cleared; the card closed on merge.Generated by Claude Code