Skip to content

Commit 06cccd3

Browse files
fix(service-messaging,mcp): the inbox list hides the raw topic; an MCP argument refusal ends at the fix (#30688)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DY5gzehJPFXRJ6RuAr8coQ1)_ domain:server#4 · os-elon-musk · session_016WV4cxrNurGFU9PsE6NwNT Part of #30126 (rows 8 and 15 land here; rows 9, 12, 13 and 16 stay on the card, see Acceptance notes) Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:收件箱「我的通知」列表不再显示 `notify` 这类原始主题 id(只剩标题、级别、时间);MCP 工具收到没声明的参数时,报错只说错在哪、该怎么写,不再追加一句给内部开发者看的历史说明。 - 为什么改:真实用户走查里,通知列表的 Topic 列读成 `notify`,读者看不懂;MCP 报错末尾「This argument used to be dropped silently…」对调用方没有用,只是噪音。 - 风险与回滚:很小。`topic` 字段本身没动,仍可搜索、可被其他视图使用;MCP 照旧拒收每个未声明参数,只是少了最后一句。回滚即撤本 PR。 - 先看:`packages/mcp/src/mcp-http-tools.ts` 的 `withoutHistorySentence` 与 `strictToolInput`;`packages/services/service-messaging/src/objects/inbox-message.object.ts` 的 `mine` 视图 `columns`。 - 两分钟验证:`pnpm --filter @objectstack/mcp exec vitest run src/mcp-http-tools.unknown-argument-keys.test.ts`,预期 13 条全绿;另有 `pnpm --filter @objectstack/service-messaging exec vitest run src/objects/inbox-message-mine-view.test.ts` 2 条全绿。 - 需要你做的:无,席位落地(非受管面)。行 9、13、16 的取舍另由席位呈决裁。 ## What changes - **Row 8, `@objectstack/service-messaging`.** `sys_inbox_message`'s `mine` list view (a person's own notifications) projected `['title', 'topic', 'severity', 'created_at']`. `topic` is the producer's machine id (`notify` from a flow's notify node, `collab.mention` from a mention) and no catalog labels it, so the column read `notify`. The view now projects `['title', 'severity', 'created_at']`. The card's suggestion was "hide it or give it a label"; there is nothing to label it from, so it is hidden. The `topic` field is unchanged and still `searchable`. - **Row 15, `@objectstack/mcp`.** Every MCP tool closes its argument shape with `strictUnknownKeyError` (`@objectstack/spec/shared`), which ends each message with the surface's `history` sentence. The MCP tools passed `UNKNOWN_ARG_HISTORY` ("This argument used to be dropped silently and the call still succeeded, …"). They now pass an empty history through a small wrapper, `withoutHistorySentence`, which trims the separator the factory leaves, so the message ends at its last fix channel. The explanation of the posture stays in the file's comments. `packages/spec` is not touched: the factory is unchanged for every authorable schema. ## Tests - `packages/services/service-messaging/src/objects/inbox-message-mine-view.test.ts` (new, 2): the `mine` view's columns are exactly title, severity, time; `topic` stays a searchable field. - `packages/mcp/src/mcp-http-tools.unknown-argument-keys.test.ts` (new top-level `describe` in the existing harness, 2): the reported `sort` call is refused with `` `sort` → `orderBy` `` and no history sentence; over every advertised tool, no refusal carries the sentence or ends in whitespace. - Ablations through `scripts/ablation-replace.mjs`, each restored with `git diff HEAD` empty: the old history sentence put back → 2 failed of 13 in the MCP file; `topic` put back in the view → 1 failed of 2. Both test files import `src`. - `@objectstack/mcp` full suite: 56 files, 680 passed. `@objectstack/service-messaging` full suite: 67 files, 847 passed. `typecheck` for both: exit 0. ## Acceptance notes - **Row 9 (mention title follows the recipient's locale): not built, `needs_decision`.** The delivery chain is the maintainer's ruling quoted in `recipient-locale.ts` (「解析链 = 收件人 `locale` → 部署默认」), which also lists a request-scoped locale as deliberately not in the chain. Since the card was filed, PR #30539 and PR #30597 save a language picked on the sign-in and register pages as the person's own when they have none. Whether a person who never picks a language should get one stamped, or whether a "last used" rung should join the chain, is a product choice; the options go to the maintainer through the seat. - **Row 12 (Setup app refusal in English): not built.** It shares `packages/rest/src/meta-item-read-gate.ts` with open PR #30459, which is still waiting on the maintainer, so per the claim's serial constraint it is left for after that lands. - **Row 13 (`os package export` `-u`/`-t` vs `os package install` `-r`/`--email`/`--password`): not built, `needs_decision`.** About twenty commands use `-u/--url` + `-t/--token`; `install` uses `-r/--runtime` with a session sign-in, and `publish` uses `-s/--server`. One way to name them means renaming a published option (breaking; this repo keeps no alias window) and, for `install`, choosing which credential kinds it takes. Options go to the maintainer. - **Row 16 (`list_actions` lists admin-only identity actions to a plain member): not built, `needs_decision`.** The listing is in `packages/runtime/src/domains/mcp.ts`, outside the claimed `packages/mcp/src/**`. The actions' admin gate lives only in each target's own mount (`/api/v1/auth/admin/*`, `plugin-auth`'s `judgePlatformAdmin`, and different gates for SSO and manager doors) and in a display-only `visible` predicate; no key the action door reads states it. Hiding or marking them means either a declared gate on the actions or a second copy of the targets' predicates in the runtime, and the two precedents (`actionPermissionError` hides, the #28178 / #28943 credential table marks `unavailable`) point different ways. Every refusal is unchanged. `invite_user` appearing four times is four objects each declaring it, which `run_action`'s `objectName` disambiguates. - Noted, not filed: the MCP refusal still carries the spec factory's upgrade-guide sentence ("Keys retired before the previous protocol major …"), which is about metadata keys rather than tool arguments. Out of row 15's ask. ## Verification <details><summary>Derived gates and suites</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `18552efc` (5 paths vs merge base `b77c160a`): 63 commands, each run with its exit code captured before any pipe. 60 exit 0 on the first pass; three exit 3 (prerequisite): - `node scripts/check-plugin-teardown-shape.mjs --self-test`: exit 0 after fetching its pinned fixture commit `621a4876` (48 cases). - `pnpm check:i18n`: exit 0 after its own named prerequisite build (CLI + the ten extract packages): "all bundles in sync, no undeclared authoring keys". - `pnpm check:dual-build-cjs-loads`: NOT MEASURED locally (needs a whole-workspace build); CI's Lint & Repo Gates runs it. - `dispatch-gates --ran`: 63 derived, 62 run, 1 NOT-MEASURED (the one above), 0 UNRUN, exit 0. Changed lines: 126 (+107 / -19, 5 files). - `pnpm --filter @objectstack/mcp typecheck`, `pnpm --filter @objectstack/service-messaging typecheck`: exit 0 (after a full build of both closures). - Suites: `@objectstack/mcp` 56 files / 680 tests passed; `@objectstack/service-messaging` 67 files / 847 tests passed. - Merged `origin/main` (`a2437b56`) before opening; the incoming commits touch neither package, so nothing was re-run. Final head `3a5b38d6`. CI check-runs: not read at the time of writing. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_016WV4cxrNurGFU9PsE6NwNT)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 71318af commit 06cccd3

5 files changed

Lines changed: 107 additions & 19 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/service-messaging': patch
3+
'@objectstack/mcp': patch
4+
---
5+
6+
fix: the inbox's own notification list no longer shows the raw topic, and an MCP argument refusal ends at the fix
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
- **`@objectstack/service-messaging`.** The `mine` list view of `sys_inbox_message` (a person's own notifications) showed a Topic column reading the producer's machine id, such as `notify` for a flow's notify node or `collab.mention` for a mention. Nothing labels those ids, so the column is gone from that view: it now shows the title, the severity and the time. The `topic` field itself is unchanged and still searchable, so a filter or another view can use it.
12+
- **`@objectstack/mcp`.** When a tool call carries an argument the tool does not declare, the refusal names the argument and the spelling to send, and now stops there. It no longer ends with the sentence "This argument used to be dropped silently and the call still succeeded, …", which explained the history of the check to internal developers and gave the caller nothing to act on. Every tool still refuses an undeclared argument exactly as before; only the closing sentence is gone.

‎packages/mcp/src/mcp-http-tools.ts‎

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -696,23 +696,25 @@ const FLOW_TRIGGER_INPUTS: [FlowTriggerOrNone, ...FlowTriggerOrNone[]] = ['none'
696696
// ── Tool arguments: an undeclared key is REFUSED, not stripped (#16913) ──────
697697

698698
/**
699-
* The one sentence every tool's refusal ends with — why the key the caller
700-
* just sent used to disappear without a word.
699+
* [#30126 row 15] The refusal says which key is wrong and what to send
700+
* instead, and stops there: no sentence on why the key used to be dropped.
701701
*
702-
* Kept identical across the eleven tools on purpose. Triage's reading of this
703-
* card was that one tool holding two postures is evidence there was never a
704-
* rule; the answer to that is one posture, stated in one sentence, on every
705-
* door — not eleven bespoke ones.
702+
* `strictUnknownKeyError` (`@objectstack/spec/shared`) closes every message
703+
* with its surface's `history` sentence. On an authorable metadata schema that
704+
* sentence tells an author why a file that used to load is now refused; here
705+
* the reader is an agent mid-call, and the history of #16913 (a mis-guessed
706+
* name used to be stripped by the SDK, so the call succeeded with a different
707+
* answer) tells it nothing it can act on. So the factory is handed an empty
708+
* history and the separator it leaves is trimmed: the message ends at its last
709+
* fix channel. The prose that explains the posture stays in this file, beside
710+
* {@link strictToolInput}, where the readers who need it look.
706711
*/
707-
// ⛔ No tracker id in this string. It is RUNTIME prose — it reaches an MCP
708-
// client, an agent transcript and any log that captures a tool error, and none
709-
// of those readers can resolve `#NNNN` (`pnpm check:doc-authoring`). The anchor
710-
// belongs in a comment, where the reader who can resolve it is already looking:
711-
// the card is #16913, and the campaign it extends is #4001.
712-
const UNKNOWN_ARG_HISTORY =
713-
'This argument used to be dropped silently and the call still succeeded, so a mis-guessed '
714-
+ 'parameter name answered a differently filtered or differently ordered set with no way for '
715-
+ 'the caller to tell.';
712+
function withoutHistorySentence(map: z.core.$ZodErrorMap): z.core.$ZodErrorMap {
713+
return (issue) => {
714+
const answer = map(issue);
715+
return typeof answer === 'string' ? answer.trimEnd() : answer;
716+
};
717+
}
716718

717719
/**
718720
* Close an MCP tool's argument shape against keys it does not declare.
@@ -786,12 +788,12 @@ function strictToolInput<T extends z.ZodRawShape>(
786788
) {
787789
return z
788790
.object(shape, {
789-
error: strictUnknownKeyError({
791+
error: withoutHistorySentence(strictUnknownKeyError({
790792
surface: options.surface,
791793
knownKeys: Object.keys(shape),
792794
aliases: options.aliases,
793-
history: UNKNOWN_ARG_HISTORY,
794-
}),
795+
history: '',
796+
})),
795797
})
796798
.strict();
797799
}

‎packages/mcp/src/mcp-http-tools.unknown-argument-keys.test.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,3 +275,39 @@ describe('MCP tool arguments — undeclared keys are refused (#16913)', () => {
275275
expect(open).toEqual([]);
276276
});
277277
});
278+
279+
// [#30126 row 15] The refusal tells the caller the fix and stops: the sentence
280+
// on why such a key used to be dropped is for this file's readers, not for an
281+
// agent mid-call.
282+
describe('MCP tool arguments — the refusal ends at the fix, with no history sentence', () => {
283+
let runtime: MCPServerRuntime;
284+
let bridge: StubBridge;
285+
286+
beforeEach(() => {
287+
runtime = new MCPServerRuntime({ name: 't', version: '1.0.0' });
288+
bridge = makeBridge();
289+
});
290+
291+
it('names the key and the spelling to send, and says nothing about the old behaviour', async () => {
292+
const { refused, text } = await callTool(runtime, bridge, 'query_records', {
293+
objectName: 'crm_opportunity',
294+
sort: '-amount',
295+
});
296+
expect(refused).toBe(true);
297+
expect(text).toContain('`sort` → `orderBy`');
298+
expect(text).not.toMatch(/used to be dropped/i);
299+
expect(text).not.toMatch(/silently/i);
300+
});
301+
302+
it('holds on every advertised tool, and no refusal ends in a dangling separator', async () => {
303+
const json = await rpc(runtime, bridge, 'tools/list');
304+
const names: string[] = (json.result?.tools ?? []).map((t: any) => t.name);
305+
expect(names.length).toBeGreaterThan(0);
306+
const offending: string[] = [];
307+
for (const name of names) {
308+
const { text } = await callTool(runtime, bridge, name, { ...VALID_ARGS[name], zzUndeclaredProbeKey: 'x' });
309+
if (/used to be dropped|silently/i.test(text) || /\s$/.test(text)) offending.push(`${name}: ${JSON.stringify(text)}`);
310+
}
311+
expect(offending).toEqual([]);
312+
});
313+
});
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#30126 row 8] The recipient's own notification list (`sys_inbox_message`'s
5+
* `mine` view) does not show the raw topic.
6+
*
7+
* `topic` is the producer's machine id — `notify` for a flow's notify node,
8+
* `collab.mention` for a mention — and no catalog gives it a label, so the
9+
* column read `notify` beside every title. The view now projects what a
10+
* recipient reads (title, severity, when); `topic` stays a field of the
11+
* object, still searchable, so a filter or another view can use it.
12+
*/
13+
14+
import { describe, expect, it } from 'vitest';
15+
import { InboxMessage } from './index.js';
16+
17+
interface ViewLike {
18+
columns?: unknown;
19+
}
20+
21+
const def = InboxMessage as unknown as {
22+
listViews?: Record<string, ViewLike>;
23+
fields: Record<string, { searchable?: boolean }>;
24+
};
25+
26+
describe('sys_inbox_message `mine` view', () => {
27+
it('projects the title, severity and time, and not the machine topic', () => {
28+
expect(def.listViews?.mine?.columns).toEqual(['title', 'severity', 'created_at']);
29+
});
30+
31+
it('keeps `topic` on the object as a searchable field', () => {
32+
expect(def.fields.topic?.searchable).toBe(true);
33+
});
34+
});

‎packages/services/service-messaging/src/objects/inbox-message.object.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,11 @@ export const InboxMessage = ObjectSchema.create({
3838
name: 'mine',
3939
label: 'Notifications',
4040
data: { provider: 'object', object: 'sys_inbox_message' },
41-
columns: ['title', 'topic', 'severity', 'created_at'],
41+
// [#30126 row 8] No `topic`: it is the producer's machine id
42+
// (`notify`, `collab.mention`), not a word a recipient reads, and
43+
// there is no catalog that labels it. The column stays on the
44+
// object (searchable, filterable); only this view stops showing it.
45+
columns: ['title', 'severity', 'created_at'],
4246
filter: [{ field: 'user_id', operator: 'equals', value: '{current_user_id}' }],
4347
sort: [{ field: 'created_at', order: 'desc' }],
4448
pagination: { pageSize: 50 },

0 commit comments

Comments
 (0)