Skip to content
Merged
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
17 changes: 9 additions & 8 deletions packages/server/api/src/app/chat/chat-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import { paginationHelper } from '../helper/pagination/pagination-utils'
import { Order } from '../helper/pagination/paginator'
import { system } from '../helper/system/system'
import { AppSystemProp } from '../helper/system/system-props'
import { mcpServerService } from '../mcp/mcp-service'
import { mcpOAuthTokenService } from '../mcp/oauth/token/mcp-oauth-token.service'
import { projectService } from '../project/project-service'
import { chatCompaction } from './chat-compaction'
import { ChatConversationEntity } from './chat-conversation-entity'
Expand Down Expand Up @@ -120,7 +120,7 @@ export const chatService = (log: FastifyBaseLogger) => ({
const [conversation, providerConfig, mcpCredentials, projectName, userContent] = await Promise.all([
this.getConversationOrThrow({ id: conversationId, projectId, userId }),
resolveChatProvider({ platformId, log }),
getMcpCredentials({ projectId, log }),
getMcpCredentials({ platformId, userId, log }),
projectService(log).getOneOrThrow(projectId).then((p) => p.displayName),
buildUserContentWithFiles({ text: content, files }),
])
Expand Down Expand Up @@ -315,17 +315,18 @@ async function connectMcpClient({ mcpCredentials, log }: {
return { mcpClient: client, mcpToolSet }
}

async function getMcpCredentials({ projectId, log }: { projectId: string, log: FastifyBaseLogger }): Promise<{ mcpServerUrl: string | null, mcpToken: string | null }> {
const { data: mcpServer, error } = await tryCatch(async () => mcpServerService(log).getByProjectId(projectId))
async function getMcpCredentials({ platformId, userId, log }: { platformId: string, userId: string, log: FastifyBaseLogger }): Promise<{ mcpServerUrl: string | null, mcpToken: string | null }> {
const { data: accessToken, error } = await tryCatch(() =>
mcpOAuthTokenService.issueInternalAccessToken({ userId, platformId, projectId: null }),
)
if (error) {
log.warn({ err: error, projectId }, 'Failed to get MCP credentials — chat will work without MCP tools')
log.warn({ err: error, platformId }, 'Failed to get MCP credentials — chat will work without MCP tools')
return { mcpServerUrl: null, mcpToken: null }
}
const frontendUrl = system.getOrThrow(AppSystemProp.FRONTEND_URL)
const mcpServerUrl = `${frontendUrl}/mcp`
return {
mcpServerUrl,
mcpToken: mcpServer.token,
mcpServerUrl: `${frontendUrl}/mcp/platform`,
mcpToken: accessToken,
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { QueryRunner } from 'typeorm'
import { Migration } from '../../migration'

export class AddPlatformMcpServer1788000000000 implements Migration {
name = 'AddPlatformMcpServer1788000000000'
breaking = false
release = '0.82.1'
transaction = true

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "mcp_server"
ADD COLUMN "platformId" varchar(21),
ADD COLUMN "type" varchar
`)

await queryRunner.query(`
UPDATE "mcp_server"
SET "type" = CASE
WHEN "projectId" IS NOT NULL THEN 'PROJECT'
ELSE 'PLATFORM'
END
`)

await queryRunner.query(`
ALTER TABLE "mcp_server"
ALTER COLUMN "type" SET NOT NULL
`)

await queryRunner.query(`
ALTER TABLE "mcp_server"
ALTER COLUMN "projectId" DROP NOT NULL
`)

await queryRunner.query(`
ALTER TABLE "mcp_server"
ADD CONSTRAINT "fk_mcp_server_platform_id"
FOREIGN KEY ("platformId") REFERENCES "platform"("id")
ON DELETE CASCADE ON UPDATE NO ACTION
`)

await queryRunner.query(`
CREATE UNIQUE INDEX "idx_mcp_server_platform_id"
ON "mcp_server" ("platformId")
`)
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP INDEX IF EXISTS "idx_mcp_server_platform_id"
`)

await queryRunner.query(`
ALTER TABLE "mcp_server"
DROP CONSTRAINT IF EXISTS "fk_mcp_server_platform_id"
`)

// Remove platform MCP servers before making projectId NOT NULL
await queryRunner.query(`
DELETE FROM "mcp_server" WHERE "type" = 'PLATFORM'
`)

await queryRunner.query(`
ALTER TABLE "mcp_server"
ALTER COLUMN "projectId" SET NOT NULL
`)

await queryRunner.query(`
ALTER TABLE "mcp_server"
DROP COLUMN "type",
DROP COLUMN "platformId"
`)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { QueryRunner } from 'typeorm'
import { Migration } from '../../migration'

export class MakeMcpOAuthProjectIdNullable1789000000000 implements Migration {
name = 'MakeMcpOAuthProjectIdNullable1789000000000'
release = '0.82.1'
breaking = false

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE "mcp_oauth_authorization_code" ALTER COLUMN "projectId" DROP NOT NULL')
await queryRunner.query('ALTER TABLE "mcp_oauth_token" ALTER COLUMN "projectId" DROP NOT NULL')
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DELETE FROM "mcp_oauth_token" WHERE "projectId" IS NULL')
await queryRunner.query('DELETE FROM "mcp_oauth_authorization_code" WHERE "projectId" IS NULL')
await queryRunner.query('ALTER TABLE "mcp_oauth_authorization_code" ALTER COLUMN "projectId" SET NOT NULL')
await queryRunner.query('ALTER TABLE "mcp_oauth_token" ALTER COLUMN "projectId" SET NOT NULL')
}
}
4 changes: 4 additions & 0 deletions packages/server/api/src/app/database/postgres-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,8 @@ import { AddUserSandboxTable1784000000000 } from './migration/postgres/178400000
import { ReplacesSandboxWithVercelAiSdk1785000000000 } from './migration/postgres/1785000000000-ReplacesSandboxWithVercelAiSdk'
import { AddChatCompactionColumns1786000000000 } from './migration/postgres/1786000000000-AddChatCompactionColumns'
import { AddSsoDomainVerification1787100000000 } from './migration/postgres/1787100000000-AddSsoDomainVerification'
import { AddPlatformMcpServer1788000000000 } from './migration/postgres/1788000000000-AddPlatformMcpServer'
import { MakeMcpOAuthProjectIdNullable1789000000000 } from './migration/postgres/1789000000000-MakeMcpOAuthProjectIdNullable'

const getSslConfig = (): boolean | TlsOptions => {
const useSsl = system.get(AppSystemProp.POSTGRES_USE_SSL)
Expand Down Expand Up @@ -751,6 +753,8 @@ export const getMigrations = (): (new () => Migration)[] => {
ReplacesSandboxWithVercelAiSdk1785000000000,
AddChatCompactionColumns1786000000000,
AddSsoDomainVerification1787100000000,
AddPlatformMcpServer1788000000000,
MakeMcpOAuthProjectIdNullable1789000000000,
]
return migrations
}
Expand Down
35 changes: 30 additions & 5 deletions packages/server/api/src/app/mcp/mcp-entity.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,28 @@
import { McpServer, Project } from '@activepieces/shared'
import { McpServer, Platform, Project } from '@activepieces/shared'
import { EntitySchema } from 'typeorm'
import { ApIdSchema, BaseColumnSchemaPart } from '../database/database-common'

type McpServerWithSchema = McpServer & {
type McpServerWithSchema = McpServer & {
platform: Platform
project: Project
}

export const McpServerEntity = new EntitySchema<McpServerWithSchema>({
name: 'mcp_server',
columns: {
...BaseColumnSchemaPart,
projectId: ApIdSchema,
platformId: {
...ApIdSchema,
nullable: true,
},
projectId: {
...ApIdSchema,
nullable: true,
},
type: {
type: String,
nullable: false,
},
status: {
type: String,
nullable: false,
Expand All @@ -35,8 +47,23 @@ export const McpServerEntity = new EntitySchema<McpServerWithSchema>({
columns: ['token'],
unique: true,
},
{
name: 'idx_mcp_server_platform_id',
columns: ['platformId'],
unique: true,
},
],
relations: {
platform: {
type: 'many-to-one',
target: 'platform',
cascade: true,
onDelete: 'CASCADE',
joinColumn: {
name: 'platformId',
foreignKeyConstraintName: 'fk_mcp_server_platform_id',
},
},
project: {
type: 'many-to-one',
target: 'project',
Expand All @@ -48,6 +75,4 @@ export const McpServerEntity = new EntitySchema<McpServerWithSchema>({
},
},
},

})

3 changes: 2 additions & 1 deletion packages/server/api/src/app/mcp/mcp-module.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'
import { mcpPlatformController } from './mcp-platform-controller'
import { mcpServerController } from './mcp-server-controller'

export const mcpServerModule: FastifyPluginAsyncZod = async (app) => {
await app.register(mcpServerController, { prefix: '/v1/projects/:projectId/mcp-server' })

await app.register(mcpPlatformController, { prefix: '/v1/mcp-server' })
}
69 changes: 69 additions & 0 deletions packages/server/api/src/app/mcp/mcp-permissions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { ActivepiecesError, ApEdition, ErrorCode, isNil, McpToolDefinition, Permission } from '@activepieces/shared'
import { FastifyBaseLogger } from 'fastify'
import { getPrincipalRoleOrThrow } from '../ee/authentication/project-role/rbac-middleware'
import { system } from '../helper/system/system'

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

export async function resolvePermissionChecker({ userId, projectId, log }: {
userId: string
projectId: string
log: FastifyBaseLogger
}): Promise<PermissionChecker> {
if (!EDITION_REQUIRES_RBAC) {
return ALLOW_ALL
}

try {
const role = await getPrincipalRoleOrThrow(userId, projectId, log)
const permissionSet = new Set(role.permissions ?? [])
return buildChecker((permission, toolTitle) => {
if (isNil(permission) || permissionSet.has(permission)) {
return null
}
return {
content: [{ type: 'text' as const, text: `❌ Permission denied: your role does not have the "${permission}" permission required to use "${toolTitle}".` }],
isError: true,
}
})
}
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,
}
})
}
throw err
}
}

export const ALLOW_ALL: PermissionChecker = {
check: () => null,
wrapExecute: ({ execute }) => execute,
}

function buildChecker(check: PermissionChecker['check']): PermissionChecker {
return {
check,
wrapExecute: ({ execute, permission, toolTitle }) => {
const error = check(permission, toolTitle)
return isNil(error) ? execute : async () => error
},
}
}

export type PermissionChecker = {
check: (permission: Permission | undefined, toolTitle: string) => McpToolErrorResult | null
wrapExecute: (params: { execute: McpToolDefinition['execute'], permission: Permission | undefined, toolTitle: string }) => McpToolDefinition['execute']
}

type McpToolErrorResult = {
content: Array<{ type: 'text', text: string }>
isError: boolean
}
60 changes: 60 additions & 0 deletions packages/server/api/src/app/mcp/mcp-platform-controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { PrincipalType, SERVICE_KEY_SECURITY_OPENAPI, UpdateMcpServerRequest } from '@activepieces/shared'
import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'
import { securityAccess } from '../core/security/authorization/fastify-security'
import { mcpServerService } from './mcp-service'

export const mcpPlatformController: FastifyPluginAsyncZod = async (app) => {

app.get('/', GetPlatformMcpRoute, async (req) => {
return mcpServerService(req.log).getByPlatformId(req.principal.platform.id)
})

app.post('/', UpdatePlatformMcpRoute, async (req) => {
const { status, enabledTools } = req.body
return mcpServerService(req.log).updatePlatform({
platformId: req.principal.platform.id,
status,
enabledTools,
})
})

app.post('/rotate', RotatePlatformTokenRoute, async (req) => {
return mcpServerService(req.log).rotatePlatformToken({
platformId: req.principal.platform.id,
})
})
}

const GetPlatformMcpRoute = {
config: {
security: securityAccess.platformAdminOnly([PrincipalType.USER]),
},
schema: {
tags: ['mcp'],
description: 'Get the platform MCP server configuration',
security: [SERVICE_KEY_SECURITY_OPENAPI],
},
}

const UpdatePlatformMcpRoute = {
config: {
security: securityAccess.platformAdminOnly([PrincipalType.USER]),
},
schema: {
tags: ['mcp'],
description: 'Update the platform MCP server configuration',
security: [SERVICE_KEY_SECURITY_OPENAPI],
body: UpdateMcpServerRequest,
},
}

const RotatePlatformTokenRoute = {
config: {
security: securityAccess.platformAdminOnly([PrincipalType.USER]),
},
schema: {
tags: ['mcp'],
description: 'Rotate the platform MCP server token',
security: [SERVICE_KEY_SECURITY_OPENAPI],
},
}
17 changes: 17 additions & 0 deletions packages/server/api/src/app/mcp/mcp-project-selection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
const selections = new Map<string, string>()

function makeKey({ platformId, userId }: { platformId: string, userId: string }): string {
return `${platformId}:${userId}`
}

export const mcpProjectSelection = {
get({ platformId, userId }: { platformId: string, userId: string }): string | null {
return selections.get(makeKey({ platformId, userId })) ?? null
},
set({ platformId, userId, projectId }: { platformId: string, userId: string, projectId: string }): void {
selections.set(makeKey({ platformId, userId }), projectId)
},
clear({ platformId, userId }: { platformId: string, userId: string }): void {
selections.delete(makeKey({ platformId, userId }))
},
}
Loading
Loading