-
Notifications
You must be signed in to change notification settings - Fork 89
Replace the MCP access level with tool permissions #8688
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5d7f9e2
2e9cd5e
a6b2bf0
eab731f
44271e4
e4f2b3d
142dcfc
2ed4977
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| <template> | ||
| <div class="grid grid-cols-5 items-center gap-y-2" :class="{ 'opacity-50': disabled }" data-el="mcp-tool-permissions"> | ||
| <span class="col-span-2" /> | ||
| <span | ||
| v-for="category in categories" | ||
| :key="category.value" | ||
| v-ff-tooltip="category.tooltip" | ||
| class="flex items-center justify-center gap-1 text-xs font-medium text-gray-500" | ||
| :class="{ 'cursor-help': category.tooltip }" | ||
| :data-category="category.value" | ||
| > | ||
| {{ category.label }} | ||
| <InformationCircleIcon v-if="category.tooltip" class="w-3.5 h-3.5" /> | ||
| </span> | ||
| <div v-for="row in rows" :key="row.group" class="contents" :data-group="row.group"> | ||
| <span class="col-span-2 text-sm whitespace-nowrap">{{ row.label }}</span> | ||
| <div v-for="category in categories" :key="category.value" class="flex justify-center"> | ||
| <!-- h-4: without a label the glyph is absolutely positioned, so the checkbox has no height of its own --> | ||
| <ff-checkbox | ||
| class="h-4" | ||
| :model-value="modelValue[row.group][category.value]" | ||
| :disabled="disabled" | ||
| @update:model-value="value => onToggle(row.group, category.value, value)" | ||
| /> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </template> | ||
|
|
||
| <script> | ||
| import { InformationCircleIcon } from '@heroicons/vue/20/solid' | ||
|
|
||
| function withCategory (categories, category, value) { | ||
| const next = { ...categories, [category]: value } | ||
| if (value) { | ||
| if (category === 'write') { | ||
| next.read = true | ||
| } else if (category === 'destructive') { | ||
| next.write = true | ||
| next.read = true | ||
| } | ||
| } else { | ||
| if (category === 'read') { | ||
| next.write = false | ||
| next.destructive = false | ||
| } else if (category === 'write') { | ||
| next.destructive = false | ||
| } | ||
| } | ||
| return next | ||
| } | ||
|
|
||
| export default { | ||
| name: 'McpToolPermissions', | ||
| components: { | ||
| InformationCircleIcon | ||
| }, | ||
| props: { | ||
| modelValue: { | ||
| type: Object, | ||
| required: true | ||
| }, | ||
| disabled: { | ||
| type: Boolean, | ||
| default: false | ||
| } | ||
| }, | ||
| emits: ['update:modelValue'], | ||
| data () { | ||
| return { | ||
| categories: [ | ||
| { value: 'read', label: 'Read' }, | ||
| { value: 'write', label: 'Write' }, | ||
| { | ||
| value: 'destructive', | ||
| label: 'Destructive', | ||
| tooltip: "Tools that delete or overwrite data, or change what's running." | ||
| } | ||
| ], | ||
| rows: [ | ||
| { group: 'platform', label: 'Platform' }, | ||
| { group: 'flow_building', label: 'Flow Building' } | ||
| ] | ||
| } | ||
| }, | ||
| methods: { | ||
| onToggle (group, category, value) { | ||
| this.$emit('update:modelValue', { | ||
| ...this.modelValue, | ||
| [group]: withCategory(this.modelValue[group], category, value) | ||
| }) | ||
| } | ||
| } | ||
| } | ||
| </script> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,17 +14,15 @@ | |
| </div> | ||
|
|
||
| <div class="w-full max-w-md space-y-4 my-4"> | ||
| <!-- Access Level --> | ||
| <!-- Tool Permissions --> | ||
| <div> | ||
| <p class="text-gray-500 text-sm mb-2"> | ||
| Choose whether the agent can make changes or only read data. | ||
| Choose what the agent can do in your teams. You can change this for individual teams below. | ||
| </p> | ||
| <McpToolPermissions v-model="defaultPermissions" :disabled="defaultUnused" /> | ||
| <p v-if="defaultPermissionsNote" class="text-gray-500 text-sm mt-2" data-el="default-permissions-note"> | ||
| {{ defaultPermissionsNote }} | ||
| </p> | ||
| <ff-radio-group | ||
| v-model="accessLevel" | ||
| label="Access Level" | ||
| orientation="vertical" | ||
| :options="accessLevelOptions" | ||
| /> | ||
| </div> | ||
|
|
||
| <!-- Team Scope --> | ||
|
|
@@ -38,16 +36,55 @@ | |
| orientation="vertical" | ||
| :options="teamScopeOptions" | ||
| /> | ||
| <div v-if="teamScope === 'specific' && teams.length > 0" class="mt-2 ml-6 space-y-1"> | ||
| <ff-checkbox | ||
| v-for="team in teams" | ||
| :key="team.id" | ||
| :model-value="selectedTeamIds.includes(team.id)" | ||
| :label="team.name" | ||
| @update:model-value="toggleTeam(team.id)" | ||
| /> | ||
| </div> | ||
|
|
||
| <!-- Team Permissions --> | ||
| <div v-if="teams.length > 0"> | ||
| <label class="block text-sm font-medium mb-1">Team Permissions</label> | ||
| <p class="text-gray-500 text-sm mb-2"> | ||
| Each team uses the permissions above unless you edit it. | ||
| </p> | ||
| <div class="space-y-2"> | ||
| <div v-for="team in teams" :key="team.id" data-el="mcp-team-row"> | ||
| <div class="flex items-center justify-between min-h-6"> | ||
| <ff-checkbox | ||
| v-if="teamScope === 'specific'" | ||
| :model-value="selectedTeamIds.includes(team.id)" | ||
| :label="team.name" | ||
| @update:model-value="toggleTeam(team.id)" | ||
| /> | ||
| <span v-else class="text-sm font-medium">{{ team.name }}</span> | ||
| <div v-if="isInScope(team.id)" class="flex items-center gap-3 text-sm"> | ||
| <span class="text-gray-500" data-el="team-permissions-state">{{ isCustom(team.id) ? 'Custom' : 'Default' }}</span> | ||
| <ff-button | ||
| v-if="isCustom(team.id)" | ||
| kind="tertiary" | ||
| size="small" | ||
| data-action="reset-team-default" | ||
| @click="resetToDefault(team.id)" | ||
| > | ||
| Reset | ||
| </ff-button> | ||
| <ff-button | ||
| kind="tertiary" | ||
| size="small" | ||
| data-action="customise-team" | ||
| :aria-expanded="isOpen(team.id)" | ||
| @click="toggleCustomise(team.id)" | ||
| > | ||
| {{ isOpen(team.id) ? 'Close' : 'Edit' }} | ||
| </ff-button> | ||
| </div> | ||
| </div> | ||
| <div v-if="isInScope(team.id) && isOpen(team.id)" class="ml-6 mt-2 mb-1"> | ||
| <McpToolPermissions | ||
| :model-value="teamPermissions(team.id)" | ||
| @update:model-value="value => onTeamPermissionsChange(team.id, value)" | ||
| /> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| <div v-if="teamScope === 'specific' && selectedTeamIds.length === 0" class="mt-2 ml-6 text-sm text-yellow-600"> | ||
| <div v-if="teamScope === 'specific' && selectedTeamIds.length === 0" class="mt-2 text-sm text-yellow-600"> | ||
| Select at least one team. | ||
| </div> | ||
| </div> | ||
|
|
@@ -79,6 +116,7 @@ import { ArrowSmallLeftIcon, ArrowSmallRightIcon, CommandLineIcon, KeyIcon } fro | |
| import { mapState } from 'pinia' | ||
|
|
||
| import FormRow from '../../components/FormRow.vue' | ||
| import McpToolPermissions from '../../components/mcp/McpToolPermissions.vue' | ||
|
|
||
| import client from '@/api/client.ts' | ||
| import teamApi from '@/api/team.ts' | ||
|
|
@@ -92,29 +130,40 @@ function defaultExpiresAt () { | |
| return date.toISOString().split('T')[0] | ||
| } | ||
|
|
||
| function defaultToolPermissions () { | ||
| return { | ||
| platform: { read: true, write: true, destructive: false }, | ||
| flow_building: { read: true, write: true, destructive: true } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. #8662 asks for Read and Write pre-selected with Destructive off, and the reason for the whole epic (#8659) is that the user has to opt in to destructive tools explicitly. With this preset, clicking Allow lets the agent remove nodes, overwrite flows and deploy without anyone choosing that. Could Flow Building start on read + write like Platform?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd lean the other way here and would like your view. With Destructive off, the agent can't remove nodes, tabs or wires, or deploy, until the user re-authenticates and changes the permissions. Deploy Flows is destructive, so even after a team turns on AI Flow Deploy the user would have to re-authenticate again before the agent can deploy. Starting Flow Building on read + write + destructive keeps build-and-deploy working from one consent, and the user still sees the grid and has to click Allow. As this is the decision the epic is about, @Steve-Mcl @dimitrieh @ZJvandeWeg could you weigh in on the default? @cstns what do you think?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Kept as is: Flow Building still starts with read, write and destructive on, and Platform with read and write. The team scope is now the deliberate choice (see the other thread), which was the main concern. |
||
| } | ||
| } | ||
|
|
||
| function permissionsEqual (a, b) { | ||
| return ['platform', 'flow_building'].every(group => ( | ||
| ['read', 'write', 'destructive'].every(category => !!a[group][category] === !!b[group][category]) | ||
| )) | ||
| } | ||
|
|
||
| export default { | ||
| name: 'AccessRequestMCP', | ||
| components: { | ||
| CommandLineIcon, | ||
| KeyIcon, | ||
| ArrowSmallRightIcon, | ||
| ArrowSmallLeftIcon, | ||
| FormRow | ||
| FormRow, | ||
| McpToolPermissions | ||
| }, | ||
| data () { | ||
| return { | ||
| // No defaults for access level/team scope: the user must make an explicit choice before Allow enables | ||
| accessLevel: null, | ||
| teamScope: null, | ||
| expiresAt: defaultExpiresAt(), | ||
| selectedTeamIds: [], | ||
| teams: [], | ||
| defaultPermissions: defaultToolPermissions(), | ||
| teamOverrides: {}, | ||
| openTeamIds: [], | ||
| submitting: false, | ||
| error: null, | ||
| accessLevelOptions: [ | ||
| { label: 'Full access', value: 'full', description: 'Read and write operations' }, | ||
| { label: 'Read-only', value: 'readonly', description: 'Read operations only' } | ||
| ], | ||
| teamScopeOptions: [ | ||
| { label: 'All teams', value: 'all', description: 'Access all teams you belong to' }, | ||
| { label: 'Specific teams', value: 'specific', description: 'Choose which teams to grant access to' } | ||
|
|
@@ -132,13 +181,37 @@ export default { | |
| if (Number.isNaN(ts)) return false | ||
| return ts > Date.now() && ts <= Date.now() + ONE_YEAR | ||
| }, | ||
| // With specific teams all customised, no team the token reaches uses the defaults. With all | ||
| // teams they still apply to teams the user joins later. | ||
| defaultUnused () { | ||
| return this.teamScope === 'specific' && this.selectedTeamIds.length > 0 && this.selectedTeamIds.every(teamId => this.isCustom(teamId)) | ||
| }, | ||
| defaultPermissionsNote () { | ||
| if (this.teamScope === 'all') { | ||
| return this.teams.length > 0 && this.teams.every(team => this.isCustom(team.id)) | ||
| ? 'Every team you belong to has custom permissions, so these only apply to teams you join later.' | ||
| : 'These apply to every team without custom permissions, including teams you join later.' | ||
| } | ||
| if (this.teamScope === 'specific') { | ||
| return this.defaultUnused | ||
| ? 'Not used, every selected team has custom permissions.' | ||
| : 'These apply to the selected teams without custom permissions.' | ||
| } | ||
| return null | ||
| }, | ||
| disableAllow () { | ||
| if (this.submitting) return true | ||
| if (!this.accessLevel || !this.teamScope || !this.expiryValid) return true | ||
| if (!this.teamScope || !this.expiryValid) return true | ||
| if (this.teamScope === 'specific' && this.selectedTeamIds.length === 0) return true | ||
| return false | ||
| } | ||
| }, | ||
| watch: { | ||
| teamScope () { | ||
| this.teamOverrides = {} | ||
| this.openTeamIds = [] | ||
| } | ||
| }, | ||
| async mounted () { | ||
| try { | ||
| const data = await teamApi.getTeams() | ||
|
|
@@ -148,22 +221,55 @@ export default { | |
| } | ||
| }, | ||
| methods: { | ||
| isInScope (teamId) { | ||
| return this.teamScope === 'all' || (this.teamScope === 'specific' && this.selectedTeamIds.includes(teamId)) | ||
| }, | ||
| isCustom (teamId) { | ||
| return Object.prototype.hasOwnProperty.call(this.teamOverrides, teamId) | ||
| }, | ||
| isOpen (teamId) { | ||
| return this.openTeamIds.includes(teamId) | ||
| }, | ||
| teamPermissions (teamId) { | ||
| return this.teamOverrides[teamId] || this.defaultPermissions | ||
| }, | ||
| toggleTeam (teamId) { | ||
| const idx = this.selectedTeamIds.indexOf(teamId) | ||
| if (idx === -1) { | ||
| this.selectedTeamIds.push(teamId) | ||
| } else { | ||
| this.selectedTeamIds.splice(idx, 1) | ||
| this.resetToDefault(teamId) | ||
| this.openTeamIds = this.openTeamIds.filter(id => id !== teamId) | ||
| } | ||
| }, | ||
| toggleCustomise (teamId) { | ||
| this.openTeamIds = this.openTeamIds.includes(teamId) | ||
| ? this.openTeamIds.filter(id => id !== teamId) | ||
| : [...this.openTeamIds, teamId] | ||
| }, | ||
| onTeamPermissionsChange (teamId, value) { | ||
| if (permissionsEqual(value, this.defaultPermissions)) { | ||
| const { [teamId]: _removed, ...rest } = this.teamOverrides | ||
| this.teamOverrides = rest | ||
| } else { | ||
| this.teamOverrides = { ...this.teamOverrides, [teamId]: value } | ||
| } | ||
| }, | ||
| resetToDefault (teamId) { | ||
| if (this.isCustom(teamId)) { | ||
| const { [teamId]: _removed, ...rest } = this.teamOverrides | ||
| this.teamOverrides = rest | ||
| } | ||
| }, | ||
| async allowAccess () { | ||
| this.submitting = true | ||
| this.error = null | ||
| try { | ||
| await client.put(`/account/authorize/${this.requestId}/consent`, { | ||
| readOnly: this.accessLevel === 'readonly', | ||
| teamIds: this.teamScope === 'all' ? [] : this.selectedTeamIds, | ||
| expiresAt: Date.parse(this.expiresAt) | ||
| expiresAt: Date.parse(this.expiresAt), | ||
| toolPermissions: { default: this.defaultPermissions, teams: this.teamOverrides } | ||
| }) | ||
| window.location.href = `/account/complete/${this.requestId}` | ||
| } catch (err) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Small one: these are hand-styled
<button>s.ff-buttonwithkind="tertiary"andsize="small"would match the rest of the UI.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done, both are
ff-buttonwithkind="tertiary"andsize="small"now.