Skip to content

Commit 2dc8bfd

Browse files
committed
fix(tag-groups): expose readable permissions
Represent permissions as explicit group and access entries so MCP clients receive a machine-readable JSON Schema, while preserving Discourse's numeric map at the HTTP boundary. Treat blank parent tag placeholders as omitted to avoid unintended parent clearing during updates.
1 parent 502b00a commit 2dc8bfd

7 files changed

Lines changed: 149 additions & 51 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111
- Advertise output schemas and identical JSON-text fallbacks; malformed upstream records now produce normal tool errors
1212

1313
* Add the dedicated opt-in `tag_groups` lifecycle toolset
14-
- Add public Guardian-filtered search, authoritative staff list/detail, deterministic optimistic state hashes, and exact numeric permission contracts
15-
- Add guarded create, complete-state update, and hard delete with local ID/name/hash preflights, tag-creation/replacement/cascade confirmations, non-retried writes, and authoritative post-state/absence verification
14+
- Add public Guardian-filtered search, authoritative staff list/detail, deterministic optimistic state hashes, and explicit `{group_id, access}` permission entries that are machine-readable in MCP JSON Schema and converted to Discourse's numeric map only at the HTTP boundary
15+
- Add guarded create, complete-state update, and hard delete with local ID/name/hash preflights, tag-creation/replacement/cascade confirmations, non-retried writes, and authoritative post-state/absence verification; tolerate blank optional `parent_tag` placeholders as omission while reserving explicit `null` for update-time clearing
1616
- Report uncertain post-dispatch outcomes without structured success or blind-retry advice; document scoped-key, plugin-dependency, tagging-setting, and deletion-cascade limits
1717

1818
* Add top-level CLI metadata and cross-platform profile home expansion

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ Select the dedicated `tag_groups` domain for six tools:
246246

247247
1. `discourse_search_tag_groups` is public, Guardian-filtered discovery. It always sends an explicit limit and reports possible truncation. Search omits tag-group IDs, parents, and permissions, so it is not authoritative inventory; case-insensitive exact group names are the correlation key. `q` and `names` combine with AND semantics, and upstream treats `%`/`_` as SQL LIKE wildcards.
248248
2. `discourse_list_tag_groups` and `discourse_get_tag_group` require configured API credential shape plus upstream **staff** authority. The local helper cannot prove a staff role; Discourse is authoritative and privacy-preservingly returns 404 to non-staff. Reads can work when tagging is disabled.
249-
3. `discourse_create_tag_group`, `discourse_update_tag_group`, and `discourse_delete_tag_group` additionally require effective write mode and upstream `tagging_enabled`. Permissions are complete numeric maps: `1` = full, `3` = readonly, and group ID `0` = everyone. New selector names require `allow_tag_creation=true` because persistent tags are created and normal indexing/plugin hooks run.
249+
3. `discourse_create_tag_group`, `discourse_update_tag_group`, and `discourse_delete_tag_group` additionally require effective write mode and upstream `tagging_enabled`. MCP inputs and normalized outputs represent permissions as explicit entries, for example `[{"group_id":0,"access":"full"},{"group_id":9,"access":"readonly"}]`; group ID `0` is Discourse's built-in everyone group. The server converts these entries to Discourse's numeric permission map (`1` = full, `3` = readonly) only at the HTTP boundary. `parent_tag` is an optional `{id}` or `{name}` selector: omit it or use `null` when creating without a parent; blank client placeholders are treated as omitted. New selector names require `allow_tag_creation=true` because persistent tags are created and normal indexing/plugin hooks run.
250250
4. Updates require a fresh `expected_state_hash`, merge omitted fields locally, and send complete tags/parent/one-per-topic/permissions because partial upstream bodies clear state. Tag/parent removals, permission replacement, and possible materialization of serializer-synthesized everyone/full legacy permissions require explicit confirmations (including `acknowledge_possible_synthetic_permission_materialization`). The hash is an MCP optimistic precondition, not an upstream atomic lock; races can still occur after preflight.
251251
5. Deletes require exact ID/name/hash plus explicit cascade and unresolved-plugin acknowledgements. Deletion cascades memberships, permissions, and category allowed/required relationships, but does not delete tags or topic-tag rows. Plugin dependency discovery is not exhaustive. Discourse's scoped API-key action map may not authorize delete. A 200 acknowledgement is not success until a post-delete GET proves absence; uncertain post-dispatch outcomes are non-retryable `outcome_unknown` errors.
252252

src/test/tag_groups.test.ts

Lines changed: 67 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ const tags = [
1717
{ id: 3, name: "parent", slug: "parent" },
1818
];
1919

