diff --git a/forge/ee/routes/expert/index.js b/forge/ee/routes/expert/index.js index 99e78bb9a8..20a45a1ff3 100644 --- a/forge/ee/routes/expert/index.js +++ b/forge/ee/routes/expert/index.js @@ -43,8 +43,10 @@ module.exports = async function (app) { error: 'unauthorized' }) } - // Ensure users team access is valid - const teamId = request.body.context?.teamId // `context.teamId` is the hash provided in the body context by the client + // Ensure users team access is valid. `teamId` is the team hash provided by the + // client — in the body context for POST routes (/chat, /mcp/features) or as a + // query param for GET routes (/mcp/tools, which has no body). + const teamId = request.body?.context?.teamId || request.query?.teamId if (!teamId) { return reply.status(404).send({ code: 'not_found', error: 'Not Found' }) } @@ -480,6 +482,66 @@ module.exports = async function (app) { reply.code(error.response?.status || 500).send({ code: error.response?.data?.code || 'unexpected_error', error: error.response?.data?.error || error.message }) } }) + + /** + * Retrieve the curated tool catalog for the Expert's human-in-the-loop permissions UI + * (#421). The MCP server(s) are static/global (not per-team registered), so unlike + * /mcp/features this is a thin read-only proxy: it forwards to the agent service's + * /mcp/tools endpoint, which returns friendly catalog entries only (raw MCP identifiers + * never leave the backend) plus a `hash` fingerprint of the catalog. Team access + + * feature gating are enforced by the shared preHandler above; read/write classification + * on each entry is what the client uses to decide which tools a role may enable. + */ + app.get('/mcp/tools', { + schema: { + hide: true, // dont show in swagger + querystring: { + type: 'object', + properties: { + teamId: { type: 'string', minLength: 10 } + }, + required: ['teamId'] + }, + response: { + 200: { + type: 'object', + properties: { + catalog: { + type: 'array', + items: { + type: 'object', + additionalProperties: true + } + }, + hash: { + type: ['string', 'null'] + } + } + }, + '4xx': { + $ref: 'APIError' + } + } + } + }, + async (request, reply) => { + if (!request.isExpertAssistantEnabled) { + return reply.status(404).send({ code: 'not_found', error: 'Not Found' }) + } + try { + const toolsUrl = `${app.expert.expertUrl.split('/').slice(0, -1).join('/')}/mcp/tools` + const response = await axios.get(toolsUrl, { + headers: { + Origin: request.headers.origin, + ...(app.expert.serviceToken ? { Authorization: `Bearer ${app.expert.serviceToken}` } : {}) + }, + timeout: app.expert.requestTimeout + }) + reply.send({ catalog: response.data?.catalog || [], hash: response.data?.hash || null }) + } catch (error) { + reply.code(error.response?.status || 500).send({ code: error.response?.data?.code || 'unexpected_error', error: error.response?.data?.error || error.message }) + } + }) } /** diff --git a/frontend/src/api/expert.js b/frontend/src/api/expert.js index 67ef3ce793..b44925e2a5 100644 --- a/frontend/src/api/expert.js +++ b/frontend/src/api/expert.js @@ -48,7 +48,22 @@ const getCapabilities = async (payload) => { }) } +/** + * Fetch the curated tool catalog for the human-in-the-loop permissions UI (#421). + * Read-only GET — proxied by forge to the agent's /mcp/tools endpoint, which serves + * friendly catalog entries only (raw MCP identifiers never leave the backend) plus a + * `hash` fingerprint the UI uses to refetch only when the catalog changes. + * @param {{ teamId: string }} params + * @returns {Promise<{ catalog: Array, hash: string|null }>} + */ +const getToolCatalog = async ({ teamId } = {}) => { + return client.get('/api/v1/expert/mcp/tools', { + params: { teamId } + }).then(res => res.data) +} + export default { chat, - getCapabilities + getCapabilities, + getToolCatalog } diff --git a/frontend/src/components/expert/components/ExpertChatInput.vue b/frontend/src/components/expert/components/ExpertChatInput.vue index 58d5c170e8..457bf11d35 100644 --- a/frontend/src/components/expert/components/ExpertChatInput.vue +++ b/frontend/src/components/expert/components/ExpertChatInput.vue @@ -105,6 +105,11 @@ data-el="expert-question-cadence" /> +
+ Tool permissions +

Choose which actions the Expert can run, and which need your approval.

