diff --git a/src/api/handlers/mcp/auth.py b/src/api/handlers/mcp/auth.py index c72e51d5b..fdfd7688f 100644 --- a/src/api/handlers/mcp/auth.py +++ b/src/api/handlers/mcp/auth.py @@ -107,6 +107,10 @@ def decorated(*args, **kwargs): def check_project_access_mcp(project_id: str) -> bool: """Return True if the current request may access project_id. + enabled_projects semantics (intentional): + {} → allow all projects (user chose no restriction when creating the token) + {id: ...} → allow only the listed project IDs + MCP token path: project must be in g.mcp_enabled_projects and not past its per-project expiry (if set). Session path: delegates to OPA (already checked in before_request). @@ -116,6 +120,10 @@ def check_project_access_mcp(project_id: str) -> bool: return True enabled = g.mcp_enabled_projects + if not enabled: + # empty dict → no project restriction, allow all + return True + if project_id not in enabled: return False diff --git a/src/api/handlers/mcp/routes/projects.py b/src/api/handlers/mcp/routes/projects.py index e82fcd9b5..4df144aad 100644 --- a/src/api/handlers/mcp/routes/projects.py +++ b/src/api/handlers/mcp/routes/projects.py @@ -27,21 +27,24 @@ def get(self): enabled = getattr(g, 'mcp_enabled_projects', None) if enabled is not None: - # MCP token path: return only scoped projects where the token owner - # is still a collaborator — re-validate membership at query time so - # a revoked collaborator cannot enumerate project metadata. + # MCP token path if not enabled: - audit_mcp('list_projects', outcome='success', details={'count': 0}) - return [] - - project_ids = list(enabled.keys()) - rows = g.db.execute_many_dict(''' - SELECT p.id, p.name, p.type, p.public - FROM project p - INNER JOIN collaborator co ON co.project_id = p.id AND co.user_id = %s - WHERE p.id = ANY(%s::uuid[]) - ORDER BY p.name - ''', [user_id, project_ids]) + # empty dict = all projects the user is a collaborator on + rows = g.db.execute_many_dict(''' + SELECT p.id, p.name, p.type, p.public + FROM project p + INNER JOIN collaborator co ON co.project_id = p.id AND co.user_id = %s + ORDER BY p.name + ''', [user_id]) + else: + project_ids = list(enabled.keys()) + rows = g.db.execute_many_dict(''' + SELECT p.id, p.name, p.type, p.public + FROM project p + INNER JOIN collaborator co ON co.project_id = p.id AND co.user_id = %s + WHERE p.id = ANY(%s::uuid[]) + ORDER BY p.name + ''', [user_id, project_ids]) else: # Session path: return all projects the user is a collaborator on rows = g.db.execute_many_dict(''' @@ -58,3 +61,4 @@ def get(self): except Exception as exc: audit_mcp('list_projects', outcome='failure', error=str(exc)) raise + diff --git a/src/dashboard-client/src/components/user/UserGlobalTokens.vue b/src/dashboard-client/src/components/user/UserGlobalTokens.vue index e71e5716f..20f92a254 100644 --- a/src/dashboard-client/src/components/user/UserGlobalTokens.vue +++ b/src/dashboard-client/src/components/user/UserGlobalTokens.vue @@ -186,6 +186,155 @@ md-cancel-text="Cancel" @close="onRevokeClose"> + + + + + +

+ + MCP Tokens + + For use with the InfraBox MCP server (INFRABOX_MCP_TOKEN) + + +

+
+
+ + + + + + + + + + Name must be at least 3 characters + + + + + 1–365 days + + + add_circle + Create MCP token + + +
+
+ Project scope + leave all unchecked to allow access to all projects +
+
+ +
+
+
+
+
+ + + + + + + Name + Projects + Created + Expires + Last Used + Trigger + Actions + + + + + + + No MCP tokens yet. Create one above. + + + + +
+ + + + MCP Token Created + + Save this token somewhere safe — it will not be shown again.

+
{{ newMcpToken }}

+ Use it with the InfraBox MCP server:
+
$ export INFRABOX_MCP_TOKEN=<TOKEN_VALUE>
+
+ + OK + +
+ + + + @@ -208,6 +357,16 @@ export default { form: { description: '', expiresDays: 365 + }, + mcpTokens: [], + newMcpToken: '', + pendingMcpRevoke: null, + scopeEditId: null, + scopeEditSelection: [], + mcpForm: { + name: '', + expiresDays: 365, + selectedProjects: [] } }), @@ -216,6 +375,20 @@ export default { return !this.form.description || this.form.description.length < 3 || !this.form.expiresDays || this.form.expiresDays < 1 || this.form.expiresDays > 3650 }, + disableMcpAdd () { + return !this.mcpForm.name || this.mcpForm.name.length < 3 || + !this.mcpForm.expiresDays || this.mcpForm.expiresDays < 1 || this.mcpForm.expiresDays > 365 + }, + mcpNameError () { + return this.mcpForm.name !== '' && this.mcpForm.name.length < 3 + }, + mcpDaysError () { + return this.mcpForm.expiresDays !== '' && this.mcpForm.expiresDays !== null && + (this.mcpForm.expiresDays < 1 || this.mcpForm.expiresDays > 365) + }, + userProjects () { + return this.$store.state.projects || [] + }, adminProjects () { return this.$store.state.projects.filter(p => p.userHasAdminRights()) }, @@ -229,6 +402,10 @@ export default { this.tokens = tokens }).catch(() => {}) + UserTokenService.loadMcpTokens().then((tokens) => { + this.mcpTokens = tokens + }).catch(() => {}) + const adminProjects = this.$store.state.projects.filter(p => p.userHasAdminRights()) if (adminProjects.length > 0) { this.projectTokensLoading = true @@ -310,6 +487,95 @@ export default { .then((log) => { this.accessLog = log }) .catch(() => {}) .finally(() => { this.logLoading = false }) + }, + + createMcpToken () { + if (!this.mcpForm.name || this.mcpForm.name.length < 3) { + NotificationService.$emit('NOTIFICATION', new Notification({ message: 'Token name must be at least 3 characters.' })) + return + } + const days = this.mcpForm.expiresDays + if (!days || days < 1 || days > 365) { + NotificationService.$emit('NOTIFICATION', new Notification({ message: 'Validity must be between 1 and 365 days.' })) + return + } + const enabledProjects = this.mcpForm.selectedProjects.length > 0 + ? this.mcpForm.selectedProjects.reduce((acc, id) => { acc[id] = null; return acc }, {}) + : {} + UserTokenService.createMcpToken(this.mcpForm.name, enabledProjects, this.mcpForm.expiresDays) + .then((result) => { + this.mcpTokens.unshift({ + token_id: result.token_id, + name: result.name, + enabled_projects: result.enabled_projects, + allow_trigger: result.allow_trigger, + expires_at: result.expires_at, + created_at: new Date().toISOString(), + last_used_at: null + }) + this.newMcpToken = result.token + this.$refs['mcpTokenDialog'].open() + this.mcpForm.name = '' + this.mcpForm.expiresDays = 365 + this.mcpForm.selectedProjects = [] + }) + .catch(() => {}) + }, + + confirmMcpRevoke (token) { + this.pendingMcpRevoke = token + this.$refs['mcpRevokeDialog'].open() + }, + + onMcpRevokeClose (type) { + if (type !== 'ok' || !this.pendingMcpRevoke) { + this.pendingMcpRevoke = null + return + } + const target = this.pendingMcpRevoke + UserTokenService.revokeMcpToken(target.token_id) + .then(() => { + this.mcpTokens = this.mcpTokens.filter(t => t.token_id !== target.token_id) + NotificationService.$emit('NOTIFICATION', new Notification({ message: `MCP token "${target.name}" revoked.` })) + }) + .catch(() => {}) + .finally(() => { this.pendingMcpRevoke = null }) + }, + + toggleScopeEdit (token) { + if (this.scopeEditId === token.token_id) { + this.scopeEditId = null + return + } + this.scopeEditId = token.token_id + this.scopeEditSelection = Object.keys(token.enabled_projects || {}) + }, + + saveScopeEdit (token) { + const enabledProjects = this.scopeEditSelection.reduce((acc, id) => { + acc[id] = null + return acc + }, {}) + UserTokenService.updateMcpToken(token.token_id, enabledProjects) + .then(() => { + token.enabled_projects = enabledProjects + this.scopeEditId = null + NotificationService.$emit('NOTIFICATION', new Notification({ message: `Scope updated for "${token.name}".` })) + }) + .catch(() => {}) + }, + + toggleMcpTrigger (token) { + // v-model has already flipped token.allow_trigger to the new value; + // read it directly rather than negating again. + const newVal = token.allow_trigger + UserTokenService.setMcpTrigger(token.token_id, newVal) + .catch(() => { token.allow_trigger = !newVal }) + }, + + closeMcpTokenDialog () { + this.$refs['mcpTokenDialog'].close() + this.newMcpToken = '' } } } @@ -338,6 +604,7 @@ export default { .log-cell { background-color: #f9f9f9 !important; padding: 8px 16px !important; + overflow: visible !important; } .log-table { @@ -385,4 +652,60 @@ export default { color: #c62828; font-weight: 500; } + +.mcp-all-projects { + color: #888; + font-style: italic; + font-size: 13px; +} + +.mcp-project-count { + font-size: 13px; +} + +.mcp-trigger-switch { + margin: 0; +} + +.mcp-project-checkbox { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 13px; + cursor: pointer; + user-select: none; + color: #444; +} + +.mcp-project-checkbox input { + cursor: pointer; +} + +.scope-btn { + display: inline-block; + margin-top: 8px; + margin-right: 8px; + padding: 6px 16px; + border: none; + border-radius: 2px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + text-transform: uppercase; + background: #e0e0e0; + color: #212121; +} + +.scope-btn:hover { + background: rgba(0,0,0,0.07); +} + +.scope-btn-primary { + background: #009688; + color: #fff; +} + +.scope-btn-primary:hover { + background: #00796b; +} diff --git a/src/dashboard-client/src/services/NewAPIService.js b/src/dashboard-client/src/services/NewAPIService.js index 132d39160..2989427c0 100644 --- a/src/dashboard-client/src/services/NewAPIService.js +++ b/src/dashboard-client/src/services/NewAPIService.js @@ -61,6 +61,16 @@ class NewAPIService { }) .catch(this._handleError(false)) } + + patch (url, payload) { + console.log(`PATCH API: ${url}`) + const u = this.api + url + return Vue.http.patch(u, payload) + .then((response) => { + return response.body || {} + }) + .catch(this._handleError(false)) + } } export default new NewAPIService() diff --git a/src/dashboard-client/src/services/UserTokenService.js b/src/dashboard-client/src/services/UserTokenService.js index 0eb879f06..c694a6dd8 100644 --- a/src/dashboard-client/src/services/UserTokenService.js +++ b/src/dashboard-client/src/services/UserTokenService.js @@ -38,6 +38,51 @@ class UserTokenService { throw err }) } + // MCP token methods — /api/v1/mcp/tokens/* + + loadMcpTokens () { + return NewAPIService.get('mcp/tokens/') + .catch((err) => { + NotificationService.$emit('NOTIFICATION', new Notification(err)) + throw err + }) + } + + createMcpToken (name, enabledProjects, expiresDays) { + return NewAPIService.post('mcp/tokens/', { + name, + enabled_projects: enabledProjects || {}, + expires_days: expiresDays || 365 + }).catch((err) => { + NotificationService.$emit('NOTIFICATION', new Notification(err)) + throw err + }) + } + + updateMcpToken (tokenId, enabledProjects) { + return NewAPIService.patch(`mcp/tokens/${tokenId}`, { enabled_projects: enabledProjects }) + .catch((err) => { + NotificationService.$emit('NOTIFICATION', new Notification(err)) + throw err + }) + } + + revokeMcpToken (tokenId) { + return NewAPIService.delete(`mcp/tokens/${tokenId}`) + .catch((err) => { + NotificationService.$emit('NOTIFICATION', new Notification(err)) + throw err + }) + } + + setMcpTrigger (tokenId, allow) { + const method = allow ? 'post' : 'delete' + return NewAPIService[method](`mcp/tokens/${tokenId}/trigger`, {}) + .catch((err) => { + NotificationService.$emit('NOTIFICATION', new Notification(err)) + throw err + }) + } } export default new UserTokenService()