Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions packages/server/api/src/app/mcp/mcp-access.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Permission } from '@activepieces/core-utils'
import { ApEdition, PlatformRole, Project, ProjectType } from '@activepieces/shared'
import { FastifyBaseLogger } from 'fastify'
import { projectMemberService } from '../ee/projects/project-members/project-member.service'
import { system } from '../helper/system/system'
import { projectService } from '../project/project-service'
import { userService } from '../user/user-service'

const EDITION_REQUIRES_RBAC = [ApEdition.CLOUD, ApEdition.ENTERPRISE].includes(system.getEdition())

async function listMcpAccessibleProjects({ platformId, userId, log }: {
platformId: string
userId: string
log: FastifyBaseLogger
}): Promise<Project[]> {
const user = await userService(log).getOneOrFail({ id: userId })
const isPrivileged = userService(log).isUserPrivileged(user)
const projects = await projectService(log).getAllForUser({ platformId, userId, isPrivileged })

if (!EDITION_REQUIRES_RBAC || user.platformRole === PlatformRole.ADMIN) {
return projects
}
if (isPrivileged) {
return projects.filter((project) => project.type !== ProjectType.PERSONAL || project.ownerId === userId)
}
const mcpProjectIds = new Set(await projectMemberService(log).listProjectIdsWithPermission({ userId, platformId, permission: Permission.READ_MCP }))
return projects.filter((project) => mcpProjectIds.has(project.id) || (project.type === ProjectType.PERSONAL && project.ownerId === userId))
}

