diff --git a/.changeset/30126-inbox-topic-mcp-refusal-history.md b/.changeset/30126-inbox-topic-mcp-refusal-history.md new file mode 100644 index 00000000000..b36d9f0adb4 --- /dev/null +++ b/.changeset/30126-inbox-topic-mcp-refusal-history.md @@ -0,0 +1,12 @@ +--- +'@objectstack/service-messaging': patch +'@objectstack/mcp': patch +--- + +fix: the inbox's own notification list no longer shows the raw topic, and an MCP argument refusal ends at the fix + +Clause-②: no +Metadata-protocol: none + +- **`@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. +- **`@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. diff --git a/packages/mcp/src/mcp-http-tools.ts b/packages/mcp/src/mcp-http-tools.ts index 4c6c56a6671..76899f768dc 100644 --- a/packages/mcp/src/mcp-http-tools.ts +++ b/packages/mcp/src/mcp-http-tools.ts @@ -696,23 +696,25 @@ const FLOW_TRIGGER_INPUTS: [FlowTriggerOrNone, ...FlowTriggerOrNone[]] = ['none' // ── Tool arguments: an undeclared key is REFUSED, not stripped (#16913) ────── /** - * The one sentence every tool's refusal ends with — why the key the caller - * just sent used to disappear without a word. + * [#30126 row 15] The refusal says which key is wrong and what to send + * instead, and stops there: no sentence on why the key used to be dropped. * - * Kept identical across the eleven tools on purpose. Triage's reading of this - * card was that one tool holding two postures is evidence there was never a - * rule; the answer to that is one posture, stated in one sentence, on every - * door — not eleven bespoke ones. + * `strictUnknownKeyError` (`@objectstack/spec/shared`) closes every message + * with its surface's `history` sentence. On an authorable metadata schema that + * sentence tells an author why a file that used to load is now refused; here + * the reader is an agent mid-call, and the history of #16913 (a mis-guessed + * name used to be stripped by the SDK, so the call succeeded with a different + * answer) tells it nothing it can act on. So the factory is handed an empty + * history and the separator it leaves is trimmed: the message ends at its last + * fix channel. The prose that explains the posture stays in this file, beside + * {@link strictToolInput}, where the readers who need it look. */ -// ⛔ No tracker id in this string. It is RUNTIME prose — it reaches an MCP -// client, an agent transcript and any log that captures a tool error, and none -// of those readers can resolve `#NNNN` (`pnpm check:doc-authoring`). The anchor -// belongs in a comment, where the reader who can resolve it is already looking: -// the card is #16913, and the campaign it extends is #4001. -const UNKNOWN_ARG_HISTORY = - 'This argument used to be dropped silently and the call still succeeded, so a mis-guessed ' - + 'parameter name answered a differently filtered or differently ordered set with no way for ' - + 'the caller to tell.'; +function withoutHistorySentence(map: z.core.$ZodErrorMap): z.core.$ZodErrorMap { + return (issue) => { + const answer = map(issue); + return typeof answer === 'string' ? answer.trimEnd() : answer; + }; +} /** * Close an MCP tool's argument shape against keys it does not declare. @@ -786,12 +788,12 @@ function strictToolInput( ) { return z .object(shape, { - error: strictUnknownKeyError({ + error: withoutHistorySentence(strictUnknownKeyError({ surface: options.surface, knownKeys: Object.keys(shape), aliases: options.aliases, - history: UNKNOWN_ARG_HISTORY, - }), + history: '', + })), }) .strict(); } diff --git a/packages/mcp/src/mcp-http-tools.unknown-argument-keys.test.ts b/packages/mcp/src/mcp-http-tools.unknown-argument-keys.test.ts index c5af4ceb459..8431c7bca98 100644 --- a/packages/mcp/src/mcp-http-tools.unknown-argument-keys.test.ts +++ b/packages/mcp/src/mcp-http-tools.unknown-argument-keys.test.ts @@ -275,3 +275,39 @@ describe('MCP tool arguments — undeclared keys are refused (#16913)', () => { expect(open).toEqual([]); }); }); + +// [#30126 row 15] The refusal tells the caller the fix and stops: the sentence +// on why such a key used to be dropped is for this file's readers, not for an +// agent mid-call. +describe('MCP tool arguments — the refusal ends at the fix, with no history sentence', () => { + let runtime: MCPServerRuntime; + let bridge: StubBridge; + + beforeEach(() => { + runtime = new MCPServerRuntime({ name: 't', version: '1.0.0' }); + bridge = makeBridge(); + }); + + it('names the key and the spelling to send, and says nothing about the old behaviour', async () => { + const { refused, text } = await callTool(runtime, bridge, 'query_records', { + objectName: 'crm_opportunity', + sort: '-amount', + }); + expect(refused).toBe(true); + expect(text).toContain('`sort` → `orderBy`'); + expect(text).not.toMatch(/used to be dropped/i); + expect(text).not.toMatch(/silently/i); + }); + + it('holds on every advertised tool, and no refusal ends in a dangling separator', async () => { + const json = await rpc(runtime, bridge, 'tools/list'); + const names: string[] = (json.result?.tools ?? []).map((t: any) => t.name); + expect(names.length).toBeGreaterThan(0); + const offending: string[] = []; + for (const name of names) { + const { text } = await callTool(runtime, bridge, name, { ...VALID_ARGS[name], zzUndeclaredProbeKey: 'x' }); + if (/used to be dropped|silently/i.test(text) || /\s$/.test(text)) offending.push(`${name}: ${JSON.stringify(text)}`); + } + expect(offending).toEqual([]); + }); +}); diff --git a/packages/services/service-messaging/src/objects/inbox-message-mine-view.test.ts b/packages/services/service-messaging/src/objects/inbox-message-mine-view.test.ts new file mode 100644 index 00000000000..23f54cb4840 --- /dev/null +++ b/packages/services/service-messaging/src/objects/inbox-message-mine-view.test.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#30126 row 8] The recipient's own notification list (`sys_inbox_message`'s + * `mine` view) does not show the raw topic. + * + * `topic` is the producer's machine id — `notify` for a flow's notify node, + * `collab.mention` for a mention — and no catalog gives it a label, so the + * column read `notify` beside every title. The view now projects what a + * recipient reads (title, severity, when); `topic` stays a field of the + * object, still searchable, so a filter or another view can use it. + */ + +import { describe, expect, it } from 'vitest'; +import { InboxMessage } from './index.js'; + +interface ViewLike { + columns?: unknown; +} + +const def = InboxMessage as unknown as { + listViews?: Record; + fields: Record; +}; + +describe('sys_inbox_message `mine` view', () => { + it('projects the title, severity and time, and not the machine topic', () => { + expect(def.listViews?.mine?.columns).toEqual(['title', 'severity', 'created_at']); + }); + + it('keeps `topic` on the object as a searchable field', () => { + expect(def.fields.topic?.searchable).toBe(true); + }); +}); diff --git a/packages/services/service-messaging/src/objects/inbox-message.object.ts b/packages/services/service-messaging/src/objects/inbox-message.object.ts index 81a1dff8192..fb8c7c3bdc8 100644 --- a/packages/services/service-messaging/src/objects/inbox-message.object.ts +++ b/packages/services/service-messaging/src/objects/inbox-message.object.ts @@ -38,7 +38,11 @@ export const InboxMessage = ObjectSchema.create({ name: 'mine', label: 'Notifications', data: { provider: 'object', object: 'sys_inbox_message' }, - columns: ['title', 'topic', 'severity', 'created_at'], + // [#30126 row 8] No `topic`: it is the producer's machine id + // (`notify`, `collab.mention`), not a word a recipient reads, and + // there is no catalog that labels it. The column stays on the + // object (searchable, filterable); only this view stops showing it. + columns: ['title', 'severity', 'created_at'], filter: [{ field: 'user_id', operator: 'equals', value: '{current_user_id}' }], sort: [{ field: 'created_at', order: 'desc' }], pagination: { pageSize: 50 },