Skip to content

Commit 4f57168

Browse files
andypalmiandypalmi
andauthored
chore(expert): tool-permissions review follow-ups (comments + generic JsonViewer) (#7730)
Follow-up cleanups agreed during review of #7639 (human-in-the-loop tool permissions). No behaviour change. ## Comments cleanup (#7728) - Shorten the `/mcp/tools` handler and `curatePlatformTool` doc blocks in `forge/ee/routes/expert/index.js`. - Drop the stale `TODO: decide with the team whether this belongs on the branch` in the catalog catch block, keeping the rationale for why an upstream 401/403 must not be forwarded as our own 401. - Trim the `size` prop comment in `ToggleButtonGroup.vue`. ## Generic JsonViewer (#7729) - Move `JsonViewer.vue` out of the expert message tree into `frontend/src/components` so it can be reused (its only consumer, `ToolApprovalCard`, is updated to the new import). - Move its `safeStringify` helper into the shared strings composable (`frontend/src/composables/strings/String.js`) and add a unit test. ## Testing - New `safeStringify` unit tests pass. - eslint and colour lint green (pre-commit hook). Closes #7728 Closes #7729 Co-authored-by: andypalmi <andrea@flowfuse.com>
1 parent d91ca78 commit 4f57168

6 files changed

Lines changed: 59 additions & 39 deletions

File tree

forge/ee/routes/expert/index.js

Lines changed: 11 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,9 @@ const getDeviceComms = (app) => { return app.comms?.devices }
1616

1717
/**
1818
* Maps a platform automation tool's wire definition into a catalog entry for the
19-
* Expert permissions UI (#421). Platform tools carry standard MCP annotations
20-
* (readOnlyHint / destructiveHint), which give the read/write/delete class. They
21-
* run on the platform, not in Node-RED, so they have no nr-assistant version window
22-
* (no minVersion/maxVersion — the UI treats their absence as always-available).
23-
* `group: 'platform'` routes them to the FlowFuse Platform Tools section (groupOf()
24-
* in the product-assistant store). The friendly label is the tool's own `title`; if a
25-
* tool ever lacks one, fall back to deriving it from the name (strip the platform_
26-
* prefix and title-case the rest).
19+
* Expert permissions UI. The read/write/delete class comes from the MCP annotations
20+
* (readOnlyHint / destructiveHint), and `group: 'platform'` routes it to the platform
21+
* section. The label is the tool's own `title`, falling back to a name-derived label.
2722
*/
2823
const curatePlatformTool = (def) => {
2924
const annotations = def.annotations || {}
@@ -586,16 +581,10 @@ module.exports = async function (app) {
586581
})
587582

588583
/**
589-
* Retrieve the curated tool catalog for the Expert's human-in-the-loop permissions UI
590-
* (#421). Returns the merged catalog for both sections the UI shows:
591-
* - flow-building tools, proxied from the agent service's /mcp/flow-tools endpoint
592-
* (friendly catalog entries only — raw MCP identifiers never leave the backend);
593-
* - FlowFuse platform tools, curated here from the platform automation handler
594-
* (app.comms.platformAutomation) and tagged group:'platform'.
595-
* A `hash` fingerprint of the flow-building catalog rides along so the browser refetches
596-
* only when it changes. Team access + feature gating are enforced by the shared
597-
* preHandler above; read/write classification on each entry is what the client uses to
598-
* decide which tools a role may enable.
584+
* Returns the merged tool catalog for the Expert permissions UI: flow-building tools
585+
* proxied from the agent's /mcp/flow-tools endpoint, plus curated platform tools. A
586+
* `hash` of the flow-building catalog rides along so the browser refetches only when
587+
* it changes. Team access and feature gating are enforced by the shared preHandler.
599588
*/
600589
app.get('/mcp/tools', {
601590
schema: {
@@ -656,11 +645,10 @@ module.exports = async function (app) {
656645

657646
reply.send({ catalog, hash: response.data?.hash || null })
658647
} catch (error) {
659-
// TODO: decide with the team whether this belongs on the branch. The tool catalog
660-
// is a non-fatal enhancement (the client swallows failures and gates safely with
661-
// defaults). Never forward an upstream auth failure as our own 401 — the SPA's
662-
// axios interceptor treats any 401 as session-expiry and logs the user out, which
663-
// an unrelated expert-service token rejection must not trigger.
648+
// The tool catalog is a non-fatal enhancement (the client swallows failures and
649+
// gates safely with defaults). Never forward an upstream auth failure as our own
650+
// 401. The SPA's axios interceptor treats any 401 as session-expiry and logs the
651+
// user out, which an unrelated expert-service token rejection must not trigger.
664652
const upstreamStatus = error.response?.status
665653
app.log.warn(`[expert/mcp/tools] upstream tool-catalog fetch failed: status=${upstreamStatus} msg=${error.message}`)
666654
if (upstreamStatus === 401 || upstreamStatus === 403) {

frontend/src/components/expert/components/messages/components/resources/JsonViewer.vue renamed to frontend/src/components/JsonViewer.vue

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -40,22 +40,13 @@
4040
</template>
4141

4242
<script>
43-
// Read-only single-value JSON viewer with prettify, word-wrap and optional collapse,
44-
// following the presentation of SnapshotDiffChangePanel without its two-sided diff
45-
// machinery. Used for payloads such as the tool approval card's call parameters.
43+
// Read-only single-value JSON viewer with prettify, word-wrap and optional collapse.
44+
// Generic component: renders any object, array or scalar as a payload block.
4645
import { ChevronRightIcon } from '@heroicons/vue/20/solid'
4746
48-
const LONG_LINE_THRESHOLD = 50
47+
import { safeStringify } from '@/composables/strings/String.js'
4948
50-
// Stringify that never throws — if the payload can't be serialised for any
51-
// reason, show a plain error instead of breaking the surrounding card.
52-
function safeStringify (value) {
53-
try {
54-
return JSON.stringify(value, null, 2)
55-
} catch (err) {
56-
return 'Could not display the payload.'
57-
}
58-
}
49+
const LONG_LINE_THRESHOLD = 50
5950
6051
export default {
6152
name: 'JsonViewer',

frontend/src/components/elements/ToggleButtonGroup.vue

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,7 @@ export default {
6262
required: false,
6363
default: 'value'
6464
},
65-
// Size of the underlying buttons (passed through to ff-button). Defaults to
66-
// 'medium' to match existing usages; 'small' suits dense contexts like tables.
65+
// Button size passed through to ff-button: 'small' or 'medium'.
6766
size: {
6867
type: String,
6968
required: false,

frontend/src/components/expert/components/messages/components/resources/ToolApprovalCard.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
</template>
4848

4949
<script>
50-
import JsonViewer from './JsonViewer.vue'
50+
import JsonViewer from '@/components/JsonViewer.vue'
5151
5252
export default {
5353
name: 'ToolApprovalCard',

frontend/src/composables/strings/String.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,22 @@ export const hashString = (str) => {
7070
return (h >>> 0).toString(16).padStart(8, '0')
7171
}
7272

73+
/**
74+
* Stringify a value as prettified JSON, never throwing. If the value can't be
75+
* serialised (e.g. a circular reference), returns a plain fallback message so the
76+
* caller can display something instead of breaking.
77+
* @param {*} value - The value to stringify
78+
* @param {String} fallback - Message returned when serialisation fails
79+
* @returns {String} - Prettified JSON, or the fallback on failure
80+
*/
81+
export const safeStringify = (value, fallback = 'Could not display the payload.') => {
82+
try {
83+
return JSON.stringify(value, null, 2)
84+
} catch (err) {
85+
return fallback
86+
}
87+
}
88+
7389
/**
7490
* Convert a string to slug format
7591
* @param {String} str - The string to convert to slug format
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { describe, expect, test } from 'vitest'
2+
3+
import { safeStringify } from '../../../../frontend/src/composables/strings/String.js'
4+
5+
describe('safeStringify', () => {
6+
test('prettifies objects with two-space indentation', () => {
7+
expect(safeStringify({ a: 1, b: [2, 3] })).toBe('{\n "a": 1,\n "b": [\n 2,\n 3\n ]\n}')
8+
})
9+
10+
test('stringifies scalars', () => {
11+
expect(safeStringify(42)).toBe('42')
12+
expect(safeStringify('hi')).toBe('"hi"')
13+
})
14+
15+
test('returns the fallback when the value cannot be serialised', () => {
16+
const circular = {}
17+
circular.self = circular
18+
expect(safeStringify(circular)).toBe('Could not display the payload.')
19+
})
20+
21+
test('accepts a custom fallback message', () => {
22+
const circular = {}
23+
circular.self = circular
24+
expect(safeStringify(circular, 'nope')).toBe('nope')
25+
})
26+
})

0 commit comments

Comments
 (0)