export const mcpAccess = {
listMcpAccessibleProjects,
}
13 changes: 4 additions & 9 deletions packages/server/api/src/app/mcp/mcp-permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,10 @@ export async function resolvePermissionChecker({ userId, projectId, log }: {
}
catch (err) {
if (err instanceof ActivepiecesError && err.error.code === ErrorCode.AUTHORIZATION) {
return buildChecker((permission, toolTitle) => {
if (isNil(permission)) {
return null
}
return {
content: [{ type: 'text' as const, text: `❌ Permission denied: no role found for this user in the project. Cannot execute "${toolTitle}".` }],
isError: true,
}
})
return buildChecker((_permission, toolTitle) => ({
content: [{ type: 'text' as const, text: `❌ Permission denied: no role found for this user in the project. Cannot execute "${toolTitle}".` }],
isError: true,
}))
}
throw err
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { isNil } from '@activepieces/core-utils'
import { PlatformRole, PrincipalType } from '@activepieces/shared'
import { PrincipalType } from '@activepieces/shared'
import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'
import { z } from 'zod'
import { securityAccess } from '../../../core/security/authorization/fastify-security'
import { JwtAudience, jwtUtils } from '../../../helper/jwt-utils'
import { projectService } from '../../../project/project-service'
import { userService } from '../../../user/user-service'
import { mcpAccess } from '../../mcp-access'
import { mcpOAuthCodeService } from './mcp-oauth-code.service'

export const mcpOAuthApproveController: FastifyPluginAsyncZod = async (app) => {
Expand All @@ -15,22 +14,15 @@ export const mcpOAuthApproveController: FastifyPluginAsyncZod = async (app) => {
const userId = req.principal.id
const platformId = req.principal.platform.id

const mcpAccessibleProjects = await mcpAccess.listMcpAccessibleProjects({ platformId, userId, log: req.log })

if (isNil(projectId)) {
const user = await userService(req.log).getOneOrFail({ id: userId })
if (user.platformRole !== PlatformRole.ADMIN) {
return reply.status(403).send({ error: 'access_denied', error_description: 'Only platform administrators can authorize platform-wide MCP access' })
if (mcpAccessibleProjects.length === 0) {
return reply.status(403).send({ error: 'access_denied', error_description: 'You do not have MCP access in any project' })
}
}
else {
const user = await userService(req.log).getOneOrFail({ id: userId })
const accessibleProjects = await projectService(req.log).getAllForUser({
platformId,
userId,
isPrivileged: userService(req.log).isUserPrivileged(user),
})
if (!accessibleProjects.some(p => p.id === projectId)) {
return reply.status(403).send({ error: 'access_denied', error_description: 'You do not have access to this project' })
}
else if (!mcpAccessibleProjects.some(p => p.id === projectId)) {
return reply.status(403).send({ error: 'access_denied', error_description: 'You do not have MCP access to this project' })
}

const key = await jwtUtils.getJwtSecret()
Expand Down
10 changes: 2 additions & 8 deletions packages/server/api/src/app/mcp/tools/ap-set-project-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ import { isNil } from '@activepieces/core-utils'
import { McpToolDefinition } from '@activepieces/shared'
import { FastifyBaseLogger } from 'fastify'
import { z } from 'zod'
import { projectService } from '../../project/project-service'
import { userService } from '../../user/user-service'
import { mcpAccess } from '../mcp-access'
import { mcpProjectSelection, ProjectSelectionScope } from '../mcp-project-selection'

export const apSetProjectContextTool = ({ platformId, userId, selectionScope, log }: {
Expand All @@ -26,12 +25,7 @@ export const apSetProjectContextTool = ({ platformId, userId, selectionScope, lo
execute: async (args: Record<string, unknown>) => {
const projectId = args.projectId as string | undefined

const user = await userService(log).getOneOrFail({ id: userId })
const projects = await projectService(log).getAllForUser({
platformId,
userId,
isPrivileged: userService(log).isUserPrivileged(user),
})
const projects = await mcpAccess.listMcpAccessibleProjects({ platformId, userId, log })

if (!isNil(projectId) && projectId !== '') {
const targetProject = projects.find(p => p.id === projectId)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { FlowStatus, FlowVersionState, Project } from '@activepieces/shared'
import { DefaultProjectRole, FlowStatus, FlowVersionState, Project } from '@activepieces/shared'
import { FastifyInstance } from 'fastify'
import { beforeAll, describe, expect, it } from 'vitest'
import { db } from '../../../helpers/db'
import { MCP_OAUTH_REDIRECT_URI, mcpOAuthTestHelpers } from '../../../helpers/mcp-oauth'
import { createMockFlow, createMockFlowVersion, createMockProject } from '../../../helpers/mocks'
import { createTestContext, TestContext } from '../../../helpers/test-context'
import { createMemberContext, createTestContext, TestContext } from '../../../helpers/test-context'
import { setupTestEnvironment } from '../../../helpers/test-setup'

let app: FastifyInstance
Expand Down Expand Up @@ -128,6 +128,29 @@ describe('platform MCP project selection', () => {
expect(listedBySecond).not.toContain(FLOW_IN_FIRST_PROJECT)
})

it('lets a platform MEMBER authorize platform-wide MCP', async () => {
const member = await createMemberContext(app, ctx, { projectRole: DefaultProjectRole.EDITOR })
const client = await mcpOAuthTestHelpers.registerClient({ app, tokenEndpointAuthMethod: 'none' })
const { challenge } = mcpOAuthTestHelpers.generatePkce()

const consent = await app.inject({
method: 'GET',
url: '/authorize?' + new URLSearchParams({
client_id: client.client_id,
redirect_uri: MCP_OAUTH_REDIRECT_URI,
response_type: 'code',
code_challenge: challenge,
code_challenge_method: 'S256',
scope: 'mcp',
resource: PLATFORM_RESOURCE,
}).toString(),
})
const authRequestId = new URL(String(consent.headers.location), MCP_OAUTH_REDIRECT_URI).searchParams.get('authRequestId') ?? ''

const approved = await member.post('/v1/mcp-oauth/approve', { authRequestId })
expect(approved.statusCode).toBe(200)
})

it('asks a client with no selection of its own to pick a project', async () => {
const alreadySelectedClient = await connectPlatformWideClient()
await callTool({ accessToken: alreadySelectedClient, name: 'ap_set_project_context', args: { projectId: ctx.project.id } })
Expand Down
75 changes: 75 additions & 0 deletions packages/server/api/test/integration/cloud/mcp/mcp-access.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { apId, Permission, RoleType } from '@activepieces/core-utils'
import { DefaultProjectRole, PlatformRole, ProjectType } from '@activepieces/shared'
import { FastifyBaseLogger, FastifyInstance } from 'fastify'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { mcpAccess } from '../../../../src/app/mcp/mcp-access'
import { db } from '../../../helpers/db'
import { createMockProject, createMockProjectRole, mockBasicUser } from '../../../helpers/mocks'
import { createMemberContext, createTestContext } from '../../../helpers/test-context'
import { setupTestEnvironment, teardownTestEnvironment } from '../../../helpers/test-setup'

let app: FastifyInstance
let mockLog: FastifyBaseLogger

beforeAll(async () => {
app = await setupTestEnvironment()
mockLog = app.log
})

afterAll(async () => {
await teardownTestEnvironment()
})

describe('mcpAccess.listMcpAccessibleProjects', () => {
it('returns every project for a privileged (admin) user', async () => {
const ctx = await createTestContext(app)

const projects = await mcpAccess.listMcpAccessibleProjects({ platformId: ctx.platform.id, userId: ctx.user.id, log: mockLog })

expect(projects.map(p => p.id)).toContain(ctx.project.id)
})

it('includes a project whose member role grants READ_MCP', async () => {
const ctx = await createTestContext(app)
const member = await createMemberContext(app, ctx, { projectRole: DefaultProjectRole.EDITOR })

const projects = await mcpAccess.listMcpAccessibleProjects({ platformId: ctx.platform.id, userId: member.user.id, log: mockLog })

expect(projects.map(p => p.id)).toContain(ctx.project.id)
})

it('gives an operator team projects but not other users\' personal projects', async () => {
const ctx = await createTestContext(app)
const { mockUser: operator } = await mockBasicUser({
user: { platformId: ctx.platform.id, platformRole: PlatformRole.OPERATOR },
})
const otherPersonalProject = createMockProject({
platformId: ctx.platform.id,
ownerId: ctx.user.id,
type: ProjectType.PERSONAL,
})
await db.save('project', otherPersonalProject)

const projects = await mcpAccess.listMcpAccessibleProjects({ platformId: ctx.platform.id, userId: operator.id, log: mockLog })
const ids = projects.map(p => p.id)

expect(ids).toContain(ctx.project.id)
expect(ids).not.toContain(otherPersonalProject.id)
})

it('excludes a project whose member role lacks READ_MCP', async () => {
const ctx = await createTestContext(app)
const noMcpRole = createMockProjectRole({
platformId: ctx.platform.id,
name: `no-mcp-${apId()}`,
permissions: [Permission.READ_FLOW],
type: RoleType.CUSTOM,
})
await db.save('project_role', noMcpRole)
const member = await createMemberContext(app, ctx, { projectRole: noMcpRole.name })

const projects = await mcpAccess.listMcpAccessibleProjects({ platformId: ctx.platform.id, userId: member.user.id, log: mockLog })

expect(projects.map(p => p.id)).not.toContain(ctx.project.id)
})
})
23 changes: 22 additions & 1 deletion packages/server/api/test/integration/cloud/mcp/mcp-rbac.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { apId, Permission } from '@activepieces/core-utils'
import { DefaultProjectRole, McpServerType, ProjectScopedMcpServer } from '@activepieces/shared'
import { DefaultProjectRole, McpServerType, PlatformRole, ProjectScopedMcpServer } from '@activepieces/shared'
import { FastifyBaseLogger, FastifyInstance } from 'fastify'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { resolvePermissionChecker } from '../../../../src/app/mcp/mcp-permissions'
import { apCreateFlowTool } from '../../../../src/app/mcp/tools/ap-create-flow'
import { apListFlowsTool } from '../../../../src/app/mcp/tools/ap-list-flows'
import { apSetupGuideTool } from '../../../../src/app/mcp/tools/ap-setup-guide'
import { mockBasicUser } from '../../../helpers/mocks'
import { createMemberContext, createTestContext } from '../../../helpers/test-context'
import { setupTestEnvironment, teardownTestEnvironment } from '../../../helpers/test-setup'

Expand Down Expand Up @@ -106,6 +107,26 @@ describe('MCP Tool RBAC', () => {
const execute = checker.wrapExecute({ execute: tool.execute, permission: tool.permission, toolTitle: tool.title })
expect(execute).toBe(tool.execute)
})

it('denies every tool, even no-permission ones, when the user has no role in the project', async () => {
const ctx = await createTestContext(app)
const { mockUser } = await mockBasicUser({
user: { platformId: ctx.platform.id, platformRole: PlatformRole.MEMBER },
})
const mcp = makeMcp(ctx.project.id)

const checker = await resolvePermissionChecker({ userId: mockUser.id, projectId: ctx.project.id, log: mockLog })
const tool = apSetupGuideTool(mcp, mockLog)

const error = checker.check(tool.permission, tool.title)
expect(error).not.toBeNull()
expect(error!.isError).toBe(true)
expect(text(error!)).toContain('no role')

const execute = checker.wrapExecute({ execute: tool.execute, permission: tool.permission, toolTitle: tool.title })
const result = await execute({})
expect(text(result)).toContain('Permission denied')
})
})

})