20+
function everyoneFullPermissions() {
21+
return [{ group_id: 0, access: "full" as const }];
22+
}
23+
2024
function rawGroup(overrides: Record<string, unknown> = {}) {
2125
return {
2226
id: 7,
@@ -141,7 +145,10 @@ test("staff list/get normalize exact wrappers, permissions and parent shape with
141145
assert.deepEqual(body(listed).tag_groups.map((group: any) => group.name), ["Editorial", "Zed"]);
142146
const normalized = body(listed).tag_groups[0];
143147
assert.deepEqual(normalized.parent_tag, tags[2]);
144-
assert.deepEqual(normalized.permissions, { "0": 1, "9": 3 });
148+
assert.deepEqual(normalized.permissions, [
149+
{ group_id: 0, access: "full" },
150+
{ group_id: 9, access: "readonly" },
151+
]);
145152
assert.equal(normalized.state_hash, tagGroupStateHash(normalized));
146153

147154
const detail = await api.invoke("discourse_get_tag_group", { id: 7 });
@@ -157,7 +164,7 @@ test("staff reads and writes reject missing local credentials/write mode before
157164
assert.equal((await anonymous.invoke("discourse_list_tag_groups", {})).isError, true);
158165
const readOnly = harness(true, false);
159166
assert.equal((await readOnly.invoke("discourse_create_tag_group", {
160-
name: "New", tags: [{ id: 1 }], permissions: { "0": 1 }, allow_tag_creation: false,
167+
name: "New", tags: [{ id: 1 }], permissions: everyoneFullPermissions(), allow_tag_creation: false,
161168
})).isError, true);
162169
assert.equal(mock.requests.length, 0);
163170
} finally { mock.restore(); }
@@ -188,7 +195,7 @@ test("create preflights duplicates and IDs, requires tag-creation confirmation,
188195
tags: [{ id: 1 }, { name: "brand-new" }],
189196
parent_tag: { id: 3 },
190197
one_per_topic: true,
191-
permissions: { "0": 1 },
198+
permissions: everyoneFullPermissions(),
192199
allow_tag_creation: true,
193200
});
194201
assertStructured(result);
@@ -201,7 +208,7 @@ test("create preflights duplicates and IDs, requires tag-creation confirmation,
201208
: Response.json({ tags }));
202209
try {
203210
const result = await harness().invoke("discourse_create_tag_group", {
204-
name: "Blocked", tags: [{ name: "unknown" }], permissions: { "0": 1 }, allow_tag_creation: false,
211+
name: "Blocked", tags: [{ name: "unknown" }], permissions: everyoneFullPermissions(), allow_tag_creation: false,
205212
});
206213
assert.equal(result.isError, true);
207214
assert.match(body(result).error, /allow_tag_creation/);
@@ -223,7 +230,7 @@ test("writes authoritatively resolve valid tag IDs omitted from the general tag
223230
});
224231
try {
225232
const result = await harness().invoke("discourse_create_tag_group", {
226-
name: "Unused", tags: [{ id: 42 }], permissions: { "0": 1 }, allow_tag_creation: false,
233+
name: "Unused", tags: [{ id: 42 }], permissions: everyoneFullPermissions(), allow_tag_creation: false,
227234
});
228235
assertStructured(result);
229236
assert.ok(mock.requests.some((request) => request.url.endsWith("/tag/42/info.json")));
@@ -236,7 +243,7 @@ test("create rejects case-insensitive duplicate names, unknown IDs, parent/membe
236243
: Response.json({ tags }));
237244
try {
238245
const result = await harness().invoke("discourse_create_tag_group", {
239-
name: "editorial", tags: [{ id: 1 }], permissions: { "0": 1 }, allow_tag_creation: false,
246+
name: "editorial", tags: [{ id: 1 }], permissions: everyoneFullPermissions(), allow_tag_creation: false,
240247
});
241248
assert.equal(body(result).code, "duplicate_name");
242249
} finally { duplicate.restore(); }
@@ -247,11 +254,11 @@ test("create rejects case-insensitive duplicate names, unknown IDs, parent/membe
247254
try {
248255
const api = harness();
249256
const unknown = await api.invoke("discourse_create_tag_group", {
250-
name: "Unknown", tags: [{ id: 999 }], permissions: { "0": 1 }, allow_tag_creation: false,
257+
name: "Unknown", tags: [{ id: 999 }], permissions: everyoneFullPermissions(), allow_tag_creation: false,
251258
});
252259
assert.equal(unknown.isError, true);
253260
const conflict = await api.invoke("discourse_create_tag_group", {
254-
name: "Conflict", tags: [{ id: 1 }], parent_tag: { id: 1 }, permissions: { "0": 1 }, allow_tag_creation: false,
261+
name: "Conflict", tags: [{ id: 1 }], parent_tag: { id: 1 }, permissions: everyoneFullPermissions(), allow_tag_creation: false,
255262
});
256263
assert.equal(conflict.isError, true);
257264
assert.equal(ids.requests.some((request) => request.method === "POST"), false);
@@ -265,19 +272,37 @@ test("create rejects case-insensitive duplicate names, unknown IDs, parent/membe
265272
});
266273
try {
267274
const unknownGroup = await harness().invoke("discourse_create_tag_group", {
268-
name: "Unknown group", tags: [{ id: 1 }], permissions: { "99": 1 }, allow_tag_creation: false,
275+
name: "Unknown group", tags: [{ id: 1 }], permissions: [{ group_id: 99, access: "full" }], allow_tag_creation: false,
269276
});
270277
assert.equal(unknownGroup.isError, true);
271278
assert.match(body(unknownGroup).error, /permission group IDs: 99/);
272279
assert.equal(groups.requests.some((request) => request.method === "POST"), false);
273280
} finally { groups.restore(); }
274281

275282
const createSchema = tagGroupTools.find((tool) => tool.name === "discourse_create_tag_group")!.schema;
276-
assert.equal(createSchema.safeParse({ name: "Bad", tags: [{ id: 1, name: "both" }], permissions: { "0": 1 } }).success, false);
277-
assert.equal(createSchema.safeParse({ name: "Bad", tags: [{ id: 1 }], permissions: { "0": 2 } }).success, false);
278-
assert.equal(createSchema.safeParse({ name: "Bad", tags: [{ id: 1 }], permissions: {} }).success, false);
279-
assert.equal(createSchema.safeParse({ name: "Bad", tags: [{ id: 1 }], permissions: { "01": 1 } }).success, false);
280-
assert.equal(createSchema.safeParse({ name: "Bad", tags: [{ id: 1 }], permissions: { "0": 1 }, id: 9 }).success, false);
283+
const blankParent = createSchema.safeParse({
284+
name: "No parent", tags: [{ id: 1 }], parent_tag: " ", permissions: everyoneFullPermissions(),
285+
});
286+
assert.equal(blankParent.success, true);
287+
if (blankParent.success) assert.equal(blankParent.data.parent_tag, undefined);
288+
const nullParent = createSchema.safeParse({
289+
name: "No parent", tags: [{ id: 1 }], parent_tag: null, permissions: everyoneFullPermissions(),
290+
});
291+
assert.equal(nullParent.success, true);
292+
assert.equal(createSchema.safeParse({
293+
name: "Bad parent", tags: [{ id: 1 }], parent_tag: "not-a-selector", permissions: everyoneFullPermissions(),
294+
}).success, false);
295+
assert.equal(createSchema.safeParse({ name: "Bad", tags: [{ id: 1, name: "both" }], permissions: everyoneFullPermissions() }).success, false);
296+
assert.equal(createSchema.safeParse({ name: "Bad", tags: [{ id: 1 }], permissions: { "0": 1 } }).success, false);
297+
assert.equal(createSchema.safeParse({ name: "Bad", tags: [{ id: 1 }], permissions: [] }).success, false);
298+
assert.equal(createSchema.safeParse({ name: "Bad", tags: [{ id: 1 }], permissions: [{ group_id: -1, access: "full" }] }).success, false);
299+
assert.equal(createSchema.safeParse({ name: "Bad", tags: [{ id: 1 }], permissions: [{ group_id: 0, access: "owner" }] }).success, false);
300+
assert.equal(createSchema.safeParse({ name: "Bad", tags: [{ id: 1 }], permissions: [
301+
{ group_id: 0, access: "full" },
302+
{ group_id: 0, access: "readonly" },
303+
] }).success, false);
304+
assert.equal(createSchema.safeParse({ name: "Bad", tags: [{ id: 1 }], permissions: [{ group_id: 0, access: "full", group_name: "everyone" }] }).success, false);
305+
assert.equal(createSchema.safeParse({ name: "Bad", tags: [{ id: 1 }], permissions: everyoneFullPermissions(), id: 9 }).success, false);
281306
});
282307

283308
test("update performs fresh hash preflight, preserves omitted values, sends complete state, and verifies authoritative normalization", async () => {
@@ -329,6 +354,13 @@ test("update rejects hash conflicts, no-ops, removals and permission replacement
329354
});
330355
try {
331356
const api = harness();
357+
const updateSchema = tagGroupTools.find((tool) => tool.name === "discourse_update_tag_group")!.schema;
358+
const normalizedBlankParent = updateSchema.parse({
359+
id: 7, expected_state_hash: normalizeTagGroup(current).state_hash, parent_tag: " ",
360+
});
361+
const blankParent = await api.invoke("discourse_update_tag_group", normalizedBlankParent as Record<string, unknown>);
362+
assert.equal(body(blankParent).code, "no_op");
363+
assert.equal(mock.requests.length, 0);
332364
const conflict = await api.invoke("discourse_update_tag_group", { id: 7, expected_state_hash: "0".repeat(64), name: "X" });
333365
assert.equal(body(conflict).code, "state_conflict");
334366
const noOp = await api.invoke("discourse_update_tag_group", { id: 7, expected_state_hash: normalizeTagGroup(current).state_hash });
@@ -344,7 +376,8 @@ test("update rejects hash conflicts, no-ops, removals and permission replacement
344376
});
345377
assert.equal(body(removal).code, "confirmation_required");
346378
const permissions = await api.invoke("discourse_update_tag_group", {
347-
id: 7, expected_state_hash: normalizeTagGroup(current).state_hash, permissions: { "0": 3 },
379+
id: 7, expected_state_hash: normalizeTagGroup(current).state_hash,
380+
permissions: [{ group_id: 0, access: "readonly" }],
348381
});
349382
assert.equal(body(permissions).code, "confirmation_required");
350383
const synthetic = await api.invoke("discourse_update_tag_group", {
@@ -481,7 +514,7 @@ test("tag-group mutations do not retry and diagnostics never leak arbitrary upst
481514
});
482515
try {
483516
const result = await harness().invoke("discourse_create_tag_group", {
484-
name: "Failure", tags: [{ id: 1 }], permissions: { "0": 1 }, allow_tag_creation: false,
517+
name: "Failure", tags: [{ id: 1 }], permissions: everyoneFullPermissions(), allow_tag_creation: false,
485518
});
486519
assert.equal(result.isError, true);
487520
assert.doesNotMatch(JSON.stringify(body(result)), /sensitive upstream detail/);
@@ -522,7 +555,7 @@ test("handlers surface upstream non-staff 404 and tagging-disabled 403 without l
522555
});
523556
try {
524557
const result = await harness().invoke("discourse_create_tag_group", {
525-
name: "Disabled", tags: [{ id: 1 }], permissions: { "0": 1 }, allow_tag_creation: false,
558+
name: "Disabled", tags: [{ id: 1 }], permissions: everyoneFullPermissions(), allow_tag_creation: false,
526559
});
527560
assert.equal(body(result).code, "insufficient_permission_or_tagging_disabled");
528561
assert.doesNotMatch(JSON.stringify(body(result)), /tagging disabled/);
@@ -588,12 +621,28 @@ test("real MCP output validation covers all six tag-group success paths", async
588621
const advertised = await client.listTools();
589622
assert.deepEqual(advertised.tools.map((tool) => tool.name), tagGroupTools.map((tool) => tool.name));
590623
assert.ok(advertised.tools.every((tool) => tool.outputSchema));
624+
const createTool = advertised.tools.find((tool) => tool.name === "discourse_create_tag_group");
625+
assert.ok(createTool);
626+
const advertisedPermissions = (createTool.inputSchema as any).properties.permissions;
627+
assert.equal(advertisedPermissions.type, "array");
628+
assert.equal(advertisedPermissions.minItems, 1);
629+
assert.equal(advertisedPermissions.items.type, "object");
630+
assert.deepEqual(advertisedPermissions.items.required, ["group_id", "access"]);
631+
assert.equal(advertisedPermissions.items.additionalProperties, false);
632+
assert.equal(advertisedPermissions.items.properties.group_id.type, "integer");
633+
assert.equal(advertisedPermissions.items.properties.group_id.minimum, 0);
634+
assert.match(advertisedPermissions.items.properties.group_id.description, /0.*everyone/);
635+
assert.deepEqual(advertisedPermissions.items.properties.access.enum, ["full", "readonly"]);
636+
const advertisedParent = (createTool.inputSchema as any).properties.parent_tag;
637+
assert.match(advertisedParent.description, /Omit or use null.*blank string placeholders.*omitted/i);
591638

592639
for (const call of [
593640
{ name: "discourse_search_tag_groups", arguments: {} },
594641
{ name: "discourse_list_tag_groups", arguments: {} },
595642
{ name: "discourse_get_tag_group", arguments: { id: 7 } },
596-
{ name: "discourse_create_tag_group", arguments: { name: "Created", tags: [{ id: 1 }], permissions: { "0": 1 }, allow_tag_creation: false } },
643+
{ name: "discourse_create_tag_group", arguments: {
644+
name: "Created", tags: [{ id: 1 }], parent_tag: "", permissions: everyoneFullPermissions(), allow_tag_creation: false,
645+
} },
597646
{ name: "discourse_update_tag_group", arguments: {
598647
id: 7,
599648
expected_state_hash: normalizeTagGroup(state).state_hash,

0 commit comments

Comments
 (0)