+ +
@@ -118,6 +123,7 @@ import FormHeading from '../../FormHeading.vue' import ResizeBar from '../../ResizeBar.vue' import CapabilitiesSelector from './CapabilitiesSelector.vue' +import ToolPermissionsSettings from './ToolPermissionsSettings.vue' import DefaultChip from './chips/DefaultChip.vue' import ContextSelector from './context-selection/index.vue' @@ -135,7 +141,8 @@ export default { ContextSelector, DefaultChip, FormHeading, - ResizeBar + ResizeBar, + ToolPermissionsSettings }, inject: { togglePinWithWidth: { @@ -292,10 +299,14 @@ export default { minHeight: 120, maxViewportMarginY: 80 }) + // Fetch the tool catalog as soon as the Expert panel mounts (not only in the + // editor) so the permissions settings can render everywhere. Flow-building + // tools are still only usable from an instance editor (see isImmersive below). + this.fetchToolCatalog() }, methods: { ...mapActions(useProductAssistantStore, ['resetContextSelection']), - ...mapActions(useProductExpertStore, ['startOver', 'handleQuery', 'setPendingInput', 'setQuestionCadence', 'setPlanMode']), + ...mapActions(useProductExpertStore, ['startOver', 'handleQuery', 'setPendingInput', 'setQuestionCadence', 'setPlanMode', 'fetchToolCatalog']), openSettings () { this.$refs.settingsDialog.show() }, diff --git a/frontend/src/components/expert/components/ToolPermissionsSettings.vue b/frontend/src/components/expert/components/ToolPermissionsSettings.vue new file mode 100644 index 0000000000..828950ac12 --- /dev/null +++ b/frontend/src/components/expert/components/ToolPermissionsSettings.vue @@ -0,0 +1,355 @@ + + + + + diff --git a/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue b/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue index 82201c1740..e73ad318cd 100644 --- a/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue +++ b/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue @@ -1,6 +1,6 @@ @@ -113,6 +125,7 @@ import QuestionsList from './resources/QuestionsList.vue' import ResourcesList from './resources/ResourcesList.vue' import RichContent from './resources/RichContent.vue' import SuggestionsList from './resources/SuggestionsList.vue' +import ToolApprovalCard from './resources/ToolApprovalCard.vue' import { useProductAssistantStore } from '@/stores/product-assistant.js' import { useProductExpertStore } from '@/stores/product-expert.js' @@ -131,7 +144,8 @@ export default { GuideStepsList, MessageBubble, GuideHeader, - IssuesList + IssuesList, + ToolApprovalCard }, props: { answer: { @@ -202,6 +216,9 @@ export default { hasPlan () { return this.isPlanAnswer && typeof this.answer.content === 'string' && this.answer.content.length > 0 }, + hasToolApproval () { + return this.answer.kind === 'tool-approval' && !!this.answer.id + }, isChatAnswer () { return !Object.hasOwnProperty.call(this.answer, 'kind') || this.answer.kind === 'chat' }, @@ -285,6 +302,13 @@ export default { if (this.componentStreamingOrder.indexOf(key) === 0) return true return this.streamedComponents.length >= this.componentStreamingOrder.indexOf(key) }, + shouldShowToolApproval () { + const key = 'tool-approval-card' + if (!this.componentStreamingOrder.includes(key)) return false + if (!this.hasToolApproval) return false + if (this.componentStreamingOrder.indexOf(key) === 0) return true + return this.streamedComponents.length >= this.componentStreamingOrder.indexOf(key) + }, shouldStream () { return !this.answer._streamed } @@ -320,7 +344,7 @@ export default { } }, methods: { - ...mapActions(useProductExpertStore, ['updateAnswerStreamedState', 'handleQuery', 'setPendingInput', 'requestPlanChange', 'resetComposer', 'setPlanMode']), + ...mapActions(useProductExpertStore, ['updateAnswerStreamedState', 'handleQuery', 'setPendingInput', 'requestPlanChange', 'resetComposer', 'setPlanMode', 'resolveToolApproval']), buildStreamingOrder () { // order matters // this is where the decision of the streaming order of components is decided @@ -335,6 +359,7 @@ export default { if (this.hasSuggestions) this.componentStreamingOrder.push('suggestions-list') if (this.hasQuestions) this.componentStreamingOrder.push('questions-list') if (this.hasPlan) this.componentStreamingOrder.push('plan-list') + if (this.hasToolApproval) this.componentStreamingOrder.push('tool-approval-card') }, async onComponentComplete (key) { if (!this.shouldStream) await this.waitFor(200) @@ -373,6 +398,15 @@ export default { this.resetComposer() this.handleQuery({ query: 'I do not want to proceed with this plan.' }) }, + onToolApprove () { + this.resolveToolApproval({ id: this.answer.id, approved: true, always: false }) + }, + onToolAllowAlways () { + this.resolveToolApproval({ id: this.answer.id, approved: true, always: true }) + }, + onToolDeny () { + this.resolveToolApproval({ id: this.answer.id, approved: false, always: false }) + }, handleClick (e) { const target = e.target // - Must be in the immersive editor diff --git a/frontend/src/components/expert/components/messages/components/MessageBubble.vue b/frontend/src/components/expert/components/messages/components/MessageBubble.vue index 2d6de846b3..3751b2ff75 100644 --- a/frontend/src/components/expert/components/messages/components/MessageBubble.vue +++ b/frontend/src/components/expert/components/messages/components/MessageBubble.vue @@ -1,7 +1,7 @@