Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/30126-inbox-topic-mcp-refusal-history.md
Original file line number Diff line number Diff line change
@@ -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.
38 changes: 20 additions & 18 deletions packages/mcp/src/mcp-http-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -786,12 +788,12 @@ function strictToolInput<T extends z.ZodRawShape>(
) {
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();
}
Expand Down
36 changes: 36 additions & 0 deletions packages/mcp/src/mcp-http-tools.unknown-argument-keys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});
});
Original file line number Diff line number Diff line change
@@ -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<string, ViewLike>;
fields: Record<string, { searchable?: boolean }>;
};

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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
Loading