feat: Serverless Functions as AI Tools (#16919)

## Summary

This PR enables serverless functions to be exposed as AI tools, allowing
them to be used by AI agents.

### Changes

- Added new `SERVERLESS_FUNCTION` tool category
- Added `toolDescription`, `toolInputSchema`, and `toolOutputSchema`
fields to serverless functions
- Created database migration for the new schema columns
- Added tool index query and resolver for fetching available tools
- Added Settings AI page tabs (Skills, Tools, Settings) with new tools
table
- Added utility to convert tool schema to JSON schema format
- Updated frontend to display tools in the settings page

### Implementation Details

- Serverless functions can now define tool metadata (description,
input/output schemas)
- These functions are automatically registered in the tool registry
- The tool index endpoint allows querying available tools with their
schemas
- Settings page now has a dedicated Tools tab showing all available
tools
This commit is contained in:
Félix Malfait
2026-01-05 11:13:06 +01:00
committed by GitHub
parent c0d71f7f96
commit 0173e40a20
76 changed files with 2137 additions and 1304 deletions
@@ -11,14 +11,14 @@ import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entit
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { ToolProviderService } from 'src/engine/core-modules/tool-provider/services/tool-provider.service';
import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
describe('McpProtocolService', () => {
let service: McpProtocolService;
let featureFlagService: jest.Mocked<FeatureFlagService>;
let toolProviderService: jest.Mocked<ToolProviderService>;
let toolRegistryService: jest.Mocked<ToolRegistryService>;
let userRoleService: jest.Mocked<UserRoleService>;
let mcpToolExecutorService: jest.Mocked<McpToolExecutorService>;
let apiKeyRoleService: jest.Mocked<ApiKeyRoleService>;
@@ -37,8 +37,8 @@ describe('McpProtocolService', () => {
isFeatureEnabled: jest.fn(),
};
const mockToolProviderService = {
getTools: jest.fn(),
const mockToolRegistryService = {
getToolsByCategories: jest.fn(),
};
const mockUserRoleService = {
@@ -62,8 +62,8 @@ describe('McpProtocolService', () => {
useValue: mockFeatureFlagService,
},
{
provide: ToolProviderService,
useValue: mockToolProviderService,
provide: ToolRegistryService,
useValue: mockToolRegistryService,
},
{
provide: UserRoleService,
@@ -82,7 +82,7 @@ describe('McpProtocolService', () => {
service = module.get<McpProtocolService>(McpProtocolService);
featureFlagService = module.get(FeatureFlagService);
toolProviderService = module.get(ToolProviderService);
toolRegistryService = module.get(ToolRegistryService);
userRoleService = module.get(UserRoleService);
mcpToolExecutorService = module.get(McpToolExecutorService);
apiKeyRoleService = module.get(ApiKeyRoleService);
@@ -227,7 +227,7 @@ describe('McpProtocolService', () => {
testTool: mockTool,
};
toolProviderService.getTools.mockResolvedValue(mockToolsMap);
toolRegistryService.getToolsByCategories.mockResolvedValue(mockToolsMap);
const mockToolCallResponse = {
id: '123',
@@ -282,7 +282,7 @@ describe('McpProtocolService', () => {
testTool: mockTool,
};
toolProviderService.getTools.mockResolvedValue(mockToolsMap);
toolRegistryService.getToolsByCategories.mockResolvedValue(mockToolsMap);
const mockToolCallResponse = {
id: '123',
@@ -316,7 +316,7 @@ describe('McpProtocolService', () => {
});
expect(result).toEqual(mockToolCallResponse);
expect(toolProviderService.getTools).toHaveBeenCalled();
expect(toolRegistryService.getToolsByCategories).toHaveBeenCalled();
});
it('should handle tools listing', async () => {
@@ -330,7 +330,7 @@ describe('McpProtocolService', () => {
},
};
toolProviderService.getTools.mockResolvedValue(mockToolsMap);
toolRegistryService.getToolsByCategories.mockResolvedValue(mockToolsMap);
const mockToolsListingResponse = {
id: '123',
@@ -398,7 +398,7 @@ describe('McpProtocolService', () => {
it('should handle error when tool is not found', async () => {
featureFlagService.isFeatureEnabled.mockResolvedValue(true);
userRoleService.getRoleIdForUserWorkspace.mockResolvedValue(mockRoleId);
toolProviderService.getTools.mockResolvedValue({});
toolRegistryService.getToolsByCategories.mockResolvedValue({});
mcpToolExecutorService.handleToolCall.mockRejectedValue(
new HttpException(
@@ -9,14 +9,14 @@ import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/service
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { ToolProviderService } from 'src/engine/core-modules/tool-provider/services/tool-provider.service';
import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@Injectable()
export class MCPMetadataService {
constructor(
private readonly featureFlagService: FeatureFlagService,
private readonly toolProvider: ToolProviderService,
private readonly toolRegistry: ToolRegistryService,
private readonly metricsService: MetricsService,
) {}
@@ -50,11 +50,18 @@ export class MCPMetadataService {
}
async getTools(workspaceId: string): Promise<ToolSet> {
return this.toolProvider.getTools({
workspaceId,
categories: [ToolCategory.METADATA],
wrapWithErrorContext: false,
});
// Metadata tools don't require role-based permissions, using empty roleId
return this.toolRegistry.getToolsByCategories(
{
workspaceId,
roleId: '',
rolePermissionConfig: { unionOf: [] },
},
{
categories: [ToolCategory.METADATA],
wrapWithErrorContext: false,
},
);
}
async handleToolCall(
@@ -12,7 +12,7 @@ import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { ToolProviderService } from 'src/engine/core-modules/tool-provider/services/tool-provider.service';
import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
import { ToolType } from 'src/engine/core-modules/tool/enums/tool-type.enum';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
@@ -21,7 +21,7 @@ import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role
export class McpProtocolService {
constructor(
private readonly featureFlagService: FeatureFlagService,
private readonly toolProvider: ToolProviderService,
private readonly toolRegistry: ToolRegistryService,
private readonly userRoleService: UserRoleService,
private readonly mcpToolExecutorService: McpToolExecutorService,
private readonly apiKeyRoleService: ApiKeyRoleService,
@@ -143,16 +143,22 @@ export class McpProtocolService {
apiKey,
);
const toolSet = await this.toolProvider.getTools({
workspaceId: workspace.id,
categories: [ToolCategory.DATABASE_CRUD, ToolCategory.ACTION],
rolePermissionConfig: { unionOf: [roleId] },
authContext,
wrapWithErrorContext: false,
// Exclude code_interpreter from MCP to prevent recursive execution attacks
// (code running in the sandbox could call code_interpreter via MCP)
excludeTools: [ToolType.CODE_INTERPRETER],
});
// Exclude code_interpreter from MCP to prevent recursive execution attacks
// (code running in the sandbox could call code_interpreter via MCP)
const toolSet = await this.toolRegistry.getToolsByCategories(
{
workspaceId: workspace.id,
roleId,
rolePermissionConfig: { unionOf: [roleId] },
authContext,
userWorkspaceId,
},
{
categories: [ToolCategory.DATABASE_CRUD, ToolCategory.ACTION],
excludeTools: [ToolType.CODE_INTERPRETER],
wrapWithErrorContext: false,
},
);
if (method === 'tools/call' && params) {
return await this.mcpToolExecutorService.handleToolCall(