[core] MCP - fix #1925

Do not restart session when updating some apps - AppDefinition.restartOnChange
This commit is contained in:
devlikepro committed 2026-05-07 14:14:19 +07:00
1 parent 376676442e
commit a331fd11bd
63 files changed
+5334 -50

No files matched your search

+38 -2
View File
@@ -51,12 +51,19 @@ This guide summarizes how to explore, modify, and validate the WhatsApp HTTP API
- Respect path aliases (`@waha/...`) defined in `tsconfig.json`
- Prefer named function declarations over `const` arrow functions
- Avoid naming unused variables with a leading underscore
- Always use explicit property names in object literals — never shorthand: write
`{ key: value }`, not `{ value }` (even when the variable name matches the
key)
- Do not write verbose ternaries; use idiomatic helpers like `??` (nullish
coalescing)
- Do not place `await` or other async calls inside ternary expressions (`?:`);
use explicit `if/else` blocks
- Do not place `await` or other async calls inside ternary expressions (`?:`) or
nullish-coalescing expressions (`??`); use explicit `if/else` blocks or assign
the awaited value to a variable first
- For configs, prefer runtime configurability over constants (environment keys
follow `WAHA_*` and `WAHA_SESSION_CONFIG_*`)
- Do not use decorative comment blocks (lines of dashes/underscores with a
label) such as `// ─────────── NAME ───────────`; use plain inline comments or
no comment at all
## How to Run API
@@ -86,6 +93,35 @@ npm run start
- Skip it on methods that only throw `NotImplementedByEngineError` /
`AvailableInPlusVersion`
## MCP Tools
MCP tools live in `src/apps/mcp/tools/` and expose the HTTP API to AI clients.
Each tool file mirrors an API domain (e.g. `chats.tools.ts` → chats endpoints).
**When you change an existing API endpoint:**
- Check the corresponding `*.tools.ts` file and update the tool's `inputSchema`,
description, or behavior if the API signature changed.
**When you add a new API endpoint:**
- Ask the user whether an MCP tool is needed for the new endpoint before
creating one.
- If yes, add the tool to the matching `*.tools.ts` file (or create a new file
for a new domain).
- Every `@Tool` decorator must include an `annotations` block with all three
fields:
```typescript
annotations: {
readOnlyHint: true | false, // true = no side effects (GET-style)
destructiveHint: true | false, // true = irreversible deletion/logout
idempotentHint: true | false, // true = safe to repeat with same args
}
```
- Input schemas live in the matching `*.zod.ts` file.
- Tools call the API via `this.textRequest({ method, url, ... })` inherited from
`McpController`.
## Related Sources
- WEBJS: `../whatsapp-web.js`
+3 -1
View File
@@ -39,6 +39,7 @@
"@casl/ability": "^6.8.0",
"@figuro/chatwoot-sdk": "^1.1.17",
"@liaoliaots/nestjs-redis": "^9",
"@modelcontextprotocol/sdk": "^1.29.0",
"@nestjs/axios": "^3.0.2",
"@nestjs/bullmq": "^11.0.2",
"@nestjs/common": "^11.0.0",
@@ -116,7 +117,8 @@
"user-agents": "^1.1.669",
"whatsapp-web.js": "github:devlikeapro/whatsapp-web.js#fork-main-2026-02-18",
"write-file-atomic": "^6.0.0",
"yaml": "^2.7.1"
"yaml": "^2.7.1",
"zod": "^4.3.6"
},
"optionalDependencies": {
"bufferutil": "^4.0.8"
+6 -5
View File
@@ -31,6 +31,7 @@ import {
import { Action, session as SessionName } from '@waha/core/auth/casl.types';
import { WAHAValidationPipe } from '@waha/nestjs/pipes/WAHAValidationPipe';
import { APPS } from '../apps/definition';
import { App } from '../dto/app.dto';
import { ListAppsQuery } from '../dto/query.dto';
@@ -62,7 +63,7 @@ export class AppsController {
async create(@Body() app: App): Promise<App> {
const result = await this.appsService.create(this.manager, app);
const isRunning = this.manager.isRunning(app.session);
if (isRunning && app.enabled) {
if (isRunning && app.enabled && APPS[app.app].restartOnChange) {
await this.manager.restart(app.session);
}
return result;
@@ -112,9 +113,9 @@ export class AppsController {
}
const result = await this.appsService.upsert(this.manager, app);
const isRunning = this.manager.isRunning(app.session);
if (isRunning) {
await this.manager.restart(app.session);
const isRunning = this.manager.isRunning(result.session);
if (isRunning && APPS[result.app].restartOnChange) {
await this.manager.restart(result.session);
}
return result;
}
@@ -133,7 +134,7 @@ export class AppsController {
}
const app = await this.appsService.delete(this.manager, id);
const isRunning = this.manager.isRunning(app.session);
if (isRunning) {
if (isRunning && APPS[app.app].restartOnChange) {
await this.manager.restart(app.session);
}
}
+11
View File
@@ -9,6 +9,8 @@ export interface AppDefinition {
queue: boolean;
// If app has any migrations
migrations: boolean;
// If adding, updating, or removing this app requires a session restart
restartOnChange: boolean;
}
// All Apps
@@ -18,11 +20,20 @@ export const APPS: Record<AppName, AppDefinition> = {
plainkey: false,
queue: false,
migrations: false,
restartOnChange: true,
},
[AppName.chatwoot]: {
name: AppName.chatwoot,
plainkey: true,
queue: true,
migrations: true,
restartOnChange: true,
},
[AppName.mcp]: {
name: AppName.mcp,
plainkey: false,
queue: false,
migrations: false,
restartOnChange: false,
},
};
+1
View File
@@ -1,4 +1,5 @@
export enum AppName {
chatwoot = 'chatwoot',
calls = 'calls',
mcp = 'mcp',
}
+14 -3
View File
@@ -1,5 +1,6 @@
import { ChatWootAppConfig } from '@waha/apps/chatwoot/dto/config.dto';
import { CallsAppConfig } from '@waha/apps/calls/dto/config.dto';
import { McpAppConfig } from '@waha/apps/mcp/dto/config.dto';
import { Type } from 'class-transformer';
import {
IsBoolean,
@@ -11,9 +12,12 @@ import {
import { ApiExtraModels, ApiProperty } from '@nestjs/swagger';
import { AppName } from '@waha/apps/app_sdk/apps/name';
export type AllowedAppConfig = ChatWootAppConfig | CallsAppConfig;
export type AllowedAppConfig =
| ChatWootAppConfig
| CallsAppConfig
| McpAppConfig;
@ApiExtraModels(ChatWootAppConfig, CallsAppConfig)
@ApiExtraModels(ChatWootAppConfig, CallsAppConfig, McpAppConfig)
export class App<T extends AllowedAppConfig = any> {
@IsString()
id: string;
@@ -43,6 +47,8 @@ export class App<T extends AllowedAppConfig = any> {
return ChatWootAppConfig;
case AppName.calls:
return CallsAppConfig;
case AppName.mcp:
return McpAppConfig;
default:
return Object;
}
@@ -62,4 +68,9 @@ export class CallsAppDto extends App<CallsAppConfig> {
config: CallsAppConfig;
}
export type AppDto = ChatWootAppDto | CallsAppDto;
export class McpAppDto extends App<McpAppConfig> {
@Type(() => McpAppConfig)
config: McpAppConfig;
}
export type AppDto = ChatWootAppDto | CallsAppDto | McpAppDto;
@@ -9,6 +9,7 @@ import { IAppService } from '@waha/apps/app_sdk/services/IAppService';
import { IAppsService } from '@waha/apps/app_sdk/services/IAppsService';
import { ChatWootAppService } from '@waha/apps/chatwoot/services/ChatWootAppService';
import { CallsAppService } from '@waha/apps/calls/services/CallsAppService';
import { McpAppService } from '@waha/apps/mcp/services/McpAppService';
import { DataStore } from '@waha/core/abc/DataStore';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { WhatsappSession } from '@waha/core/abc/session.abc';
@@ -36,15 +37,18 @@ export class AppsEnabledService implements IAppsService {
protected logger: PinoLogger,
@Optional() protected readonly chatwootService: ChatWootAppService,
@Optional() protected readonly callsAppService: CallsAppService,
@Optional() protected readonly mcpAppService: McpAppService,
) {}
async list(manager: SessionManager, session: string): Promise<App[]> {
const knex = manager.store.getWAHADatabase();
const repo = new AppRepository(knex);
const apps = await repo.getAllBySession(session);
apps.forEach((app) => {
for (const app of apps) {
delete app.pk;
});
const service = this.getAppService(app);
await service?.enrich(manager, app);
}
return apps;
}
@@ -101,6 +105,9 @@ export class AppsEnabledService implements IAppsService {
const result = await repo.save(app);
delete result.pk;
if (app.enabled !== false) {
await service?.afterCreated(manager, result);
}
return result;
}
@@ -112,6 +119,8 @@ export class AppsEnabledService implements IAppsService {
return null;
}
delete (app as any).pk;
const service = this.getAppService(app);
await service?.enrich(manager, app);
return app;
}
@@ -156,12 +165,12 @@ export class AppsEnabledService implements IAppsService {
if (hasEnabledChange) {
if (app.enabled) {
await service?.beforeEnabled(savedApp, app);
await service?.beforeEnabled(manager, savedApp, app);
} else {
await service?.beforeDisabled(savedApp, app);
await service?.beforeDisabled(manager, savedApp, app);
}
} else {
await service?.beforeUpdated(savedApp, app);
await service?.beforeUpdated(manager, savedApp, app);
}
await repo.update(app.id, app);
const updated = await repo.getById(app.id);
@@ -177,7 +186,7 @@ export class AppsEnabledService implements IAppsService {
throw new NotFoundException(`App '${appId}' not found`);
}
const service = this.getAppService(app);
await service?.beforeDeleted(app);
await service?.beforeDeleted(manager, app);
await repo.delete(app.id);
delete app.pk;
return app;
@@ -186,6 +195,11 @@ export class AppsEnabledService implements IAppsService {
async removeBySession(manager: SessionManager, session: string) {
const knex = manager.store.getWAHADatabase();
const repo = new AppRepository(knex);
const apps = await repo.getAllBySession(session);
for (const app of apps) {
const service = this.getAppService(app);
await service?.beforeSessionDeleted(manager, app);
}
await repo.deleteBySession(session);
}
@@ -257,6 +271,8 @@ export class AppsEnabledService implements IAppsService {
return this.chatwootService;
case AppName.calls:
return this.callsAppService;
case AppName.mcp:
return this.mcpAppService;
default:
throw new Error(`App '${app.app}' not supported`);
}
+35 -4
View File
@@ -1,4 +1,5 @@
import { App } from '@waha/apps/app_sdk/dto/app.dto';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { WhatsappSession } from '@waha/core/abc/session.abc';
/**
@@ -9,19 +10,49 @@ export interface IAppService {
beforeCreated(app: App): Promise<void>;
/**
* Called after the app record is saved to the database during creation.
* Use this for side effects that must happen after the app exists in storage.
*/
afterCreated(manager: SessionManager, app: App): Promise<void>;
/**
* Called only when the app transitions from disabled -> enabled.
*/
beforeEnabled(savedApp: App, newApp: App): Promise<void>;
beforeEnabled(
manager: SessionManager,
savedApp: App,
newApp: App,
): Promise<void>;
/**
* Called only when the app transitions from enabled -> disabled.
*/
beforeDisabled(savedApp: App, newApp: App): Promise<void>;
beforeDisabled(
manager: SessionManager,
savedApp: App,
newApp: App,
): Promise<void>;
beforeUpdated(savedApp: App, newApp: App): Promise<void>;
beforeUpdated(
manager: SessionManager,
savedApp: App,
newApp: App,
): Promise<void>;
beforeDeleted(app: App): Promise<void>;
beforeDeleted(manager: SessionManager, app: App): Promise<void>;
/**
* Called for each app before a bulk session deletion removes all app records.
* Use this to clean up external resources tied to the app (e.g. API keys).
*/
beforeSessionDeleted(manager: SessionManager, app: App): Promise<void>;
/**
* Called after reading an app from storage, before returning it to the caller.
* Use this to populate transient fields that are not persisted (e.g. secret values).
*/
enrich(manager: SessionManager, app: App): Promise<void>;
beforeSessionStart(app: App, session: WhatsappSession): void;
+6 -5
View File
@@ -13,8 +13,7 @@ import {
WANumberExistResult,
} from '@waha/structures/chatting.dto';
import { SessionInfo } from '@waha/structures/sessions.dto';
import axios, { AxiosInstance } from 'axios';
import { Auth } from '@waha/core/auth/config';
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
import { ContactSortField } from '@waha/structures/contacts.dto';
import { PaginationParams } from '@waha/structures/pagination.dto';
@@ -25,9 +24,7 @@ export interface RequestOptions {
export class WAHASelf {
public client: AxiosInstance;
constructor() {
// Set 'X-Api-Key'
const key = Auth.keyplain.value;
constructor(public key: string) {
const port =
parseInt(process.env.PORT) ||
parseInt(process.env.WHATSAPP_API_PORT) ||
@@ -42,6 +39,10 @@ export class WAHASelf {
});
}
request(config: AxiosRequestConfig) {
return this.client.request(config);
}
async fetch(url: string, opts?: RequestOptions): Promise<Buffer> {
const response = await this.client.get(url, {
responseType: 'arraybuffer',
+6
View File
@@ -5,6 +5,7 @@ import { BullBoardModule } from '@bull-board/nestjs';
import { ExpressAdapter } from '@bull-board/express';
import { BullAuthMiddleware } from '@waha/apps/app_sdk/auth';
import { ChatWootExports } from '@waha/apps/chatwoot/chatwoot.module';
import { McpModuleExports } from '@waha/apps/mcp/mcp.module';
import { AppsController } from '@waha/apps/app_sdk/api/apps.controller';
import { CallsAppExports } from '@waha/apps/calls/calls.module';
import { AppsService } from '@waha/apps/app_sdk/services/IAppsService';
@@ -83,6 +84,8 @@ function getAppModule(name: AppName) {
return CallsAppExports;
case AppName.chatwoot:
return ChatWootExports;
case AppName.mcp:
return McpModuleExports;
default:
throw Error(`App module not found for ${name}`);
}
@@ -91,11 +94,13 @@ function getAppModule(name: AppName) {
export const AppsEnabled = {
imports: [
...QUEUES_IMPORTS,
...getAppModule(AppName.mcp).imports,
...getAppModule(AppName.chatwoot).imports,
...getAppModule(AppName.calls).imports,
],
controllers: [
AppsController,
...getAppModule(AppName.mcp).controllers,
...getAppModule(AppName.chatwoot).controllers,
...getAppModule(AppName.calls).controllers,
],
@@ -104,6 +109,7 @@ export const AppsEnabled = {
provide: AppsService,
useClass: AppsEnabledService,
},
...getAppModule(AppName.mcp).providers,
...getAppModule(AppName.calls).providers,
...getAppModule(AppName.chatwoot).providers,
],
+37 -4
View File
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { App } from '@waha/apps/app_sdk/dto/app.dto';
import { IAppService } from '@waha/apps/app_sdk/services/IAppService';
import { CallsAppConfig } from '@waha/apps/calls/dto/config.dto';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { WhatsappSession } from '@waha/core/abc/session.abc';
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
import { CallsListener } from '@waha/apps/calls/services/CallsListener';
@@ -25,32 +26,64 @@ export class CallsAppService implements IAppService {
}
async beforeEnabled(
manager: SessionManager,
savedApp: App<CallsAppConfig>,
newApp: App<CallsAppConfig>,
): Promise<void> {
// Enabling behaves the same as creating for this lightweight app.
void manager;
void savedApp;
void newApp;
return;
}
async beforeDisabled(
manager: SessionManager,
savedApp: App<CallsAppConfig>,
newApp: App<CallsAppConfig>,
): Promise<void> {
void newApp;
void manager;
void savedApp;
void newApp;
}
async beforeUpdated(
manager: SessionManager,
savedApp: App<CallsAppConfig>,
newApp: App<CallsAppConfig>,
): Promise<void> {
void manager;
void savedApp;
void newApp;
}
async beforeDeleted(app: App<CallsAppConfig>): Promise<void> {
async beforeDeleted(
manager: SessionManager,
app: App<CallsAppConfig>,
): Promise<void> {
void manager;
void app;
}
async afterCreated(
manager: SessionManager,
app: App<CallsAppConfig>,
): Promise<void> {
void manager;
void app;
}
async beforeSessionDeleted(
manager: SessionManager,
app: App<CallsAppConfig>,
): Promise<void> {
void manager;
void app;
}
async enrich(
manager: SessionManager,
app: App<CallsAppConfig>,
): Promise<void> {
void manager;
void app;
}
+2 -1
View File
@@ -30,6 +30,7 @@ import {
ConversationSelector,
ConversationSort,
} from '@waha/apps/chatwoot/services/ConversationSelector';
import { Auth } from '@waha/core/auth/config';
/**
* Dependency Injection Container for ChatWoot
@@ -194,7 +195,7 @@ export class DIContainer {
*/
@CacheSync()
public WAHASelf(): WAHASelf {
const self = new WAHASelf();
const self = new WAHASelf(Auth.keyplain.value);
const logging = new AxiosLogging(this.Logger());
logging.applyTo(self.client);
return self;
@@ -6,6 +6,7 @@ import { ChatWootAppConfig } from '@waha/apps/chatwoot/dto/config.dto';
import { ChatWootScheduleService } from '@waha/apps/chatwoot/services/ChatWootScheduleService';
import { ChatWootWAHAQueueService } from '@waha/apps/chatwoot/services/ChatWootWAHAQueueService';
import { App } from '@waha/apps/chatwoot/storage';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { WhatsappSession } from '@waha/core/abc/session.abc';
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
@@ -38,27 +39,31 @@ export class ChatWootAppService implements IAppService {
}
async beforeEnabled(
manager: SessionManager,
savedApp: App<ChatWootAppConfig>,
newApp: App<ChatWootAppConfig>,
): Promise<void> {
void manager;
// Enabling -> behave like created
await this.beforeCreated(newApp);
return;
}
async beforeDisabled(
manager: SessionManager,
savedApp: App<ChatWootAppConfig>,
newApp: App<ChatWootAppConfig>,
): Promise<void> {
void manager;
// Disabling -> behave like deleted
await this.beforeDeleted(savedApp);
return;
await this.beforeDeleted(manager, savedApp);
}
async beforeUpdated(
manager: SessionManager,
savedApp: App<ChatWootAppConfig>,
newApp: App<ChatWootAppConfig>,
) {
void manager;
const isTheSameUrl = savedApp.config.url === newApp.config.url;
const isTheSameInboxId = savedApp.config.inboxId === newApp.config.inboxId;
const isTheSameInbox = isTheSameUrl && isTheSameInboxId;
@@ -76,7 +81,11 @@ export class ChatWootAppService implements IAppService {
}
}
async beforeDeleted(app: App<ChatWootAppConfig>): Promise<void> {
async beforeDeleted(
manager: SessionManager,
app: App<ChatWootAppConfig>,
): Promise<void> {
void manager;
await this.chatWootScheduleService.unschedule(app.id, app.session);
this.cleanCache(app);
this.sendDisconnectedMessage(app).catch((err) => {
@@ -86,6 +95,30 @@ export class ChatWootAppService implements IAppService {
});
}
async afterCreated(
manager: SessionManager,
app: App<ChatWootAppConfig>,
): Promise<void> {
void manager;
void app;
}
async beforeSessionDeleted(
manager: SessionManager,
app: App<ChatWootAppConfig>,
): Promise<void> {
void manager;
void app;
}
async enrich(
manager: SessionManager,
app: App<ChatWootAppConfig>,
): Promise<void> {
void manager;
void app;
}
private async sendConnectedMessage(app: App<ChatWootAppConfig>) {
const di = new DIContainer(0, app.config, this.logger, null);
const repo = di.ContactConversationService();
+13
View File
@@ -0,0 +1,13 @@
import { Controller, Post, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express';
import { McpService } from '../mcp.service';
@Controller('mcp')
export class McpController {
constructor(private readonly mcp: McpService) {}
@Post()
post(@Req() req: Request, @Res() res: Response) {
return this.mcp.handlePost(req, res);
}
}
+34
View File
@@ -0,0 +1,34 @@
import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf';
import { AxiosRequestConfig } from 'axios';
import { ImageMcpResponse, TextMcpResponse } from '@waha/apps/mcp/responses';
export class McpController {
constructor(protected readonly api: WAHASelf) {}
protected async request(config: AxiosRequestConfig<any>) {
const requestConfig = { ...config };
requestConfig.validateStatus = () => true;
const response = await this.api.request(requestConfig);
return response;
}
protected async textRequest(config: AxiosRequestConfig) {
const response = await this.request(config);
const responseText =
typeof response.data === 'string'
? response.data
: JSON.stringify(response.data);
return TextMcpResponse(
JSON.stringify({ status: response.status, response: responseText }),
);
}
protected async imageRequest(url: string) {
const response = await this.api.request({
method: 'GET',
url: url,
responseType: 'arraybuffer',
});
return ImageMcpResponse(Buffer.from(response.data));
}
}
+54
View File
@@ -0,0 +1,54 @@
import type { ResourceMetadata } from '@modelcontextprotocol/sdk/server/mcp.js';
import { McpController } from '@waha/apps/mcp/decorators/controller';
export const RESOURCES_KEY = Symbol('waha:mcp:resources');
export const RESOURCE_TEMPLATES_KEY = Symbol('waha:mcp:resource-templates');
export interface ResourceDef {
name: string;
uri: string;
config: ResourceMetadata;
method: string;
}
export interface ResourceTemplateDef {
name: string;
uriTemplate: string;
config: ResourceMetadata;
method: string;
}
export function Resource(
name: string,
uri: string,
config: ResourceMetadata = {},
) {
return (target: object, propertyKey: string) => {
const defs: ResourceDef[] = (target as any)[RESOURCES_KEY] ?? [];
defs.push({ name, uri, config, method: propertyKey });
(target as any)[RESOURCES_KEY] = defs;
};
}
export function ResourceTemplate(
name: string,
uriTemplate: string,
config: ResourceMetadata = {},
) {
return (target: object, propertyKey: string) => {
const defs: ResourceTemplateDef[] =
(target as any)[RESOURCE_TEMPLATES_KEY] ?? [];
defs.push({ name, uriTemplate, config, method: propertyKey });
(target as any)[RESOURCE_TEMPLATES_KEY] = defs;
};
}
export function getResources(controller: McpController): ResourceDef[] {
return (controller as any)[RESOURCES_KEY] ?? [];
}
export function getResourceTemplates(
controller: McpController,
): ResourceTemplateDef[] {
return (controller as any)[RESOURCE_TEMPLATES_KEY] ?? [];
}
+23
View File
@@ -0,0 +1,23 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { McpController } from '@waha/apps/mcp/decorators/controller';
export const TOOLS_KEY = Symbol('waha:mcp:tools');
export type ToolConfig = Parameters<McpServer['registerTool']>[1];
export interface ToolDef {
name: string;
config: ToolConfig;
method: string;
}
export function Tool(name: string, config: ToolConfig) {
return (target: object, propertyKey: string) => {
const defs: ToolDef[] = (target as any)[TOOLS_KEY] ?? [];
defs.push({ name, config, method: propertyKey });
(target as any)[TOOLS_KEY] = defs;
};
}
export function getTools(controller: McpController): ToolDef[] {
return (controller as any)[TOOLS_KEY] ?? [];
}
+43
View File
@@ -0,0 +1,43 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsDefined,
IsOptional,
IsString,
ValidateNested,
} from 'class-validator';
import { SessionActionsDTO } from '@waha/structures/apikeys.dto';
export class McpAppConfig {
@ApiProperty({
type: SessionActionsDTO,
description: 'Permission scopes for the generated API key.',
})
@IsDefined()
@ValidateNested()
@Type(() => SessionActionsDTO)
actions: SessionActionsDTO;
@ApiProperty({
example: 'key_id_00000000000000000000000000',
required: false,
nullable: true,
readOnly: true,
description: 'ID of the API key created for this app. Read-only.',
})
@IsOptional()
@IsString()
key_id?: string;
@ApiProperty({
example: 'key_11111111111AAAAAAAAAAAAAAAAAAAAA',
required: false,
nullable: true,
readOnly: true,
description:
'The API key value. Populated on read; not persisted in this record.',
})
@IsOptional()
@IsString()
key?: string;
}
+9
View File
@@ -0,0 +1,9 @@
import { McpController } from '@waha/apps/mcp/api/mcp.controller';
import { McpService } from '@waha/apps/mcp/mcp.service';
import { McpAppService } from '@waha/apps/mcp/services/McpAppService';
export const McpModuleExports = {
imports: [],
controllers: [McpController],
providers: [McpService, McpAppService],
};
+64
View File
@@ -0,0 +1,64 @@
import {
McpServer,
ResourceTemplate as SdkResourceTemplate,
} from '@modelcontextprotocol/sdk/server/mcp.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import { VERSION } from '@waha/version';
import { McpController } from '@waha/apps/mcp/decorators/controller';
import { getTools } from '@waha/apps/mcp/decorators/tool';
import {
getResources,
getResourceTemplates,
} from '@waha/apps/mcp/decorators/resource';
export class WMcpServer {
private readonly mcp: McpServer;
constructor(controllers: McpController[]) {
this.mcp = new McpServer({
name: 'whatsapp-server-mcp',
version: `${VERSION.version} (${VERSION.engine}, ${VERSION.tier}, ${VERSION.platform})`,
});
for (const controller of controllers) {
for (const { name, config, method } of getTools(controller)) {
this.mcp.registerTool(
name,
config,
(controller as any)[method].bind(controller),
);
}
for (const { name, uri, config, method } of getResources(controller)) {
this.mcp.registerResource(
name,
uri,
config,
(controller as any)[method].bind(controller),
);
}
for (const { name, uriTemplate, config, method } of getResourceTemplates(
controller,
)) {
const template = new SdkResourceTemplate(uriTemplate, {
list: undefined,
});
this.mcp.registerResource(
name,
template,
config,
(controller as any)[method].bind(controller),
);
}
}
}
connect(transport: Transport) {
return this.mcp.connect(transport);
}
close() {
return this.mcp.close();
}
}
+58
View File
@@ -0,0 +1,58 @@
import { Injectable } from '@nestjs/common';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import type { Request, Response } from 'express';
import { WMcpServer } from './mcp.server';
import { SendTools } from './tools/send.tools';
import { AuthTools } from '@waha/apps/mcp/tools/auth.tools';
import { SessionTools } from '@waha/apps/mcp/tools/sessions.tools';
import { ChatTools } from '@waha/apps/mcp/tools/chats.tools';
import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf';
import { ApiTools } from '@waha/apps/mcp/tools/api.tools';
import { CallTools } from '@waha/apps/mcp/tools/calls.tools';
import { ChannelTools } from '@waha/apps/mcp/tools/channels.tools';
import { ContactTools } from '@waha/apps/mcp/tools/contacts.tools';
import { GroupTools } from '@waha/apps/mcp/tools/groups.tools';
import { LabelTools } from '@waha/apps/mcp/tools/labels.tools';
import { LidTools } from '@waha/apps/mcp/tools/lids.tools';
import { PingTools } from '@waha/apps/mcp/tools/ping.tools';
import { PresenceTools } from '@waha/apps/mcp/tools/presence.tools';
import { ProfileTools } from '@waha/apps/mcp/tools/profile.tools';
import { StatusTools } from '@waha/apps/mcp/tools/status.tools';
import { ServerTools } from '@waha/apps/mcp/tools/server.tools';
@Injectable()
export class McpService {
async handlePost(req: Request, res: Response) {
const apiKey = req.headers['x-api-key'] as string | undefined;
const api = new WAHASelf(apiKey);
const server = new WMcpServer([
new PingTools(api),
new SendTools(api),
new AuthTools(api),
new SessionTools(api),
new ChatTools(api),
new ApiTools(api),
new CallTools(api),
new ChannelTools(api),
new ContactTools(api),
new GroupTools(api),
new LabelTools(api),
new LidTools(api),
new PresenceTools(api),
new ProfileTools(api),
new StatusTools(api),
new ServerTools(api),
]);
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // stateless
});
res.on('close', () => {
transport.close();
server.close();
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
}
}
+31
View File
@@ -0,0 +1,31 @@
export function TextMcpResponse(text: string) {
return { content: [{ type: 'text' as const, text: text }] };
}
export function JsonMcpToolResponse(value: unknown) {
return TextMcpResponse(JSON.stringify(value));
}
export function ImageMcpResponse(buffer: Buffer) {
return {
content: [
{
type: 'image' as const,
data: buffer.toString('base64'),
mimeType: 'image/png',
},
],
};
}
export function JsonMcpResponse(uri: URL, data: any) {
return {
contents: [
{
uri: uri.toString(),
mimeType: 'application/json',
text: JSON.stringify(data),
},
],
};
}
+605
View File
@@ -0,0 +1,605 @@
import 'reflect-metadata';
import { Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsEnum,
IsIn,
IsNumber,
IsOptional,
IsString,
IsUrl,
Matches,
MaxLength,
ValidateNested,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { z } from 'zod';
import { DtoToZod } from './DtoToZod';
function expectSchemasToEqual(schema1: z.ZodType, schema2: z.ZodType) {
expect(z.toJSONSchema(schema1)).toEqual(z.toJSONSchema(schema2));
}
describe('DtoToZod', () => {
describe('primitive types', () => {
it('converts @IsString to z.string()', () => {
class Dto {
@IsString()
name: string;
}
const Expected = z.object({
name: z.string(),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('converts @IsNumber to z.number()', () => {
class Dto {
@IsNumber()
count: number;
}
const Expected = z.object({
count: z.number(),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('converts @IsBoolean to z.boolean()', () => {
class Dto {
@IsBoolean()
flag: boolean;
}
const Expected = z.object({
flag: z.boolean(),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
});
describe('optional fields', () => {
it('marks @IsOptional fields as optional', () => {
class Dto {
@IsNumber()
@IsOptional()
limit?: number;
}
const Expected = z.object({
limit: z.number().optional(),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('marks @ApiPropertyOptional fields as optional', () => {
class Dto {
@IsString()
@ApiPropertyOptional({ description: 'Optional name' })
name?: string;
}
const Expected = z.object({
name: z.string().optional().describe('Optional name'),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('marks @ApiProperty({ required: false }) fields as optional', () => {
class Dto {
@IsBoolean()
@ApiProperty({ required: false })
active?: boolean;
}
const Expected = z.object({
active: z.boolean().optional(),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('required fields fail without value', () => {
class Dto {
@IsString()
name: string;
}
const Expected = z.object({
name: z.string(),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
});
describe('default values', () => {
it('picks up default from class field initializer', () => {
class Dto {
@IsBoolean()
@IsOptional()
active?: boolean = true;
}
const Expected = z.object({
active: z.boolean().optional().default(true),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('picks up default from @ApiProperty', () => {
class Dto {
@IsBoolean()
@IsOptional()
@ApiProperty({ default: false, description: 'Enable feature' })
enabled?: boolean;
}
const Expected = z.object({
enabled: z
.boolean()
.optional()
.default(false)
.describe('Enable feature'),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('does not apply default to required fields', () => {
class Dto {
@IsNumber()
count: number;
}
const Expected = z.object({
count: z.number(),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('prefers @ApiProperty default over instance default', () => {
class Dto {
@IsNumber()
@IsOptional()
@ApiProperty({ default: 99 })
val?: number = 1;
}
const Expected = z.object({
val: z.number().optional().default(99),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
});
describe('descriptions', () => {
it('applies @ApiProperty description to required field', () => {
class Dto {
@IsString()
@ApiProperty({ description: 'Session identifier' })
session: string;
}
const Expected = z.object({
session: z.string().describe('Session identifier'),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('applies description to optional field with default', () => {
class Dto {
@IsBoolean()
@IsOptional()
@ApiProperty({
description: 'Include stopped sessions',
default: false,
})
all?: boolean;
}
const Expected = z.object({
all: z
.boolean()
.optional()
.default(false)
.describe('Include stopped sessions'),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
});
describe('string refinements', () => {
it('applies @IsUrl as .url()', () => {
class Dto {
@IsUrl()
webhookUrl: string;
}
const Expected = z.object({
webhookUrl: z.string().url(),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('applies @MaxLength', () => {
class Dto {
@IsString()
@MaxLength(10)
name: string;
}
const Expected = z.object({
name: z.string().max(10),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('applies @Matches regex', () => {
class Dto {
@IsString()
@Matches(/^[a-z]+$/)
slug: string;
}
const Expected = z.object({
slug: z.string().regex(/^[a-z]+$/),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('chains multiple string refinements', () => {
class Dto {
@IsString()
@MaxLength(54)
@Matches(/^[a-zA-Z0-9_-]*$/)
sessionName: string;
}
const Expected = z.object({
sessionName: z
.string()
.max(54)
.regex(/^[a-zA-Z0-9_-]*$/),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
});
describe('enums', () => {
it('converts string enum with @IsEnum', () => {
enum Color {
RED = 'red',
GREEN = 'green',
BLUE = 'blue',
}
class Dto {
@IsEnum(Color)
color: Color;
}
const Expected = z.object({
color: z.nativeEnum(Color),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('converts numeric enum with @IsEnum', () => {
enum Priority {
LOW = 1,
MEDIUM = 2,
HIGH = 3,
}
class Dto {
@IsEnum(Priority)
@IsOptional()
priority?: Priority;
}
const Expected = z.object({
priority: z.nativeEnum(Priority).optional(),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
});
describe('@IsIn union', () => {
it('creates union of number literals from @IsIn', () => {
class Dto {
@IsIn([86400, 604800, 2592000])
duration: number;
}
const Expected = z.object({
duration: z.number(),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('creates union of string literals from @IsIn', () => {
class Dto {
@IsIn(['asc', 'desc'])
@IsOptional()
@ApiProperty({ default: 'desc' })
order?: string;
}
const Expected = z.object({
order: z.string().optional().default('desc'),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
});
describe('nested objects', () => {
it('converts @ValidateNested + @Type to nested ZodObject', () => {
class AddressDto {
@IsString()
@ApiProperty({ description: 'City name' })
city: string;
}
class Dto {
@ValidateNested()
@Type(() => AddressDto)
@IsOptional()
address?: AddressDto;
}
const Expected = z.object({
address: z
.object({
city: z.string().describe('City name'),
})
.optional(),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('propagates descriptions from nested DTO fields', () => {
class AddressDto {
@IsString()
@ApiProperty({ description: 'City name' })
city: string;
}
class Dto {
@ValidateNested()
@Type(() => AddressDto)
@IsOptional()
address?: AddressDto;
}
const Expected = z.object({
address: z
.object({
city: z.string().describe('City name'),
})
.optional(),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
});
describe('arrays', () => {
it('converts @IsArray + @IsString({ each }) to z.array(z.string())', () => {
class Dto {
@IsArray()
@IsString({ each: true })
tags: string[];
}
const Expected = z.object({
tags: z.array(z.string()),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('converts array of nested DTOs', () => {
class ItemDto {
@IsString()
label: string;
}
class Dto {
@ValidateNested({ each: true })
@Type(() => ItemDto)
@IsArray()
@IsOptional()
items?: ItemDto[];
}
const Expected = z.object({
items: z
.array(
z.object({
label: z.string(),
}),
)
.optional(),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('converts @IsArray + @IsEnum({ each }) to array of enum', () => {
enum Status {
ACTIVE = 'active',
INACTIVE = 'inactive',
}
class Dto {
@IsArray()
@IsEnum(Status, { each: true })
@IsOptional()
statuses?: Status[];
}
const Expected = z.object({
statuses: z.array(z.nativeEnum(Status)).optional(),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
});
describe('plugin-generated fields (undecorated)', () => {
it('handles required undecorated field', () => {
class Dto {
field: string;
static _OPENAPI_METADATA_FACTORY() {
return { field: { required: true, type: () => String } };
}
}
const Expected = z.object({
field: z.string(),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('handles optional undecorated field', () => {
class Dto {
field?: string;
static _OPENAPI_METADATA_FACTORY() {
return { field: { required: false, type: () => String } };
}
}
const Expected = z.object({
field: z.string().optional(),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('handles optional number with default from factory', () => {
class Dto {
count?: number;
static _OPENAPI_METADATA_FACTORY() {
return {
count: { required: false, type: () => Number, default: 10 },
};
}
}
const Expected = z.object({
count: z.number().optional().default(10),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('handles optional boolean with description from factory', () => {
class Dto {
verbose?: boolean;
static _OPENAPI_METADATA_FACTORY() {
return {
verbose: {
required: false,
type: () => Boolean,
description: 'Enable verbose output',
},
};
}
}
const Expected = z.object({
verbose: z.boolean().optional().describe('Enable verbose output'),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('handles array field from factory', () => {
class Dto {
tags: string[];
static _OPENAPI_METADATA_FACTORY() {
return { tags: { required: true, type: () => [String] } };
}
}
const Expected = z.object({
tags: z.array(z.string()),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
it('handles mix of decorated and undecorated fields', () => {
class Dto {
@IsString()
name: string;
age?: number;
static _OPENAPI_METADATA_FACTORY() {
return { age: { required: false, type: () => Number } };
}
}
const Expected = z.object({
name: z.string(),
age: z.number().optional(),
});
expectSchemasToEqual(DtoToZod(Dto), Expected);
});
});
describe('inheritance', () => {
it('includes parent class fields', () => {
class BaseDto {
@IsString()
@ApiProperty({ description: 'Unique ID' })
id: string;
}
class ChildDto extends BaseDto {
@IsNumber()
@IsOptional()
count?: number;
}
const Expected = z.object({
id: z.string().describe('Unique ID'),
count: z.number().optional(),
});
expectSchemasToEqual(DtoToZod(ChildDto), Expected);
});
it('inherits parent field types and descriptions', () => {
class BaseDto {
@IsString()
@ApiProperty({ description: 'Unique ID' })
id: string;
}
class ChildDto extends BaseDto {
@IsNumber()
@IsOptional()
count?: number;
}
const Expected = z.object({
id: z.string().describe('Unique ID'),
count: z.number().optional(),
});
expectSchemasToEqual(DtoToZod(ChildDto), Expected);
});
it('parent required fields remain required in child', () => {
class BaseDto {
@IsString()
id: string;
}
class ChildDto extends BaseDto {
@IsNumber()
@IsOptional()
count?: number;
}
const Expected = z.object({
id: z.string(),
count: z.number().optional(),
});
expectSchemasToEqual(DtoToZod(ChildDto), Expected);
});
});
});
+416
View File
@@ -0,0 +1,416 @@
import 'reflect-metadata';
import { defaultMetadataStorage } from 'class-transformer/cjs/storage';
import { getMetadataStorage } from 'class-validator';
import { z } from 'zod';
// ──────────────────────────────────────────────────────────────────────────────
// Public types
// ──────────────────────────────────────────────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type Constructor<T = any> = new (...args: any[]) => T;
export type DtoShape<T> = {
[K in keyof T & string]: z.ZodType<T[K]>;
};
// ──────────────────────────────────────────────────────────────────────────────
// Internal metadata interfaces
// ──────────────────────────────────────────────────────────────────────────────
interface CvConstraint {
type: string;
name: string;
propertyName: string;
constraints: unknown[];
each: boolean;
}
// Built-in decorators (@IsString, @IsArray, etc.) use type='customValidation' with
// the real name in `name`. Special decorators (@IsOptional, @ValidateNested) use
// a descriptive `type` directly with no `name`.
function constraintKey(c: CvConstraint): string {
return c.type === 'customValidation' ? c.name : c.type;
}
interface TypeMeta {
typeFunction?: () => Constructor;
propertyName: string;
}
interface ApiPropertyMeta {
description?: string;
default?: unknown;
// Explicitly set via @ApiPropertyOptional() or @ApiProperty({ required: false })
required?: boolean;
// design:type merged by NestJS Swagger at decoration time — already a constructor
type?: unknown;
isArray?: boolean;
}
// Shape of entries returned by the _OPENAPI_METADATA_FACTORY static method that
// the @nestjs/swagger compiler plugin generates for every DTO property.
interface FactoryPropertyMeta {
required?: boolean;
// Lazy constructor ref: `() => String`, or array notation: `() => [String]`
type?: () => unknown;
default?: unknown;
description?: string;
}
// ──────────────────────────────────────────────────────────────────────────────
// Constants
// ──────────────────────────────────────────────────────────────────────────────
// class-validator constraint type names
const CV_IS_STRING = 'isString';
const CV_IS_BOOLEAN = 'isBoolean';
const CV_IS_NUMBER = 'isNumber';
const CV_IS_INT = 'isInt';
const CV_IS_ARRAY = 'isArray';
const CV_IS_ENUM = 'isEnum';
const CV_NESTED = 'nestedValidation';
const CV_OPTIONAL = 'conditionalValidation'; // @IsOptional / @ValidateIf
const CV_IS_URL = 'isUrl';
const CV_MAX_LENGTH = 'maxLength';
const CV_MATCHES = 'matches';
const CV_IS_IN = 'isIn';
// NestJS Swagger reflect-metadata keys (stable since @nestjs/swagger v3)
const SW_PROPS = 'swagger/apiModelProperties';
const SW_PROPS_ARRAY = 'swagger/apiModelPropertiesArray';
// Static method injected by the @nestjs/swagger compiler plugin on every DTO
const METADATA_FACTORY_NAME = '_OPENAPI_METADATA_FACTORY';
function collectPropertyNames(cls: Constructor): string[] {
const names = new Set<string>();
// @ApiProperty-decorated properties — walk prototype chain
let proto: object = cls.prototype;
while (proto && proto !== Object.prototype) {
const arr =
(Reflect.getMetadata(SW_PROPS_ARRAY, proto) as string[] | undefined) ??
[];
for (const entry of arr) {
names.add(entry.startsWith(':') ? entry.slice(1) : entry);
}
proto = Object.getPrototypeOf(proto) as object;
}
// class-validator metadata (handles inheritance internally)
const cvAll = getMetadataStorage().getTargetValidationMetadatas(
cls,
'',
false,
false,
) as CvConstraint[];
for (const meta of cvAll) {
names.add(meta.propertyName);
}
// _OPENAPI_METADATA_FACTORY — plugin-generated, covers undecorated properties
for (const key of Object.keys(getClassFactoryMeta(cls))) {
names.add(key);
}
return [...names];
}
// Reads the _OPENAPI_METADATA_FACTORY static method walking the constructor chain
// so that parent-class factory entries are merged (child takes precedence).
function getClassFactoryMeta(
cls: Constructor,
): Record<string, FactoryPropertyMeta> {
const result: Record<string, FactoryPropertyMeta> = {};
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
let ctor: Function = cls;
while (ctor && ctor !== Object) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const factory = (ctor as any)[METADATA_FACTORY_NAME] as unknown;
if (typeof factory === 'function') {
const meta = (factory as () => Record<string, FactoryPropertyMeta>)();
for (const [key, val] of Object.entries(meta)) {
if (!(key in result)) result[key] = val;
}
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
ctor = Object.getPrototypeOf(ctor) as Function;
}
return result;
}
function getConstraints(cls: Constructor, prop: string): CvConstraint[] {
const all = getMetadataStorage().getTargetValidationMetadatas(
cls,
'',
false,
false,
) as CvConstraint[];
return all.filter((m) => m.propertyName === prop);
}
// Walks the constructor chain because class-transformer stores type metadata
// on the exact constructor where @Type() was declared.
function getTypeMeta(cls: Constructor, prop: string): TypeMeta | undefined {
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
let ctor: Function = cls;
while (ctor && ctor !== Object && typeof ctor.prototype !== 'undefined') {
const meta = defaultMetadataStorage.findTypeMetadata(ctor, prop) as
| TypeMeta
| undefined;
if (meta?.typeFunction) return meta;
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
ctor = Object.getPrototypeOf(ctor) as Function;
}
return undefined;
}
function getApiPropertyMeta(
cls: Constructor,
prop: string,
): ApiPropertyMeta | undefined {
let proto: object = cls.prototype;
while (proto && proto !== Object.prototype) {
const meta = Reflect.getMetadata(SW_PROPS, proto, prop) as
| ApiPropertyMeta
| undefined;
if (meta !== undefined) return meta;
proto = Object.getPrototypeOf(proto) as object;
}
return undefined;
}
function getDesignType(cls: Constructor, prop: string): unknown {
let proto: object = cls.prototype;
while (proto && proto !== Object.prototype) {
const type = Reflect.getMetadata('design:type', proto, prop);
if (type !== undefined) return type;
proto = Object.getPrototypeOf(proto) as object;
}
return undefined;
}
function getInstanceDefault(cls: Constructor, prop: string): unknown {
try {
const instance = new cls() as Record<string, unknown>;
const val = instance[prop];
return val !== undefined ? val : undefined;
} catch {
return undefined;
}
}
// ──────────────────────────────────────────────────────────────────────────────
// Schema builders
// ──────────────────────────────────────────────────────────────────────────────
function inferFromDesignType(type: unknown): z.ZodTypeAny {
if (type === String) return z.string();
if (type === Boolean) return z.boolean();
if (type === Number) return z.number();
if (type === Array) return z.array(z.unknown());
// Record<*, *> and plain objects — TypeScript emits Object for both
if (type === Object) return z.record(z.string(), z.unknown());
return z.unknown();
}
function buildStringSchema(cs: CvConstraint[]): z.ZodString {
let schema = z.string();
for (const c of cs) {
const key = constraintKey(c);
if (key === CV_IS_URL) {
schema = schema.url();
} else if (key === CV_MAX_LENGTH) {
schema = schema.max(c.constraints[0] as number);
} else if (key === CV_MATCHES) {
schema = schema.regex(c.constraints[0] as RegExp);
}
}
return schema;
}
function buildEnumSchema(cs: CvConstraint[]): z.ZodTypeAny {
const c = cs.find((m) => constraintKey(m) === CV_IS_ENUM);
if (!c) return z.string();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return z.nativeEnum(c.constraints[0] as any);
}
// function buildIsInSchema(cs: CvConstraint[]): z.ZodTypeAny {
// const c = cs.find((m) => m.type === CV_IS_IN);
// if (!c) return z.unknown();
//
// const values = c.constraints[0] as unknown[];
// if (values.length === 0) return z.unknown();
//
// const [first, second, ...rest] = values.map((v) => z.literal(v));
// return z.union([
// first,
// second,
// ...rest,
// ] as [z.ZodTypeAny, z.ZodTypeAny, ...z.ZodTypeAny[]]);
// }
// Determines the (element) Zod type from a set of constraints.
// Returns undefined when the constraints do not specify a type.
function resolveElementSchema(
cls: Constructor,
prop: string,
cs: CvConstraint[],
): z.ZodTypeAny | undefined {
const types = new Set(cs.map(constraintKey));
if (types.has(CV_NESTED)) {
const meta = getTypeMeta(cls, prop);
if (meta?.typeFunction) return DtoToZod(meta.typeFunction());
return z.record(z.string(), z.unknown());
}
// if (types.has(CV_IS_IN)) return buildIsInSchema(cs);
if (types.has(CV_IS_ENUM)) return buildEnumSchema(cs);
const isStringLike =
types.has(CV_IS_STRING) ||
types.has(CV_IS_URL) ||
types.has(CV_MAX_LENGTH) ||
types.has(CV_MATCHES);
if (isStringLike) return buildStringSchema(cs);
if (types.has(CV_IS_BOOLEAN)) return z.boolean();
if (types.has(CV_IS_NUMBER) || types.has(CV_IS_INT)) return z.number();
return undefined;
}
// Falls back to @Type() metadata when element constraints are absent (e.g. array
// with only @Type(() => SomeClass) and @ValidateNested({ each: true }), or arrays
// of primitives decorated only with @Type(() => Number)).
function resolveElementFromTypeMeta(
cls: Constructor,
prop: string,
): z.ZodTypeAny | undefined {
const meta = getTypeMeta(cls, prop);
if (!meta?.typeFunction) return undefined;
const ctor = meta.typeFunction();
if (ctor === String) return z.string();
if (ctor === Number) return z.number();
if (ctor === Boolean) return z.boolean();
return DtoToZod(ctor);
}
// Uses the `type` stored in @ApiProperty() metadata, which NestJS Swagger
// populates from design:type at decoration time. Handles constructor refs,
// lazy () => Class functions, and primitive string type names.
function resolveFromSwaggerType(type: unknown): z.ZodTypeAny | undefined {
if (!type) return undefined;
// Lazy type function: @ApiProperty({ type: () => SomeClass }) or factory type: () => X
if (typeof type === 'function' && type.name === 'type') {
return resolveFromSwaggerType((type as () => unknown)());
}
// Array notation [Constructor]: from _OPENAPI_METADATA_FACTORY type: () => [String]
if (Array.isArray(type) && (type as unknown[]).length === 1) {
const elem = resolveFromSwaggerType((type as unknown[])[0]);
return z.array(elem ?? z.unknown());
}
if (type === String) return z.string();
if (type === Boolean) return z.boolean();
if (type === Number) return z.number();
if (type === Array) return z.array(z.unknown());
if (type === Object) return z.record(z.string(), z.unknown());
// String type names (e.g. 'string', 'number', 'boolean') from explicit @ApiProperty({ type: 'string' })
if (type === 'string') return z.string();
if (type === 'number') return z.number();
if (type === 'boolean') return z.boolean();
if (type === 'integer') return z.number().int();
// Class constructor — recurse
if (typeof type === 'function') {
try {
return DtoToZod(type as Constructor);
} catch {
return undefined;
}
}
return undefined;
}
function buildPropertySchema(
cls: Constructor,
prop: string,
cs: CvConstraint[],
apiMeta: ApiPropertyMeta | undefined,
factoryMeta: FactoryPropertyMeta | undefined,
): z.ZodTypeAny {
const ownCs = cs.filter((c) => !c.each);
const ownTypes = new Set(ownCs.map(constraintKey));
// Detect array from class-validator @IsArray() or from @ApiProperty({ isArray: true })
const isArray = ownTypes.has(CV_IS_ARRAY) || apiMeta?.isArray === true;
if (isArray) {
const eachCs = cs.filter((c) => c.each);
const elemSchema =
resolveElementSchema(cls, prop, eachCs) ??
resolveElementFromTypeMeta(cls, prop) ??
z.unknown();
return z.array(elemSchema);
}
return (
resolveElementSchema(cls, prop, ownCs) ??
resolveFromSwaggerType(apiMeta?.type) ??
resolveFromSwaggerType(factoryMeta?.type) ??
inferFromDesignType(getDesignType(cls, prop))
);
}
// ──────────────────────────────────────────────────────────────────────────────
// Public API
// ──────────────────────────────────────────────────────────────────────────────
export function DtoToZod<T>(cls: Constructor<T>): z.ZodObject<DtoShape<T>> {
const shape: Record<string, z.ZodTypeAny> = {};
const factoryAll = getClassFactoryMeta(cls);
for (const prop of collectPropertyNames(cls)) {
const cs = getConstraints(cls, prop);
const apiMeta = getApiPropertyMeta(cls, prop);
const factoryMeta = factoryAll[prop];
// @IsOptional() registers as conditionalValidation; @ApiPropertyOptional()
// / @ApiProperty({ required: false }) sets required: false on the swagger meta.
// Factory required: false covers undecorated optional fields (plugin-generated).
const isOptional =
cs.some((c) => constraintKey(c) === CV_OPTIONAL) ||
apiMeta?.required === false ||
factoryMeta?.required === false;
let schema = buildPropertySchema(cls, prop, cs, apiMeta, factoryMeta);
if (isOptional) {
schema = schema.optional();
const defaultVal =
apiMeta?.default ??
factoryMeta?.default ??
getInstanceDefault(cls, prop);
if (defaultVal !== undefined) {
schema = (schema as z.ZodOptional<z.ZodTypeAny>).default(defaultVal);
}
}
const desc = apiMeta?.description ?? factoryMeta?.description;
if (desc) schema = schema.describe(desc);
shape[prop] = schema;
}
return z.object(shape) as unknown as z.ZodObject<DtoShape<T>>;
}
+183
View File
@@ -0,0 +1,183 @@
import { Injectable, UnprocessableEntityException } from '@nestjs/common';
import { App } from '@waha/apps/app_sdk/dto/app.dto';
import { IAppService } from '@waha/apps/app_sdk/services/IAppService';
import { AppRepository } from '@waha/apps/app_sdk/storage/AppRepository';
import { McpAppConfig } from '@waha/apps/mcp/dto/config.dto';
import { SessionManager } from '@waha/core/abc/manager.abc';
import { ApiKeyService } from '@waha/core/services/ApiKeyService';
import { WhatsappSession } from '@waha/core/abc/session.abc';
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
@Injectable()
export class McpAppService implements IAppService {
constructor(
@InjectPinoLogger('McpAppService')
private readonly logger: PinoLogger,
) {}
validate(app: App<McpAppConfig>): void {
if (!app.config) {
app.config = new McpAppConfig();
}
delete app.config.key_id;
delete app.config.key;
}
async beforeCreated(app: App<McpAppConfig>): Promise<void> {
void app;
}
async afterCreated(
manager: SessionManager,
app: App<McpAppConfig>,
): Promise<void> {
const keyDto = await new ApiKeyService(manager).createForApp(
{
isAdmin: false,
session: app.session,
isActive: true,
actions: app.config?.actions ?? null,
},
app.id,
);
const updatedConfig = {
...(app.config ?? {}),
key_id: keyDto.id,
} as McpAppConfig;
const repo = new AppRepository(manager.store.getWAHADatabase());
await repo.update(app.id, { config: updatedConfig });
app.config = updatedConfig;
}
async beforeEnabled(
manager: SessionManager,
savedApp: App<McpAppConfig>,
newApp: App<McpAppConfig>,
): Promise<void> {
await this.requireKeyExists(manager, savedApp);
await this.syncKeyActions(manager, savedApp, newApp);
await this.setKeyActive(manager, savedApp, true);
newApp.config = {
...(newApp.config ?? {}),
key_id: savedApp.config?.key_id,
} as McpAppConfig;
}
async beforeDisabled(
manager: SessionManager,
savedApp: App<McpAppConfig>,
newApp: App<McpAppConfig>,
): Promise<void> {
await this.setKeyActive(manager, savedApp, false);
newApp.config = {
...(newApp.config ?? {}),
key_id: savedApp.config?.key_id,
} as McpAppConfig;
}
async beforeUpdated(
manager: SessionManager,
savedApp: App<McpAppConfig>,
newApp: App<McpAppConfig>,
): Promise<void> {
await this.requireKeyExists(manager, savedApp);
await this.syncKeyActions(manager, savedApp, newApp);
newApp.config = {
...(newApp.config ?? {}),
key_id: savedApp.config?.key_id,
} as McpAppConfig;
}
async beforeDeleted(
manager: SessionManager,
app: App<McpAppConfig>,
): Promise<void> {
await this.deleteKey(manager, app);
}
async beforeSessionDeleted(
manager: SessionManager,
app: App<McpAppConfig>,
): Promise<void> {
await this.deleteKey(manager, app);
}
async enrich(manager: SessionManager, app: App<McpAppConfig>): Promise<void> {
const keyId = app.config?.key_id;
if (!keyId) {
return;
}
const keyDto = await new ApiKeyService(manager).getById(keyId);
if (!keyDto) {
return;
}
app.config = { ...(app.config ?? {}), key: keyDto.key } as McpAppConfig;
}
beforeSessionStart(app: App<McpAppConfig>, session: WhatsappSession): void {
void app;
void session;
}
afterSessionStart(app: App<McpAppConfig>, session: WhatsappSession): void {
void app;
void session;
}
private async requireKeyExists(
manager: SessionManager,
app: App<McpAppConfig>,
): Promise<void> {
const keyId = app.config?.key_id;
if (!keyId) {
throw new UnprocessableEntityException(
'MCP app has no associated API key. Delete this app and create a new one.',
);
}
const existing = await new ApiKeyService(manager).getById(keyId);
if (!existing) {
throw new UnprocessableEntityException(
`The API key for this MCP app no longer exists. Delete this app and create a new one.`,
);
}
}
private async syncKeyActions(
manager: SessionManager,
savedApp: App<McpAppConfig>,
newApp: App<McpAppConfig>,
): Promise<void> {
const keyId = savedApp.config?.key_id;
if (!keyId) {
return;
}
await new ApiKeyService(manager).updateForApp(keyId, {
actions: newApp.config?.actions ?? null,
});
}
private async setKeyActive(
manager: SessionManager,
app: App<McpAppConfig>,
isActive: boolean,
): Promise<void> {
const keyId = app.config?.key_id;
if (!keyId) {
return;
}
await new ApiKeyService(manager).updateForApp(keyId, {
isActive: isActive,
});
}
private async deleteKey(
manager: SessionManager,
app: App<McpAppConfig>,
): Promise<void> {
const keyId = app.config?.key_id;
if (!keyId) {
return;
}
await new ApiKeyService(manager).deleteForApp(keyId);
}
}
+63
View File
@@ -0,0 +1,63 @@
import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf';
import { McpController } from '@waha/apps/mcp/decorators/controller';
import { Tool } from '@waha/apps/mcp/decorators/tool';
import { APIInput } from '@waha/apps/mcp/tools/api.zod';
import { Auth } from '@waha/core/auth/config';
import { z } from 'zod';
export class ApiTools extends McpController {
constructor(api: WAHASelf) {
super(api);
}
@Tool('api-openapi', {
title: 'Get OpenAPI schema for HTTP API',
description:
'Get the latest OpenAPI schema for the HTTP API available to call in the "api-call" tool. ' +
'Use it as a last option if no native tools are found. ' +
'Find appropriate /api/send.* methods to send messages to direct chats (lid, c.us), groups (g.us) and channels/newsletter (@newsletter). ' +
'Use api/:session/status to send status.',
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async openapi() {
const headers: Record<string, string> = {};
const swaggerUser = Auth.swagger.username.value;
const swaggerPassword = Auth.swagger.password.value;
if (swaggerUser && swaggerPassword) {
const encoded = Buffer.from(`${swaggerUser}:${swaggerPassword}`).toString(
'base64',
);
headers['Authorization'] = `Basic ${encoded}`;
}
return this.textRequest({
method: 'GET',
url: '/-json',
headers: headers,
});
}
@Tool('api-call', {
title: 'Call HTTP API',
description:
'Call HTTP API for certain method if no native MCP tools available. ' +
'Before calling fetch "api-openapi" tool to get the latest openapi spec. ' +
'Use it as last option if no native tools are found.',
inputSchema: APIInput,
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
},
})
async call({ path, method, body }: z.infer<typeof APIInput>) {
return this.textRequest({
method: method,
url: path,
data: body,
});
}
}
+13
View File
@@ -0,0 +1,13 @@
import { z } from 'zod';
export const APIInput = z.object({
path: z
.string()
.describe(
'API path to call, e.g: /api/sessions&query=here, include query in path, no host and protocol required.',
),
method: z
.enum(['GET', 'POST', 'DELETE', 'PATCH'])
.describe('API method to call'),
body: z.any().optional().describe('Body if required by openapi spec'),
});
+97
View File
@@ -0,0 +1,97 @@
import { z } from 'zod';
import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf';
import { McpController } from '@waha/apps/mcp/decorators/controller';
import { Tool } from '@waha/apps/mcp/decorators/tool';
import {
AuthQRInput,
AuthRequestCodeInput,
ScreenshotInput,
} from '@waha/apps/mcp/tools/auth.zod';
function AuthContent(key: string): any {
return {
type: 'text' as const,
text: `
You can either ask the user to scan a QR code or provide a phone number and call auth-request-code. auth-request-code is preferable, so ask for the phone number and pass it in international format without +.
If the user wants to open the QR code or screenshot in a browser, add "?x-api-key=${key}" to the query params.
`,
};
}
export class AuthTools extends McpController {
constructor(api: WAHASelf) {
super(api);
}
@Tool('auth-qr', {
title: 'Get QR code',
description:
'Get QR code to pair WhatsApp Session. ' +
'The first QR code is valid for 60 seconds; each subsequent code is valid for 20 seconds. ' +
'If the code expires before scanning, call this tool again to get a fresh one. ' +
'If you run out of codes the server closes the connection — reconnect and start over.',
inputSchema: AuthQRInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: false,
},
})
async authQR({ session }: z.infer<typeof AuthQRInput>) {
const result = await this.imageRequest(`/api/${session}/auth/qr`);
result.content.push(AuthContent(this.api.key));
return result;
}
@Tool('screenshot', {
title: 'Get screenshot',
description:
'Get a screenshot of the current WhatsApp Web page (WEBJS/WPP only)',
inputSchema: ScreenshotInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: false,
},
})
async screenshot({ session }: z.infer<typeof ScreenshotInput>) {
const result = await this.imageRequest(
`/api/screenshot?session=${session}`,
);
result.content.push(AuthContent(this.api.key));
return result;
}
@Tool('auth-request-code', {
title: 'Request pairing code',
description:
'Request a one-time pairing code for phone-number-based authentication (alternative to QR). ' +
'Leave method empty for Web pairing.',
inputSchema: AuthRequestCodeInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async requestCode({
session,
...body
}: z.infer<typeof AuthRequestCodeInput>) {
const result = await this.textRequest({
method: 'POST',
url: `/api/${session}/auth/request-code`,
data: body,
});
result.content.push({
type: 'text',
text:
'Share the pairing code with the user and ask them to complete linking:\n' +
'1. Open WhatsApp on your phone\n' +
'2. Tap More Options ⋮ or Settings\n' +
'3. Tap Linked Devices → Link a device\n' +
'4. Tap "Link with phone number instead" and enter the code',
});
return result;
}
}
+15
View File
@@ -0,0 +1,15 @@
import { z } from 'zod';
import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod';
import { RequestCodeRequest } from '@waha/structures/auth.dto';
export const AuthQRInput = z.object({
session: z.string(),
});
export const ScreenshotInput = z.object({
session: z.string(),
});
export const AuthRequestCodeInput = DtoToZod(RequestCodeRequest).extend({
session: z.string(),
});
+29
View File
@@ -0,0 +1,29 @@
import { z } from 'zod';
import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf';
import { McpController } from '@waha/apps/mcp/decorators/controller';
import { Tool } from '@waha/apps/mcp/decorators/tool';
import { RejectCallInput } from '@waha/apps/mcp/tools/calls.zod';
export class CallTools extends McpController {
constructor(api: WAHASelf) {
super(api);
}
@Tool('calls-reject', {
title: 'Reject incoming call',
description: 'Reject an incoming WhatsApp call',
inputSchema: RejectCallInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async rejectCall({ session, ...body }: z.infer<typeof RejectCallInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/calls/reject`,
data: body,
});
}
}
+7
View File
@@ -0,0 +1,7 @@
import { z } from 'zod';
import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod';
import { RejectCallRequest } from '@waha/structures/calls.dto';
export const RejectCallInput = DtoToZod(RejectCallRequest).extend({
session: z.string().describe('Session name'),
});
+280
View File
@@ -0,0 +1,280 @@
import { z } from 'zod';
import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf';
import { McpController } from '@waha/apps/mcp/decorators/controller';
import { Tool } from '@waha/apps/mcp/decorators/tool';
import {
ChannelCreateInput,
ChannelIdInput,
ChannelPreviewMessagesInput,
ChannelsListInput,
ChannelSearchByTextInput,
ChannelSearchByViewInput,
ChannelSearchMetaInput,
} from '@waha/apps/mcp/tools/channels.zod';
export class ChannelTools extends McpController {
constructor(api: WAHASelf) {
super(api);
}
@Tool('channels-list', {
title: 'List channels',
description: 'Get list of known WhatsApp channels for a session',
inputSchema: ChannelsListInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async list({ session, ...query }: z.infer<typeof ChannelsListInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/channels`,
params: query,
});
}
@Tool('channels-create', {
title: 'Create channel',
description: 'Create a new WhatsApp channel',
inputSchema: ChannelCreateInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async create({ session, ...body }: z.infer<typeof ChannelCreateInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/channels`,
data: body,
});
}
@Tool('channels-delete', {
title: 'Delete channel',
description: 'Delete a WhatsApp channel',
inputSchema: ChannelIdInput,
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
},
})
async delete({ session, id }: z.infer<typeof ChannelIdInput>) {
return this.textRequest({
method: 'DELETE',
url: `/api/${session}/channels/${id}`,
});
}
@Tool('channels-get', {
title: 'Get channel info',
description:
'Get channel information by ID (123@newsletter) or invite code/link',
inputSchema: ChannelIdInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async get({ session, id }: z.infer<typeof ChannelIdInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/channels/${id}`,
});
}
@Tool('channels-messages-preview', {
title: 'Preview channel messages',
description:
'Preview recent messages from a channel by ID (123@newsletter) or invite code/link',
inputSchema: ChannelPreviewMessagesInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async previewMessages({
session,
id,
...query
}: z.infer<typeof ChannelPreviewMessagesInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/channels/${id}/messages/preview`,
params: query,
});
}
@Tool('channels-follow', {
title: 'Follow channel',
description: 'Follow a WhatsApp channel',
inputSchema: ChannelIdInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async follow({ session, id }: z.infer<typeof ChannelIdInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/channels/${id}/follow`,
});
}
@Tool('channels-unfollow', {
title: 'Unfollow channel',
description: 'Unfollow a WhatsApp channel',
inputSchema: ChannelIdInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async unfollow({ session, id }: z.infer<typeof ChannelIdInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/channels/${id}/unfollow`,
});
}
@Tool('channels-mute', {
title: 'Mute channel',
description: 'Mute notifications for a WhatsApp channel',
inputSchema: ChannelIdInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async mute({ session, id }: z.infer<typeof ChannelIdInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/channels/${id}/mute`,
});
}
@Tool('channels-unmute', {
title: 'Unmute channel',
description: 'Unmute notifications for a WhatsApp channel',
inputSchema: ChannelIdInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async unmute({ session, id }: z.infer<typeof ChannelIdInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/channels/${id}/unmute`,
});
}
@Tool('channels-search-by-view', {
title: 'Search channels by view',
description:
'Search for public WhatsApp channels by view, countries, and categories',
inputSchema: ChannelSearchByViewInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async searchByView({
session,
...body
}: z.infer<typeof ChannelSearchByViewInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/channels/search/by-view`,
data: body,
});
}
@Tool('channels-search-by-text', {
title: 'Search channels by text',
description: 'Search for public WhatsApp channels by text query',
inputSchema: ChannelSearchByTextInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async searchByText({
session,
...body
}: z.infer<typeof ChannelSearchByTextInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/channels/search/by-text`,
data: body,
});
}
@Tool('channels-search-views', {
title: 'Get channel search views',
description:
'Get available view options for channel search (e.g. RECOMMENDED)',
inputSchema: ChannelSearchMetaInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async getSearchViews({ session }: z.infer<typeof ChannelSearchMetaInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/channels/search/views`,
});
}
@Tool('channels-search-countries', {
title: 'Get channel search countries',
description: 'Get available country codes for channel search',
inputSchema: ChannelSearchMetaInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async getSearchCountries({
session,
}: z.infer<typeof ChannelSearchMetaInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/channels/search/countries`,
});
}
@Tool('channels-search-categories', {
title: 'Get channel search categories',
description: 'Get available category options for channel search',
inputSchema: ChannelSearchMetaInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async getSearchCategories({
session,
}: z.infer<typeof ChannelSearchMetaInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/channels/search/categories`,
});
}
}
+46
View File
@@ -0,0 +1,46 @@
import { z } from 'zod';
import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod';
import {
ChannelSearchByText,
ChannelSearchByView,
CreateChannelRequest,
ListChannelsQuery,
PreviewChannelMessages,
} from '@waha/structures/channels.dto';
const SessionField = z.string().describe('Session name');
const ChannelIdField = z
.string()
.describe('Channel ID (123@newsletter) or invite code');
export const ChannelsListInput = DtoToZod(ListChannelsQuery).extend({
session: SessionField,
});
export const ChannelCreateInput = DtoToZod(CreateChannelRequest).extend({
session: SessionField,
});
export const ChannelIdInput = z.object({
session: SessionField,
id: ChannelIdField,
});
export const ChannelPreviewMessagesInput = DtoToZod(
PreviewChannelMessages,
).extend({
session: SessionField,
id: ChannelIdField,
});
export const ChannelSearchByViewInput = DtoToZod(ChannelSearchByView).extend({
session: SessionField,
});
export const ChannelSearchByTextInput = DtoToZod(ChannelSearchByText).extend({
session: SessionField,
});
export const ChannelSearchMetaInput = z.object({
session: SessionField,
});
+382
View File
@@ -0,0 +1,382 @@
import { z } from 'zod';
import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf';
import { McpController } from '@waha/apps/mcp/decorators/controller';
import { Tool } from '@waha/apps/mcp/decorators/tool';
import { TextMcpResponse } from '@waha/apps/mcp/responses';
import {
ChatInput,
ChatMessageInput,
ChatMessagesInput,
ChatPictureInput,
ChatsListInput,
ChatsOverviewBodyInput,
ChatsOverviewInput,
DeleteMessageInput,
EditMessageInput,
PinMessageInput,
ReadChatMessagesInput,
UnpinMessageInput,
} from '@waha/apps/mcp/tools/chats.zod';
function FetchMediaUsingApiKeyContent(key: string) {
return {
type: 'text' as const,
text: `To fetch media use "X-Api-Key: ${key}" HTTP header. If the user wants to open it - add "?x-api-key=${key}" to query params`,
};
}
export class ChatTools extends McpController {
constructor(api: WAHASelf) {
super(api);
}
@Tool('chats-list', {
title: 'List chats',
description: 'Get list of chats for a session',
inputSchema: ChatsListInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async list({ session, ...pagination }: z.infer<typeof ChatsListInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/chats`,
params: pagination,
});
}
@Tool('chats-overview', {
title: 'Get chats overview',
description:
'Get chats overview with last message, name, and picture. Sorted by last message timestamp',
inputSchema: ChatsOverviewInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async overview({ session, ...params }: z.infer<typeof ChatsOverviewInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/chats/overview`,
params: params,
});
}
@Tool('chats-overview-post', {
title: 'Get chats overview (POST)',
description:
'Get chats overview via POST body — use this instead of chats-overview when filtering by many chat ids',
inputSchema: ChatsOverviewBodyInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async overviewPost({
session,
...body
}: z.infer<typeof ChatsOverviewBodyInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/chats/overview`,
data: body,
});
}
@Tool('chats-delete', {
title: 'Delete chat',
description: 'Delete a chat',
inputSchema: ChatInput,
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
},
})
async deleteChat({ session, chatId }: z.infer<typeof ChatInput>) {
return this.textRequest({
method: 'DELETE',
url: `/api/${session}/chats/${chatId}`,
});
}
@Tool('chats-get-picture', {
title: 'Get chat picture',
description: 'Get the profile picture URL for a chat',
inputSchema: ChatPictureInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async getPicture({
session,
chatId,
refresh,
}: z.infer<typeof ChatPictureInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/chats/${chatId}/picture`,
params: { refresh: refresh },
});
}
@Tool('chats-get-messages', {
title: 'Get chat messages',
description:
'Get messages in a chat. ' +
'To retrieve all messages, paginate by incrementing the offset by limit until the returned array is empty or shorter than the limit. ' +
'To fetch media for a specific message, use chats-get-message with that message id and downloadMedia=true instead of fetching it here.',
inputSchema: ChatMessagesInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async getMessages({
session,
chatId,
_data,
...query
}: z.infer<typeof ChatMessagesInput>) {
const response = await this.request({
method: 'GET',
url: `/api/${session}/chats/${chatId}/messages`,
params: query,
});
let messages = response.data;
if (!_data && Array.isArray(messages)) {
messages = messages.map((msg: any) => {
const result = { ...msg };
delete result._data;
if (result.replyTo) {
result.replyTo = { ...result.replyTo };
delete result.replyTo._data;
}
return result;
});
}
const responseText =
typeof messages === 'string' ? messages : JSON.stringify(messages);
const result = TextMcpResponse(
JSON.stringify({ status: response.status, response: responseText }),
);
if (query.downloadMedia) {
result.content.push(FetchMediaUsingApiKeyContent(this.api.key));
}
return result;
}
@Tool('chats-read-messages', {
title: 'Read chat messages',
description: 'Mark messages as read in a chat',
inputSchema: ReadChatMessagesInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async readMessages({
session,
chatId,
...query
}: z.infer<typeof ReadChatMessagesInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/chats/${chatId}/messages/read`,
params: query,
});
}
@Tool('chats-get-message', {
title: 'Get message by ID',
description: 'Get a specific message by its ID',
inputSchema: ChatMessageInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async getMessage({
session,
chatId,
messageId,
...query
}: z.infer<typeof ChatMessageInput>) {
const result = await this.textRequest({
method: 'GET',
url: `/api/${session}/chats/${chatId}/messages/${messageId}`,
params: query,
});
if (query.downloadMedia) {
result.content.push(FetchMediaUsingApiKeyContent(this.api.key));
}
return result;
}
@Tool('chats-pin-message', {
title: 'Pin message',
description: 'Pin a message in a chat',
inputSchema: PinMessageInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async pinMessage({
session,
chatId,
messageId,
duration,
}: z.infer<typeof PinMessageInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/chats/${chatId}/messages/${messageId}/pin`,
data: { duration: duration },
});
}
@Tool('chats-unpin-message', {
title: 'Unpin message',
description: 'Unpin a message in a chat',
inputSchema: UnpinMessageInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async unpinMessage({
session,
chatId,
messageId,
}: z.infer<typeof UnpinMessageInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/chats/${chatId}/messages/${messageId}/unpin`,
});
}
@Tool('chats-clear-messages', {
title: 'Clear chat messages',
description: 'Delete all messages from a chat',
inputSchema: ChatInput,
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
},
})
async clearMessages({ session, chatId }: z.infer<typeof ChatInput>) {
return this.textRequest({
method: 'DELETE',
url: `/api/${session}/chats/${chatId}/messages`,
});
}
@Tool('chats-delete-message', {
title: 'Delete message',
description: 'Delete a specific message from a chat',
inputSchema: DeleteMessageInput,
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
},
})
async deleteMessage({
session,
chatId,
messageId,
}: z.infer<typeof DeleteMessageInput>) {
return this.textRequest({
method: 'DELETE',
url: `/api/${session}/chats/${chatId}/messages/${messageId}`,
});
}
@Tool('chats-edit-message', {
title: 'Edit message',
description: 'Edit the text of a sent message',
inputSchema: EditMessageInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async editMessage({
session,
chatId,
messageId,
...body
}: z.infer<typeof EditMessageInput>) {
return this.textRequest({
method: 'PUT',
url: `/api/${session}/chats/${chatId}/messages/${messageId}`,
data: body,
});
}
@Tool('chats-archive', {
title: 'Archive chat',
description: 'Archive a chat',
inputSchema: ChatInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async archiveChat({ session, chatId }: z.infer<typeof ChatInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/chats/${chatId}/archive`,
});
}
@Tool('chats-unarchive', {
title: 'Unarchive chat',
description: 'Unarchive a chat',
inputSchema: ChatInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async unarchiveChat({ session, chatId }: z.infer<typeof ChatInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/chats/${chatId}/unarchive`,
});
}
@Tool('chats-unread', {
title: 'Mark chat as unread',
description: 'Mark a chat as unread',
inputSchema: ChatInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async unreadChat({ session, chatId }: z.infer<typeof ChatInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/chats/${chatId}/unread`,
});
}
}
+93
View File
@@ -0,0 +1,93 @@
import { z } from 'zod';
import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod';
import {
ChatPictureQuery,
GetChatMessageQuery,
GetChatMessagesFilter,
GetChatMessagesQuery,
GetChatsOverviewParams,
GetChatsParams,
OverviewFilter,
PinMessageRequest,
ReadChatMessagesQuery,
} from '@waha/structures/chats.dto';
const SessionField = z.string().describe('Session name');
const ChatIdField = z.string().describe('Chat ID (e.g. 11111@c.us)');
const MessageIdField = z.string().describe('Message ID');
export const ChatsListInput = DtoToZod(GetChatsParams).extend({
session: SessionField,
});
export const ChatsOverviewInput = DtoToZod(GetChatsOverviewParams)
.merge(DtoToZod(OverviewFilter))
.extend({ session: SessionField });
export const ChatInput = z.object({
session: SessionField,
chatId: ChatIdField,
});
export const ChatPictureInput = DtoToZod(ChatPictureQuery).extend({
session: SessionField,
chatId: ChatIdField,
});
export const ChatMessagesInput = DtoToZod(GetChatMessagesQuery)
.merge(DtoToZod(GetChatMessagesFilter))
.extend({
session: SessionField,
chatId: z.string().describe('Chat ID'),
_data: z
.boolean()
.optional()
.default(false)
.describe(
'Include raw _data field in messages. When false (default), _data is stripped from each message and from replyTo.',
),
});
export const ReadChatMessagesInput = DtoToZod(ReadChatMessagesQuery).extend({
session: SessionField,
chatId: ChatIdField,
});
export const ChatMessageInput = DtoToZod(GetChatMessageQuery).extend({
session: SessionField,
chatId: ChatIdField,
messageId: MessageIdField,
});
export const PinMessageInput = DtoToZod(PinMessageRequest).extend({
session: SessionField,
chatId: ChatIdField,
messageId: MessageIdField,
});
export const UnpinMessageInput = z.object({
session: SessionField,
chatId: ChatIdField,
messageId: MessageIdField,
});
export const DeleteMessageInput = z.object({
session: SessionField,
chatId: ChatIdField,
messageId: MessageIdField,
});
export const EditMessageInput = z.object({
session: SessionField,
chatId: ChatIdField,
messageId: MessageIdField,
text: z.string().describe('New text'),
linkPreview: z.boolean().optional().default(true),
});
export const ChatsOverviewBodyInput = z.object({
session: SessionField,
pagination: DtoToZod(GetChatsOverviewParams),
filter: DtoToZod(OverviewFilter),
});
+169
View File
@@ -0,0 +1,169 @@
import { z } from 'zod';
import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf';
import { McpController } from '@waha/apps/mcp/decorators/controller';
import { Tool } from '@waha/apps/mcp/decorators/tool';
import {
ContactCheckExistsInput,
ContactGetInput,
ContactProfilePictureInput,
ContactRequestInput,
ContactsGetAllInput,
ContactUpsertInput,
} from '@waha/apps/mcp/tools/contacts.zod';
export class ContactTools extends McpController {
constructor(api: WAHASelf) {
super(api);
}
@Tool('contacts-get-all', {
title: 'Get all contacts',
description: 'Get all contacts for a session',
inputSchema: ContactsGetAllInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async getAll({ session, ...query }: z.infer<typeof ContactsGetAllInput>) {
return this.textRequest({
method: 'GET',
url: '/api/contacts/all',
params: { session: session, ...query },
});
}
@Tool('contacts-get', {
title: 'Get contact info',
description:
'Get basic contact info. Always returns a result even if the number is not registered in WhatsApp — use contacts-check-exists to verify registration.',
inputSchema: ContactGetInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async get({ session, id }: z.infer<typeof ContactGetInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/contacts/${id}`,
});
}
@Tool('contacts-check-exists', {
title: 'Check if number is on WhatsApp',
description: 'Check whether a phone number is registered in WhatsApp',
inputSchema: ContactCheckExistsInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async checkExists(query: z.infer<typeof ContactCheckExistsInput>) {
return this.textRequest({
method: 'GET',
url: '/api/contacts/check-exists',
params: query,
});
}
@Tool('contacts-get-about', {
title: "Get contact's about",
description:
'Get the contact\'s "about" / status text. Returns null if privacy settings block access.',
inputSchema: ContactGetInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async getAbout({ session, id }: z.infer<typeof ContactGetInput>) {
return this.textRequest({
method: 'GET',
url: '/api/contacts/about',
params: { session: session, contactId: id },
});
}
@Tool('contacts-get-picture', {
title: "Get contact's profile picture",
description:
"Get the contact's profile picture URL. Returns null if privacy settings block access.",
inputSchema: ContactProfilePictureInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async getProfilePicture(query: z.infer<typeof ContactProfilePictureInput>) {
return this.textRequest({
method: 'GET',
url: '/api/contacts/profile-picture',
params: query,
});
}
@Tool('contacts-block', {
title: 'Block contact',
description: 'Block a contact',
inputSchema: ContactRequestInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async block(body: z.infer<typeof ContactRequestInput>) {
return this.textRequest({
method: 'POST',
url: '/api/contacts/block',
data: body,
});
}
@Tool('contacts-unblock', {
title: 'Unblock contact',
description: 'Unblock a contact',
inputSchema: ContactRequestInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async unblock(body: z.infer<typeof ContactRequestInput>) {
return this.textRequest({
method: 'POST',
url: '/api/contacts/unblock',
data: body,
});
}
@Tool('contacts-upsert', {
title: 'Create or update contact',
description:
'Create or update a contact in the phone address book. May not work if multiple WhatsApp apps are installed on the same phone.',
inputSchema: ContactUpsertInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async upsert({
session,
chatId,
...body
}: z.infer<typeof ContactUpsertInput>) {
return this.textRequest({
method: 'PUT',
url: `/api/${session}/contacts/${chatId}`,
data: body,
});
}
}
+32
View File
@@ -0,0 +1,32 @@
import { z } from 'zod';
import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod';
import {
ContactsPaginationParams,
ContactProfilePictureQuery,
ContactRequest,
ContactUpdateBody,
} from '@waha/structures/contacts.dto';
import { CheckNumberStatusQuery } from '@waha/structures/chatting.dto';
const SessionField = z.string().describe('Session name');
const ContactIdField = z.string().describe('Contact ID (e.g. 11111@c.us)');
export const ContactsGetAllInput = DtoToZod(ContactsPaginationParams).extend({
session: SessionField,
});
export const ContactGetInput = z.object({
session: SessionField,
id: ContactIdField,
});
export const ContactCheckExistsInput = DtoToZod(CheckNumberStatusQuery);
export const ContactProfilePictureInput = DtoToZod(ContactProfilePictureQuery);
export const ContactRequestInput = DtoToZod(ContactRequest);
export const ContactUpsertInput = DtoToZod(ContactUpdateBody).extend({
session: SessionField,
chatId: ContactIdField,
});
+504
View File
@@ -0,0 +1,504 @@
import { z } from 'zod';
import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf';
import { McpController } from '@waha/apps/mcp/decorators/controller';
import { Tool } from '@waha/apps/mcp/decorators/tool';
import {
GroupAdminOnlyInput,
GroupCreateInput,
GroupDescriptionInput,
GroupIdInput,
GroupJoinInput,
GroupParticipantsInput,
GroupPictureInput,
GroupsListInput,
GroupsSessionInput,
GroupSetPictureInput,
GroupSubjectInput,
} from '@waha/apps/mcp/tools/groups.zod';
export class GroupTools extends McpController {
constructor(api: WAHASelf) {
super(api);
}
@Tool('groups-list', {
title: 'List groups',
description: 'Get all groups for a session',
inputSchema: GroupsListInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async list({ session, ...query }: z.infer<typeof GroupsListInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/groups`,
params: query,
});
}
@Tool('groups-count', {
title: 'Count groups',
description: 'Get the number of groups for a session',
inputSchema: GroupsSessionInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async count({ session }: z.infer<typeof GroupsSessionInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/groups/count`,
});
}
@Tool('groups-refresh', {
title: 'Refresh groups',
description: 'Refresh the groups list from the server',
inputSchema: GroupsSessionInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async refresh({ session }: z.infer<typeof GroupsSessionInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/groups/refresh`,
});
}
@Tool('groups-join-info', {
title: 'Get group join info',
description:
'Get info about a group before joining via invite code or link',
inputSchema: GroupJoinInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async joinInfo({ session, ...query }: z.infer<typeof GroupJoinInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/groups/join-info`,
params: query,
});
}
@Tool('groups-join', {
title: 'Join group',
description: 'Join a group via invite code or invite link',
inputSchema: GroupJoinInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async join({ session, ...body }: z.infer<typeof GroupJoinInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/groups/join`,
data: body,
});
}
@Tool('groups-create', {
title: 'Create group',
description: 'Create a new WhatsApp group',
inputSchema: GroupCreateInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async create({ session, ...body }: z.infer<typeof GroupCreateInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/groups`,
data: body,
});
}
@Tool('groups-get', {
title: 'Get group',
description: 'Get group information by ID',
inputSchema: GroupIdInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async get({ session, id }: z.infer<typeof GroupIdInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/groups/${id}`,
});
}
@Tool('groups-delete', {
title: 'Delete group',
description: 'Delete a WhatsApp group',
inputSchema: GroupIdInput,
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
},
})
async delete({ session, id }: z.infer<typeof GroupIdInput>) {
return this.textRequest({
method: 'DELETE',
url: `/api/${session}/groups/${id}`,
});
}
@Tool('groups-leave', {
title: 'Leave group',
description: 'Leave a WhatsApp group',
inputSchema: GroupIdInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async leave({ session, id }: z.infer<typeof GroupIdInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/groups/${id}/leave`,
});
}
@Tool('groups-get-picture', {
title: 'Get group picture',
description: 'Get the group profile picture URL',
inputSchema: GroupPictureInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async getPicture({
session,
id,
...query
}: z.infer<typeof GroupPictureInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/groups/${id}/picture`,
params: query,
});
}
@Tool('groups-set-picture', {
title: 'Set group picture',
description: 'Set the group profile picture',
inputSchema: GroupSetPictureInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async setPicture({
session,
id,
...body
}: z.infer<typeof GroupSetPictureInput>) {
return this.textRequest({
method: 'PUT',
url: `/api/${session}/groups/${id}/picture`,
data: body,
});
}
@Tool('groups-delete-picture', {
title: 'Delete group picture',
description: 'Remove the group profile picture',
inputSchema: GroupIdInput,
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
},
})
async deletePicture({ session, id }: z.infer<typeof GroupIdInput>) {
return this.textRequest({
method: 'DELETE',
url: `/api/${session}/groups/${id}/picture`,
});
}
@Tool('groups-set-description', {
title: 'Set group description',
description: 'Update the group description',
inputSchema: GroupDescriptionInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async setDescription({
session,
id,
...body
}: z.infer<typeof GroupDescriptionInput>) {
return this.textRequest({
method: 'PUT',
url: `/api/${session}/groups/${id}/description`,
data: body,
});
}
@Tool('groups-set-subject', {
title: 'Set group subject',
description: 'Update the group name/subject',
inputSchema: GroupSubjectInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async setSubject({
session,
id,
...body
}: z.infer<typeof GroupSubjectInput>) {
return this.textRequest({
method: 'PUT',
url: `/api/${session}/groups/${id}/subject`,
data: body,
});
}
@Tool('groups-get-info-admin-only', {
title: 'Get info-admin-only setting',
description: 'Get whether only admins can edit group info',
inputSchema: GroupIdInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async getInfoAdminOnly({ session, id }: z.infer<typeof GroupIdInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/groups/${id}/settings/security/info-admin-only`,
});
}
@Tool('groups-set-info-admin-only', {
title: 'Set info-admin-only setting',
description:
'Allow only admins to edit group info (title, description, photo)',
inputSchema: GroupAdminOnlyInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async setInfoAdminOnly({
session,
id,
...body
}: z.infer<typeof GroupAdminOnlyInput>) {
return this.textRequest({
method: 'PUT',
url: `/api/${session}/groups/${id}/settings/security/info-admin-only`,
data: body,
});
}
@Tool('groups-get-messages-admin-only', {
title: 'Get messages-admin-only setting',
description: 'Get whether only admins can send messages in the group',
inputSchema: GroupIdInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async getMessagesAdminOnly({ session, id }: z.infer<typeof GroupIdInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/groups/${id}/settings/security/messages-admin-only`,
});
}
@Tool('groups-set-messages-admin-only', {
title: 'Set messages-admin-only setting',
description: 'Allow only admins to send messages in the group',
inputSchema: GroupAdminOnlyInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async setMessagesAdminOnly({
session,
id,
...body
}: z.infer<typeof GroupAdminOnlyInput>) {
return this.textRequest({
method: 'PUT',
url: `/api/${session}/groups/${id}/settings/security/messages-admin-only`,
data: body,
});
}
@Tool('groups-get-invite-code', {
title: 'Get group invite code',
description: 'Get the invite code for a group',
inputSchema: GroupIdInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async getInviteCode({ session, id }: z.infer<typeof GroupIdInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/groups/${id}/invite-code`,
});
}
@Tool('groups-revoke-invite-code', {
title: 'Revoke group invite code',
description: 'Invalidate the current invite code and generate a new one',
inputSchema: GroupIdInput,
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
},
})
async revokeInviteCode({ session, id }: z.infer<typeof GroupIdInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/groups/${id}/invite-code/revoke`,
});
}
@Tool('groups-get-participants', {
title: 'Get group participants',
description: 'Get the list of group participants with roles',
inputSchema: GroupIdInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async getParticipants({ session, id }: z.infer<typeof GroupIdInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/groups/${id}/participants/v2`,
});
}
@Tool('groups-add-participants', {
title: 'Add participants',
description: 'Add participants to a group',
inputSchema: GroupParticipantsInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async addParticipants({
session,
id,
...body
}: z.infer<typeof GroupParticipantsInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/groups/${id}/participants/add`,
data: body,
});
}
@Tool('groups-remove-participants', {
title: 'Remove participants',
description: 'Remove participants from a group',
inputSchema: GroupParticipantsInput,
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
},
})
async removeParticipants({
session,
id,
...body
}: z.infer<typeof GroupParticipantsInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/groups/${id}/participants/remove`,
data: body,
});
}
@Tool('groups-promote-to-admin', {
title: 'Promote to admin',
description: 'Promote participants to group admin',
inputSchema: GroupParticipantsInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async promoteToAdmin({
session,
id,
...body
}: z.infer<typeof GroupParticipantsInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/groups/${id}/admin/promote`,
data: body,
});
}
@Tool('groups-demote-to-user', {
title: 'Demote to user',
description: 'Demote admin participants back to regular users',
inputSchema: GroupParticipantsInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async demoteToUser({
session,
id,
...body
}: z.infer<typeof GroupParticipantsInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/groups/${id}/admin/demote`,
data: body,
});
}
}
+66
View File
@@ -0,0 +1,66 @@
import { z } from 'zod';
import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod';
import {
CreateGroupRequest,
DescriptionRequest,
GroupsListFields,
GroupsPaginationParams,
JoinGroupRequest,
ParticipantsRequest,
SettingsSecurityChangeInfo,
SubjectRequest,
} from '@waha/structures/groups.dto';
import { ChatPictureQuery } from '@waha/structures/chats.dto';
import { ProfilePictureRequest } from '@waha/structures/profile.dto';
const SessionField = z.string().describe('Session name');
const GroupIdField = z.string().describe('Group ID (e.g. 123456789@g.us)');
export const GroupsListInput = DtoToZod(GroupsPaginationParams)
.merge(DtoToZod(GroupsListFields))
.extend({ session: SessionField });
export const GroupsSessionInput = z.object({ session: SessionField });
export const GroupIdInput = z.object({
session: SessionField,
id: GroupIdField,
});
export const GroupCreateInput = DtoToZod(CreateGroupRequest).extend({
session: SessionField,
});
export const GroupJoinInput = DtoToZod(JoinGroupRequest).extend({
session: SessionField,
});
export const GroupPictureInput = DtoToZod(ChatPictureQuery).extend({
session: SessionField,
id: GroupIdField,
});
export const GroupSetPictureInput = DtoToZod(ProfilePictureRequest).extend({
session: SessionField,
id: GroupIdField,
});
export const GroupDescriptionInput = DtoToZod(DescriptionRequest).extend({
session: SessionField,
id: GroupIdField,
});
export const GroupSubjectInput = DtoToZod(SubjectRequest).extend({
session: SessionField,
id: GroupIdField,
});
export const GroupAdminOnlyInput = DtoToZod(SettingsSecurityChangeInfo).extend({
session: SessionField,
id: GroupIdField,
});
export const GroupParticipantsInput = DtoToZod(ParticipantsRequest).extend({
session: SessionField,
id: GroupIdField,
});
+148
View File
@@ -0,0 +1,148 @@
import { z } from 'zod';
import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf';
import { McpController } from '@waha/apps/mcp/decorators/controller';
import { Tool } from '@waha/apps/mcp/decorators/tool';
import {
LabelBodyInput,
LabelChatInput,
LabelIdInput,
LabelsSessionInput,
LabelUpdateInput,
SetChatLabelsInput,
} from '@waha/apps/mcp/tools/labels.zod';
export class LabelTools extends McpController {
constructor(api: WAHASelf) {
super(api);
}
@Tool('labels-get-all', {
title: 'Get all labels',
description: 'Get all labels for a session',
inputSchema: LabelsSessionInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async getAll({ session }: z.infer<typeof LabelsSessionInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/labels`,
});
}
@Tool('labels-create', {
title: 'Create label',
description: 'Create a new label (max 20 per session)',
inputSchema: LabelBodyInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async create({ session, ...body }: z.infer<typeof LabelBodyInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/labels`,
data: body,
});
}
@Tool('labels-update', {
title: 'Update label',
description: 'Update an existing label name or color',
inputSchema: LabelUpdateInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async update({
session,
labelId,
...body
}: z.infer<typeof LabelUpdateInput>) {
return this.textRequest({
method: 'PUT',
url: `/api/${session}/labels/${labelId}`,
data: body,
});
}
@Tool('labels-delete', {
title: 'Delete label',
description: 'Delete a label',
inputSchema: LabelIdInput,
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
},
})
async delete({ session, labelId }: z.infer<typeof LabelIdInput>) {
return this.textRequest({
method: 'DELETE',
url: `/api/${session}/labels/${labelId}`,
});
}
@Tool('labels-get-chat-labels', {
title: 'Get chat labels',
description: 'Get all labels applied to a chat',
inputSchema: LabelChatInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async getChatLabels({ session, chatId }: z.infer<typeof LabelChatInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/labels/chats/${chatId}`,
});
}
@Tool('labels-set-chat-labels', {
title: 'Set chat labels',
description: 'Replace all labels on a chat',
inputSchema: SetChatLabelsInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async setChatLabels({
session,
chatId,
...body
}: z.infer<typeof SetChatLabelsInput>) {
return this.textRequest({
method: 'PUT',
url: `/api/${session}/labels/chats/${chatId}`,
data: body,
});
}
@Tool('labels-get-chats-by-label', {
title: 'Get chats by label',
description: 'Get all chats that have a given label applied',
inputSchema: LabelIdInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async getChatsByLabel({ session, labelId }: z.infer<typeof LabelIdInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/labels/${labelId}/chats`,
});
}
}
+33
View File
@@ -0,0 +1,33 @@
import { z } from 'zod';
import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod';
import { LabelBody, SetLabelsRequest } from '@waha/structures/labels.dto';
const SessionField = z.string().describe('Session name');
const LabelIdField = z.string().describe('Label ID');
const ChatIdField = z.string().describe('Chat ID (e.g. 11111@c.us)');
export const LabelsSessionInput = z.object({ session: SessionField });
export const LabelIdInput = z.object({
session: SessionField,
labelId: LabelIdField,
});
export const LabelBodyInput = DtoToZod(LabelBody).extend({
session: SessionField,
});
export const LabelUpdateInput = DtoToZod(LabelBody).extend({
session: SessionField,
labelId: LabelIdField,
});
export const LabelChatInput = z.object({
session: SessionField,
chatId: ChatIdField,
});
export const SetChatLabelsInput = DtoToZod(SetLabelsRequest).extend({
session: SessionField,
chatId: ChatIdField,
});
+90
View File
@@ -0,0 +1,90 @@
import { z } from 'zod';
import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf';
import { McpController } from '@waha/apps/mcp/decorators/controller';
import { Tool } from '@waha/apps/mcp/decorators/tool';
import {
LidInput,
LidsListInput,
LidsSessionInput,
PhoneNumberInput,
} from '@waha/apps/mcp/tools/lids.zod';
export class LidTools extends McpController {
constructor(api: WAHASelf) {
super(api);
}
@Tool('lids-get-all', {
title: 'Get all LIDs',
description:
'LIDs (Linked IDs) are anonymous identifiers WhatsApp assigns to contacts in some regions instead of phone numbers. ' +
'Get all known LID-to-phone-number mappings for a session.',
inputSchema: LidsListInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async getAll({ session, ...query }: z.infer<typeof LidsListInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/lids`,
params: query,
});
}
@Tool('lids-count', {
title: 'Count LIDs',
description: 'Get the number of known LIDs for a session',
inputSchema: LidsSessionInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async count({ session }: z.infer<typeof LidsSessionInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/lids/count`,
});
}
@Tool('lids-find-pn-by-lid', {
title: 'Find phone number by LID',
description: 'Look up the phone number (chat ID) for a given LID',
inputSchema: LidInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async findPNByLid({ session, lid }: z.infer<typeof LidInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/lids/${lid}`,
});
}
@Tool('lids-find-lid-by-pn', {
title: 'Find LID by phone number',
description: 'Look up the LID for a given phone number / chat ID',
inputSchema: PhoneNumberInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async findLidByPN({
session,
phoneNumber,
}: z.infer<typeof PhoneNumberInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/lids/pn/${phoneNumber}`,
});
}
}
+23
View File
@@ -0,0 +1,23 @@
import { z } from 'zod';
import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod';
import { LidsListQueryParams } from '@waha/structures/lids.dto';
const SessionField = z.string().describe('Session name');
export const LidsListInput = DtoToZod(LidsListQueryParams).extend({
session: SessionField,
});
export const LidsSessionInput = z.object({ session: SessionField });
export const LidInput = z.object({
session: SessionField,
lid: z.string().describe('LID (e.g. 1111111@lid)'),
});
export const PhoneNumberInput = z.object({
session: SessionField,
phoneNumber: z
.string()
.describe('Phone number / chat ID (e.g. 3333333@c.us)'),
});
+25
View File
@@ -0,0 +1,25 @@
import { McpController } from '@waha/apps/mcp/decorators/controller';
import { Tool } from '@waha/apps/mcp/decorators/tool';
import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf';
export class PingTools extends McpController {
constructor(api: WAHASelf) {
super(api);
}
@Tool('ping', {
title: 'Ping the server',
description: 'Check if the WAHA server is alive and responding',
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async ping() {
return this.textRequest({
method: 'GET',
url: '/ping',
});
}
}
+87
View File
@@ -0,0 +1,87 @@
import { z } from 'zod';
import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf';
import { McpController } from '@waha/apps/mcp/decorators/controller';
import { Tool } from '@waha/apps/mcp/decorators/tool';
import {
PresenceChatInput,
PresenceSessionInput,
PresenceSetInput,
} from '@waha/apps/mcp/tools/presence.zod';
export class PresenceTools extends McpController {
constructor(api: WAHASelf) {
super(api);
}
@Tool('presence-set', {
title: 'Set presence',
description:
'Set the session presence. Use ONLINE/OFFLINE for global scope (no chatId). ' +
'Use TYPING/RECORDING/PAUSED with a chatId for chat-scoped presence.',
inputSchema: PresenceSetInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async set({ session, ...body }: z.infer<typeof PresenceSetInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/presence`,
data: body,
});
}
@Tool('presence-get-all', {
title: 'Get all presences',
description: 'Get all subscribed presence information for a session',
inputSchema: PresenceSessionInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async getAll({ session }: z.infer<typeof PresenceSessionInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/presence`,
});
}
@Tool('presence-get', {
title: 'Get chat presence',
description:
'Get presence for a chat. Subscribes automatically if not yet subscribed.',
inputSchema: PresenceChatInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async get({ session, chatId }: z.infer<typeof PresenceChatInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/presence/${chatId}`,
});
}
@Tool('presence-subscribe', {
title: 'Subscribe to presence',
description: 'Subscribe to presence events for a chat',
inputSchema: PresenceChatInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async subscribe({ session, chatId }: z.infer<typeof PresenceChatInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/presence/${chatId}/subscribe`,
});
}
}
+17
View File
@@ -0,0 +1,17 @@
import { z } from 'zod';
import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod';
import { WAHASessionPresence } from '@waha/structures/presence.dto';
const SessionField = z.string().describe('Session name');
const ChatIdField = z.string().describe('Chat ID (e.g. 11111@c.us)');
export const PresenceSessionInput = z.object({ session: SessionField });
export const PresenceSetInput = DtoToZod(WAHASessionPresence).extend({
session: SessionField,
});
export const PresenceChatInput = z.object({
session: SessionField,
chatId: ChatIdField,
});
+105
View File
@@ -0,0 +1,105 @@
import { z } from 'zod';
import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf';
import { McpController } from '@waha/apps/mcp/decorators/controller';
import { Tool } from '@waha/apps/mcp/decorators/tool';
import {
ProfileNameInput,
ProfilePictureInput,
ProfileSessionInput,
ProfileStatusInput,
} from '@waha/apps/mcp/tools/profile.zod';
export class ProfileTools extends McpController {
constructor(api: WAHASelf) {
super(api);
}
@Tool('profile-get', {
title: 'Get my profile',
description:
'Get the profile info (id, name, picture) for the session account',
inputSchema: ProfileSessionInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async get({ session }: z.infer<typeof ProfileSessionInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/profile`,
});
}
@Tool('profile-set-name', {
title: 'Set profile name',
description: 'Update the display name for the session account',
inputSchema: ProfileNameInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async setName({ session, ...body }: z.infer<typeof ProfileNameInput>) {
return this.textRequest({
method: 'PUT',
url: `/api/${session}/profile/name`,
data: body,
});
}
@Tool('profile-set-status', {
title: 'Set profile status',
description: 'Update the "About" / status text for the session account',
inputSchema: ProfileStatusInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async setStatus({ session, ...body }: z.infer<typeof ProfileStatusInput>) {
return this.textRequest({
method: 'PUT',
url: `/api/${session}/profile/status`,
data: body,
});
}
@Tool('profile-set-picture', {
title: 'Set profile picture',
description: 'Update the profile picture for the session account',
inputSchema: ProfilePictureInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async setPicture({ session, ...body }: z.infer<typeof ProfilePictureInput>) {
return this.textRequest({
method: 'PUT',
url: `/api/${session}/profile/picture`,
data: body,
});
}
@Tool('profile-delete-picture', {
title: 'Delete profile picture',
description: 'Remove the profile picture for the session account',
inputSchema: ProfileSessionInput,
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
},
})
async deletePicture({ session }: z.infer<typeof ProfileSessionInput>) {
return this.textRequest({
method: 'DELETE',
url: `/api/${session}/profile/picture`,
});
}
}
+23
View File
@@ -0,0 +1,23 @@
import { z } from 'zod';
import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod';
import {
ProfileNameRequest,
ProfilePictureRequest,
ProfileStatusRequest,
} from '@waha/structures/profile.dto';
const SessionField = z.string().describe('Session name');
export const ProfileSessionInput = z.object({ session: SessionField });
export const ProfileNameInput = DtoToZod(ProfileNameRequest).extend({
session: SessionField,
});
export const ProfileStatusInput = DtoToZod(ProfileStatusRequest).extend({
session: SessionField,
});
export const ProfilePictureInput = DtoToZod(ProfilePictureRequest).extend({
session: SessionField,
});
+425
View File
@@ -0,0 +1,425 @@
import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf';
import { z } from 'zod';
import { McpController } from '@waha/apps/mcp/decorators/controller';
import { Tool } from '@waha/apps/mcp/decorators/tool';
import {
ForwardMessageInput,
NewMessageIdInput,
SendEventInput,
SendButtonsInput,
SendButtonsReplyInput,
SendContactVcardInput,
SendFileInput,
SendImageInput,
SendLinkCustomPreviewInput,
SendListInput,
SendLocationInput,
SendPollInput,
SendPollVoteInput,
SendSeenInput,
SendTextInput,
SendVideoInput,
SendVoiceInput,
SetReactionInput,
SetStarInput,
TypingInput,
} from '@waha/apps/mcp/tools/send.zod';
const FileNote =
'Use file.url for remote HTTP/HTTPS URLs. ' +
'For local file paths (e.g. /tmp/file.ext) - upload it to S3 or other remote server first, or read the file, encode it as base64, and pass it via file.data instead.';
export class SendTools extends McpController {
constructor(api: WAHASelf) {
super(api);
}
@Tool('send-text', {
title: 'Send a text message',
description:
'Send a text message to a number. ' +
'Always call start-typing first, wait a few seconds, call stop-typing, then send the message.',
inputSchema: SendTextInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async sendText(data: z.infer<typeof SendTextInput>) {
return this.textRequest({
method: 'POST',
url: '/api/sendText',
data: data,
});
}
@Tool('send-image', {
title: 'Send an image',
description: 'Send an image to a chat. ' + FileNote,
inputSchema: SendImageInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async sendImage(data: z.infer<typeof SendImageInput>) {
return this.textRequest({
method: 'POST',
url: '/api/sendImage',
data: data,
});
}
@Tool('send-file', {
title: 'Send a file',
description: 'Send a file (document) to a chat. ' + FileNote,
inputSchema: SendFileInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async sendFile(data: z.infer<typeof SendFileInput>) {
return this.textRequest({
method: 'POST',
url: '/api/sendFile',
data: data,
});
}
@Tool('send-voice', {
title: 'Send a voice message',
description: 'Send a voice message to a chat. ' + FileNote,
inputSchema: SendVoiceInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async sendVoice(data: z.infer<typeof SendVoiceInput>) {
return this.textRequest({
method: 'POST',
url: '/api/sendVoice',
data: data,
});
}
@Tool('send-video', {
title: 'Send a video',
description: 'Send a video to a chat. ' + FileNote,
inputSchema: SendVideoInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async sendVideo(data: z.infer<typeof SendVideoInput>) {
return this.textRequest({
method: 'POST',
url: '/api/sendVideo',
data: data,
});
}
@Tool('send-link-custom-preview', {
title: 'Send a text message with a custom link preview',
description:
'Send a text message with a custom link preview (title, description, image)',
inputSchema: SendLinkCustomPreviewInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async sendLinkCustomPreview(
data: z.infer<typeof SendLinkCustomPreviewInput>,
) {
return this.textRequest({
method: 'POST',
url: '/api/send/link-custom-preview',
data: data,
});
}
@Tool('send-buttons', {
title: 'Send a buttons message',
description: 'Send an interactive buttons message',
inputSchema: SendButtonsInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async sendButtons(data: z.infer<typeof SendButtonsInput>) {
return this.textRequest({
method: 'POST',
url: '/api/sendButtons',
data: data,
});
}
@Tool('send-list', {
title: 'Send a list message',
description: 'Send an interactive list message with sections and rows',
inputSchema: SendListInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async sendList(data: z.infer<typeof SendListInput>) {
return this.textRequest({
method: 'POST',
url: '/api/sendList',
data: data,
});
}
@Tool('send-seen', {
title: 'Mark messages as seen',
description: 'Mark one or more messages as seen (read)',
inputSchema: SendSeenInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async sendSeen(data: z.infer<typeof SendSeenInput>) {
return this.textRequest({
method: 'POST',
url: '/api/sendSeen',
data: data,
});
}
@Tool('send-poll', {
title: 'Send a poll',
description: 'Send a poll message with options',
inputSchema: SendPollInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async sendPoll(data: z.infer<typeof SendPollInput>) {
return this.textRequest({
method: 'POST',
url: '/api/sendPoll',
data: data,
});
}
@Tool('send-poll-vote', {
title: 'Vote on a poll',
description: 'Cast vote(s) on an existing poll message',
inputSchema: SendPollVoteInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async sendPollVote(data: z.infer<typeof SendPollVoteInput>) {
return this.textRequest({
method: 'POST',
url: '/api/sendPollVote',
data: data,
});
}
@Tool('send-location', {
title: 'Send a location',
description: 'Send a location with latitude, longitude, and title',
inputSchema: SendLocationInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async sendLocation(data: z.infer<typeof SendLocationInput>) {
return this.textRequest({
method: 'POST',
url: '/api/sendLocation',
data: data,
});
}
@Tool('send-contact-vcard', {
title: 'Send a contact vCard',
description: 'Send one or more contacts as a vCard',
inputSchema: SendContactVcardInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async sendContactVcard(data: z.infer<typeof SendContactVcardInput>) {
return this.textRequest({
method: 'POST',
url: '/api/sendContactVcard',
data: data,
});
}
@Tool('send-buttons-reply', {
title: 'Reply to a buttons message',
description: 'Send a reply to an interactive buttons message',
inputSchema: SendButtonsReplyInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async sendButtonsReply(data: z.infer<typeof SendButtonsReplyInput>) {
return this.textRequest({
method: 'POST',
url: '/api/send/buttons/reply',
data: data,
});
}
@Tool('forward-message', {
title: 'Forward a message',
description: 'Forward an existing message to a chat',
inputSchema: ForwardMessageInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async forwardMessage(data: z.infer<typeof ForwardMessageInput>) {
return this.textRequest({
method: 'POST',
url: '/api/forwardMessage',
data: data,
});
}
@Tool('start-typing', {
title: 'Start typing indicator',
description:
'Show a typing indicator in a chat (runs until stop-typing is called)',
inputSchema: TypingInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async startTyping(data: z.infer<typeof TypingInput>) {
return this.textRequest({
method: 'POST',
url: '/api/startTyping',
data: data,
});
}
@Tool('stop-typing', {
title: 'Stop typing indicator',
description: 'Stop the typing indicator in a chat',
inputSchema: TypingInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async stopTyping(data: z.infer<typeof TypingInput>) {
return this.textRequest({
method: 'POST',
url: '/api/stopTyping',
data: data,
});
}
@Tool('set-reaction', {
title: 'React to a message',
description:
'React to a message with an emoji. Send empty string to remove reaction',
inputSchema: SetReactionInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async setReaction(data: z.infer<typeof SetReactionInput>) {
return this.textRequest({
method: 'PUT',
url: '/api/reaction',
data: data,
});
}
@Tool('set-star', {
title: 'Star or unstar a message',
description: 'Star or unstar a message',
inputSchema: SetStarInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
async setStar(data: z.infer<typeof SetStarInput>) {
return this.textRequest({
method: 'PUT',
url: '/api/star',
data: data,
});
}
@Tool('send-event-message', {
title: 'Send an event message',
description: 'Send an event/appointment message to a chat',
inputSchema: SendEventInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async sendEvent({
session,
chatId,
reply_to,
...event
}: z.infer<typeof SendEventInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/events`,
data: { chatId: chatId, reply_to: reply_to, event: event },
});
}
@Tool('new-message-id', {
title: 'Generate a new message ID',
description: 'Generate a new unique message ID for use in send requests',
inputSchema: NewMessageIdInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: false,
},
})
async newMessageId({ session }: z.infer<typeof NewMessageIdInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/new-message-id`,
});
}
}
+53
View File
@@ -0,0 +1,53 @@
import { z } from 'zod';
import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod';
import {
ChatRequest,
MessageButtonReply,
MessageContactVcardRequest,
MessageFileRequest,
MessageForwardRequest,
MessageImageRequest,
MessageLinkCustomPreviewRequest,
MessageLocationRequest,
MessagePollRequest,
MessagePollVoteRequest,
MessageReactionRequest,
MessageStarRequest,
MessageTextRequest,
MessageVideoRequest,
MessageVoiceRequest,
SendSeenRequest,
} from '@waha/structures/chatting.dto';
import { SendButtonsRequest } from '@waha/structures/chatting.buttons.dto';
import { SendListRequest } from '@waha/structures/chatting.list.dto';
import { EventMessage } from '@waha/structures/events.dto';
export const SendTextInput = DtoToZod(MessageTextRequest);
export const SendImageInput = DtoToZod(MessageImageRequest);
export const SendFileInput = DtoToZod(MessageFileRequest);
export const SendVoiceInput = DtoToZod(MessageVoiceRequest);
export const SendVideoInput = DtoToZod(MessageVideoRequest);
export const SendLinkCustomPreviewInput = DtoToZod(
MessageLinkCustomPreviewRequest,
);
export const SendButtonsInput = DtoToZod(SendButtonsRequest);
export const SendListInput = DtoToZod(SendListRequest);
export const SendSeenInput = DtoToZod(SendSeenRequest);
export const SendPollInput = DtoToZod(MessagePollRequest);
export const SendPollVoteInput = DtoToZod(MessagePollVoteRequest);
export const SendLocationInput = DtoToZod(MessageLocationRequest);
export const SendContactVcardInput = DtoToZod(MessageContactVcardRequest);
export const SendButtonsReplyInput = DtoToZod(MessageButtonReply);
export const ForwardMessageInput = DtoToZod(MessageForwardRequest);
export const TypingInput = DtoToZod(ChatRequest);
export const SetReactionInput = DtoToZod(MessageReactionRequest);
export const SetStarInput = DtoToZod(MessageStarRequest);
export const NewMessageIdInput = z.object({
session: z.string().describe('Session name'),
});
export const SendEventInput = DtoToZod(EventMessage).extend({
session: z.string().describe('Session name'),
chatId: z.string().describe('Chat ID (e.g. 11111@c.us)'),
reply_to: z.string().optional().describe('Message ID to reply to'),
});
+81
View File
@@ -0,0 +1,81 @@
import { z } from 'zod';
import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf';
import { McpController } from '@waha/apps/mcp/decorators/controller';
import { Tool } from '@waha/apps/mcp/decorators/tool';
import { EnvironmentInput, StopInput } from '@waha/apps/mcp/tools/server.zod';
export class ServerTools extends McpController {
constructor(api: WAHASelf) {
super(api);
}
@Tool('server-version', {
title: 'Get server version',
description: 'Get the version and build information of the WAHA server',
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async version() {
return this.textRequest({
method: 'GET',
url: '/api/server/version',
});
}
@Tool('server-status', {
title: 'Get server status',
description: 'Get server uptime, start timestamp, and worker information',
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async status() {
return this.textRequest({
method: 'GET',
url: '/api/server/status',
});
}
@Tool('server-environment', {
title: 'Get server environment',
description:
'Get environment variables from the server (WAHA_* and WHATSAPP_* by default)',
inputSchema: EnvironmentInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async environment(params: z.infer<typeof EnvironmentInput>) {
return this.textRequest({
method: 'GET',
url: '/api/server/environment',
params: params,
});
}
@Tool('server-stop', {
title: 'Stop the server',
description:
'Stop (and restart) the WAHA server. Use force=true for immediate termination.',
inputSchema: StopInput,
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
},
})
async stop(body: z.infer<typeof StopInput>) {
return this.textRequest({
method: 'POST',
url: '/api/server/stop',
data: body,
});
}
}
+17
View File
@@ -0,0 +1,17 @@
import { z } from 'zod';
export const EnvironmentInput = z.object({
all: z
.boolean()
.optional()
.describe('Include all environment variables, not just WAHA_* ones'),
});
export const StopInput = z.object({
force: z
.boolean()
.optional()
.describe(
'Force-terminate immediately instead of graceful shutdown (SIGKILL vs SIGTERM)',
),
});
+174
View File
@@ -0,0 +1,174 @@
import { z } from 'zod';
import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf';
import { McpController } from '@waha/apps/mcp/decorators/controller';
import { Tool } from '@waha/apps/mcp/decorators/tool';
import {
SessionCreateInput,
SessionListInput,
SessionNameInput,
SessionUpdateInput,
} from '@waha/apps/mcp/tools/sessions.zod';
export class SessionTools extends McpController {
constructor(api: WAHASelf) {
super(api);
}
@Tool('sessions-list', {
title: 'List sessions',
description: 'List all WhatsApp sessions',
inputSchema: SessionListInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async list(params: z.infer<typeof SessionListInput>) {
// Send back all sessions
params.all = true;
return this.textRequest({
method: 'GET',
url: '/api/sessions',
params: params,
});
}
@Tool('sessions-get', {
title: 'Get session',
description: 'Get information about a WhatsApp session',
inputSchema: SessionNameInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
},
})
async get({ session }: z.infer<typeof SessionNameInput>) {
return this.textRequest({
method: 'GET',
url: `/api/sessions/${session}`,
});
}
@Tool('sessions-create', {
title: 'Create session',
description: 'Create a new WhatsApp session',
inputSchema: SessionCreateInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async create(body: z.infer<typeof SessionCreateInput>) {
return this.textRequest({
method: 'POST',
url: '/api/sessions',
data: body,
});
}
@Tool('sessions-update', {
title: 'Update session',
description: 'Update config of an existing WhatsApp session',
inputSchema: SessionUpdateInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async update({ session, ...body }: z.infer<typeof SessionUpdateInput>) {
return this.textRequest({
method: 'PUT',
url: `/api/sessions/${session}`,
data: body,
});
}
@Tool('sessions-delete', {
title: 'Delete session',
description: 'Delete a WhatsApp session (stops and logs out)',
inputSchema: SessionNameInput,
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
},
})
async delete({ session }: z.infer<typeof SessionNameInput>) {
return this.textRequest({
method: 'DELETE',
url: `/api/sessions/${session}`,
});
}
@Tool('sessions-start', {
title: 'Start session',
description: 'Start an existing WhatsApp session',
inputSchema: SessionNameInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async start({ session }: z.infer<typeof SessionNameInput>) {
return this.textRequest({
method: 'POST',
url: `/api/sessions/${session}/start`,
});
}
@Tool('sessions-stop', {
title: 'Stop session',
description: 'Stop a running WhatsApp session',
inputSchema: SessionNameInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async stop({ session }: z.infer<typeof SessionNameInput>) {
return this.textRequest({
method: 'POST',
url: `/api/sessions/${session}/stop`,
});
}
@Tool('sessions-logout', {
title: 'Logout session',
description: 'Logout from a WhatsApp session',
inputSchema: SessionNameInput,
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
},
})
async logout({ session }: z.infer<typeof SessionNameInput>) {
return this.textRequest({
method: 'POST',
url: `/api/sessions/${session}/logout`,
});
}
@Tool('sessions-restart', {
title: 'Restart session',
description: 'Restart a WhatsApp session',
inputSchema: SessionNameInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async restart({ session }: z.infer<typeof SessionNameInput>) {
return this.textRequest({
method: 'POST',
url: `/api/sessions/${session}/restart`,
});
}
}
+20
View File
@@ -0,0 +1,20 @@
import { z } from 'zod';
import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod';
import {
ListSessionsQuery,
SessionCreateRequest,
SessionUpdateRequest,
} from '@waha/structures/sessions.dto';
export const SessionNameInput = z.object({
session: z.string().describe('Session name'),
});
export const SessionListInput = DtoToZod(ListSessionsQuery);
export const SessionCreateInput = DtoToZod(SessionCreateRequest);
export const SessionUpdateInput = DtoToZod(SessionUpdateRequest).extend({
session: z.string().describe('Session name'),
});
+130
View File
@@ -0,0 +1,130 @@
import { z } from 'zod';
import { WAHASelf } from '@waha/apps/app_sdk/waha/WAHASelf';
import { McpController } from '@waha/apps/mcp/decorators/controller';
import { Tool } from '@waha/apps/mcp/decorators/tool';
import {
DeleteStatusInput,
ImageStatusInput,
StatusSessionInput,
TextStatusInput,
VideoStatusInput,
VoiceStatusInput,
} from '@waha/apps/mcp/tools/status.zod';
const FileNote =
'Use file.url for remote HTTP/HTTPS URLs. ' +
'For local file paths - encode as base64 and pass via file.data instead.';
export class StatusTools extends McpController {
constructor(api: WAHASelf) {
super(api);
}
@Tool('status-send-text', {
title: 'Send text status',
description: 'Post a text WhatsApp status update',
inputSchema: TextStatusInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async sendText({ session, ...body }: z.infer<typeof TextStatusInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/status/text`,
data: body,
});
}
@Tool('status-send-image', {
title: 'Send image status',
description: 'Post an image WhatsApp status update. ' + FileNote,
inputSchema: ImageStatusInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async sendImage({ session, ...body }: z.infer<typeof ImageStatusInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/status/image`,
data: body,
});
}
@Tool('status-send-voice', {
title: 'Send voice status',
description: 'Post a voice WhatsApp status update. ' + FileNote,
inputSchema: VoiceStatusInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async sendVoice({ session, ...body }: z.infer<typeof VoiceStatusInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/status/voice`,
data: body,
});
}
@Tool('status-send-video', {
title: 'Send video status',
description: 'Post a video WhatsApp status update. ' + FileNote,
inputSchema: VideoStatusInput,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
},
})
async sendVideo({ session, ...body }: z.infer<typeof VideoStatusInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/status/video`,
data: body,
});
}
@Tool('status-delete', {
title: 'Delete status',
description: 'Delete a posted WhatsApp status update',
inputSchema: DeleteStatusInput,
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
},
})
async delete({ session, ...body }: z.infer<typeof DeleteStatusInput>) {
return this.textRequest({
method: 'POST',
url: `/api/${session}/status/delete`,
data: body,
});
}
@Tool('status-new-message-id', {
title: 'Generate status message ID',
description:
'Generate a new message ID to use when sending a status update',
inputSchema: StatusSessionInput,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: false,
},
})
async newMessageId({ session }: z.infer<typeof StatusSessionInput>) {
return this.textRequest({
method: 'GET',
url: `/api/${session}/status/new-message-id`,
});
}
}
+33
View File
@@ -0,0 +1,33 @@
import { z } from 'zod';
import { DtoToZod } from '@waha/apps/mcp/schemas/DtoToZod';
import {
DeleteStatusRequest,
ImageStatus,
TextStatus,
VideoStatus,
VoiceStatus,
} from '@waha/structures/status.dto';
const SessionField = z.string().describe('Session name');
export const StatusSessionInput = z.object({ session: SessionField });
export const TextStatusInput = DtoToZod(TextStatus).extend({
session: SessionField,
});
export const ImageStatusInput = DtoToZod(ImageStatus).extend({
session: SessionField,
});
export const VoiceStatusInput = DtoToZod(VoiceStatus).extend({
session: SessionField,
});
export const VideoStatusInput = DtoToZod(VideoStatus).extend({
session: SessionField,
});
export const DeleteStatusInput = DtoToZod(DeleteStatusRequest).extend({
session: SessionField,
});
+1
View File
@@ -190,6 +190,7 @@ export class SwaggerConfiguratorCore {
const config = this.app.get(WhatsappConfigService);
const exclude = lodash.uniq([
'/api/',
'/mcp',
dashboardConfig.dashboardUri,
'/health',
'/ping',
+1 -4
View File
@@ -1,6 +1,4 @@
import {
getBrowserExecutablePath as getBrowserExecutablePathAutodetect,
} from '@waha/core/abc/session.browser';
import { getBrowserExecutablePath as getBrowserExecutablePathAutodetect } from '@waha/core/abc/session.browser';
import {
CoreMediaConverter,
IMediaConverter,
@@ -145,7 +143,6 @@ const qrcode = require('qrcode-terminal');
axiosRetry(axios, { retries: 3 });
export function ensureSuffix(phone) {
const suffix = '@c.us';
if (phone.includes('@')) {
+1 -1
View File
@@ -257,7 +257,7 @@ export class AppModuleCore {
consumer
.apply(ApiKeyAuthMiddleware)
.exclude(...exclude)
.forRoutes('api', 'health');
.forRoutes('api', 'health', 'mcp');
// Dashboard
const dashboardCredentials = this.dashboardConfig.credentials;
+49 -1
View File
@@ -37,7 +37,7 @@ export class ApiKeyService {
this.repository = manager.apiKeyRepository;
}
async create(body: ApiKeyRequest): Promise<ApiKeyDTO> {
async create(body: ApiKeyRequest, appId?: string): Promise<ApiKeyDTO> {
CheckInvariant(body);
if (body.session) {
const exists = await this.manager.exists(body.session);
@@ -56,6 +56,7 @@ export class ApiKeyService {
isAdmin: body.isAdmin,
session: body.session,
actions: body.actions ?? null,
app_id: appId ?? null,
};
const idExists = await this.repository.getById(apikey.id);
if (idExists) {
@@ -87,6 +88,11 @@ export class ApiKeyService {
if (!existing) {
throw new NotFoundException('API key not found');
}
if (existing.app_id) {
throw new UnprocessableEntityException(
`This API key is managed by app '${existing.app_id}'. Update the app instead of the key.`,
);
}
const apikey: ApiKey = {
...existing,
isActive: body.isActive,
@@ -113,10 +119,52 @@ export class ApiKeyService {
if (!existing) {
throw new NotFoundException('API key not found');
}
if (existing.app_id) {
const appExists = await this.manager.store
.getWAHADatabase()('apps')
.where('id', existing.app_id)
.first();
if (appExists) {
throw new UnprocessableEntityException(
`This API key is managed by app '${existing.app_id}'. Delete the app instead of the key.`,
);
}
}
await this.repository.deleteById(id);
return { result: true };
}
async createForApp(body: ApiKeyRequest, appId: string): Promise<ApiKeyDTO> {
return this.create(body, appId);
}
async deleteForApp(id: string): Promise<void> {
const existing = await this.repository.getById(id);
if (!existing) {
return;
}
await this.repository.deleteById(id);
}
async updateForApp(
id: string,
updates: Partial<Pick<ApiKey, 'isActive' | 'actions'>>,
): Promise<void> {
const existing = await this.repository.getById(id);
if (!existing) {
return;
}
await this.repository.upsert({ ...existing, ...updates });
}
async getById(id: string): Promise<ApiKeyDTO | null> {
const existing = await this.repository.getById(id);
if (!existing) {
return null;
}
return this.toDTO(existing);
}
private toDTO(apikey: ApiKey): ApiKeyDTO {
return {
id: apikey.id,
+1
View File
@@ -7,6 +7,7 @@ export interface ApiKey {
isAdmin: boolean;
session: string | null;
actions: SessionActions | null;
app_id?: string | null;
}
export interface IApiKeyRepository {
+1
View File
@@ -189,6 +189,7 @@ export class MessageTextRequest extends ChatRequest {
mentions?: string[];
@ReplyToProperty()
@IsOptional()
reply_to?: string;
linkPreview?: boolean = true;
+249 -9
View File
@@ -1367,6 +1367,15 @@ __metadata:
languageName: node
linkType: hard
"@hono/node-server@npm:^1.19.9":
version: 1.19.14
resolution: "@hono/node-server@npm:1.19.14"
peerDependencies:
hono: ^4
checksum: 10/618dd95feeb3fd11ec8502e088879cd86529523788de19602edebd16892dd61899e73564d6e3d00875cc5a49488a908ddb2aa425d28f9cdeb7f22cfecabf022c
languageName: node
linkType: hard
"@img/colour@npm:^1.0.0":
version: 1.0.0
resolution: "@img/colour@npm:1.0.0"
@@ -2274,6 +2283,39 @@ __metadata:
languageName: node
linkType: hard
"@modelcontextprotocol/sdk@npm:^1.29.0":
version: 1.29.0
resolution: "@modelcontextprotocol/sdk@npm:1.29.0"
dependencies:
"@hono/node-server": "npm:^1.19.9"
ajv: "npm:^8.17.1"
ajv-formats: "npm:^3.0.1"
content-type: "npm:^1.0.5"
cors: "npm:^2.8.5"
cross-spawn: "npm:^7.0.5"
eventsource: "npm:^3.0.2"
eventsource-parser: "npm:^3.0.0"
express: "npm:^5.2.1"
express-rate-limit: "npm:^8.2.1"
hono: "npm:^4.11.4"
jose: "npm:^6.1.3"
json-schema-typed: "npm:^8.0.2"
pkce-challenge: "npm:^5.0.0"
raw-body: "npm:^3.0.0"
zod: "npm:^3.25 || ^4.0"
zod-to-json-schema: "npm:^3.25.1"
peerDependencies:
"@cfworker/json-schema": ^4.1.1
zod: ^3.25 || ^4.0
peerDependenciesMeta:
"@cfworker/json-schema":
optional: true
zod:
optional: false
checksum: 10/ff551b97e06b661f95fec8fd34e112c446e69894a84a9979cdac369fb5de27f0a1a5c1f4e2a1f270cc60f93e54c28a8059a94ca51c3d528d2670ade874b244f9
languageName: node
linkType: hard
"@mongodb-js/saslprep@npm:^1.1.5":
version: 1.3.0
resolution: "@mongodb-js/saslprep@npm:1.3.0"
@@ -4569,7 +4611,7 @@ __metadata:
languageName: node
linkType: hard
"ajv-formats@npm:3.0.1":
"ajv-formats@npm:3.0.1, ajv-formats@npm:^3.0.1":
version: 3.0.1
resolution: "ajv-formats@npm:3.0.1"
dependencies:
@@ -4641,6 +4683,18 @@ __metadata:
languageName: node
linkType: hard
"ajv@npm:^8.17.1":
version: 8.18.0
resolution: "ajv@npm:8.18.0"
dependencies:
fast-deep-equal: "npm:^3.1.3"
fast-uri: "npm:^3.0.1"
json-schema-traverse: "npm:^1.0.0"
require-from-string: "npm:^2.0.2"
checksum: 10/bfed9de827a2b27c6d4084324eda76a4e32bdde27410b3e9b81d06e6f8f5c78370fc6b93fe1d869f1939ff1d7c4ae8896960995acb8425e3e9288c8884247c48
languageName: node
linkType: hard
"ansi-align@npm:^3.0.0":
version: 3.0.1
resolution: "ansi-align@npm:3.0.1"
@@ -5223,6 +5277,23 @@ __metadata:
languageName: node
linkType: hard
"body-parser@npm:^2.2.1":
version: 2.2.2
resolution: "body-parser@npm:2.2.2"
dependencies:
bytes: "npm:^3.1.2"
content-type: "npm:^1.0.5"
debug: "npm:^4.4.3"
http-errors: "npm:^2.0.0"
iconv-lite: "npm:^0.7.0"
on-finished: "npm:^2.4.1"
qs: "npm:^6.14.1"
raw-body: "npm:^3.0.1"
type-is: "npm:^2.0.1"
checksum: 10/69671f67d4d5ae5974593901a92d639757231da1725ed6de4d35e86cde9ce7650afdf1cd28df9b6f7892ea7f9eb03ccb30c70fe27d679275ae4cb4aae5ce1b21
languageName: node
linkType: hard
"boolbase@npm:^1.0.0":
version: 1.0.0
resolution: "boolbase@npm:1.0.0"
@@ -5402,7 +5473,7 @@ __metadata:
languageName: node
linkType: hard
"bytes@npm:3.1.2, bytes@npm:^3.1.2":
"bytes@npm:3.1.2, bytes@npm:^3.1.2, bytes@npm:~3.1.2":
version: 3.1.2
resolution: "bytes@npm:3.1.2"
checksum: 10/a10abf2ba70c784471d6b4f58778c0beeb2b5d405148e66affa91f23a9f13d07603d0a0354667310ae1d6dc141474ffd44e2a074be0f6e2254edb8fc21445388
@@ -6131,6 +6202,16 @@ __metadata:
languageName: node
linkType: hard
"cors@npm:^2.8.5":
version: 2.8.6
resolution: "cors@npm:2.8.6"
dependencies:
object-assign: "npm:^4"
vary: "npm:^1"
checksum: 10/aa7174305b21ceb90f9c84f4eaa32f04432d333addbfdc0d1eb7310393c48902e5364aada5ac2f5d054528d63b3179238444475426fcb74e1e345077de485727
languageName: node
linkType: hard
"cosmiconfig@npm:^8.2.0":
version: 8.3.6
resolution: "cosmiconfig@npm:8.3.6"
@@ -6217,7 +6298,7 @@ __metadata:
languageName: node
linkType: hard
"cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.6":
"cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.5, cross-spawn@npm:^7.0.6":
version: 7.0.6
resolution: "cross-spawn@npm:7.0.6"
dependencies:
@@ -6436,7 +6517,7 @@ __metadata:
languageName: node
linkType: hard
"depd@npm:2.0.0, depd@npm:^2.0.0":
"depd@npm:2.0.0, depd@npm:^2.0.0, depd@npm:~2.0.0":
version: 2.0.0
resolution: "depd@npm:2.0.0"
checksum: 10/c0c8ff36079ce5ada64f46cc9d6fd47ebcf38241105b6e0c98f412e8ad91f084bcf906ff644cc3a4bd876ca27a62accb8b0fff72ea6ed1a414b89d8506f4a5ca
@@ -6926,6 +7007,22 @@ __metadata:
languageName: node
linkType: hard
"eventsource-parser@npm:^3.0.0, eventsource-parser@npm:^3.0.1":
version: 3.0.8
resolution: "eventsource-parser@npm:3.0.8"
checksum: 10/286a84a7005e3e669e94dce0bb48f00acfda0d3973671ae2790e48a93f7b27a85b1828df8f3876c70afe4d577b6a7e4f8794cb596072585b584b4f207bc35c15
languageName: node
linkType: hard
"eventsource@npm:^3.0.2":
version: 3.0.7
resolution: "eventsource@npm:3.0.7"
dependencies:
eventsource-parser: "npm:^3.0.1"
checksum: 10/e034915bc97068d1d38617951afd798e6776d6a3a78e36a7569c235b177c7afc2625c9fe82656f7341ab72c7eeecb3fd507b7f88e9328f2448872ff9c4742bb6
languageName: node
linkType: hard
"execa@npm:^5.0.0, execa@npm:^5.1.1":
version: 5.1.1
resolution: "execa@npm:5.1.1"
@@ -6986,6 +7083,17 @@ __metadata:
languageName: node
linkType: hard
"express-rate-limit@npm:^8.2.1":
version: 8.3.2
resolution: "express-rate-limit@npm:8.3.2"
dependencies:
ip-address: "npm:10.1.0"
peerDependencies:
express: ">= 4.11"
checksum: 10/71afac0fff29bf03117a89902953e7feafc67402332910cfb601b27da079b98f04ba551a1ef4f0ad5cf2538d9599c2649450af6d3f7505bcc7d24f7eaf765a4e
languageName: node
linkType: hard
"express@npm:5.1.0, express@npm:^4.21.1 || ^5.0.0":
version: 5.1.0
resolution: "express@npm:5.1.0"
@@ -7021,6 +7129,42 @@ __metadata:
languageName: node
linkType: hard
"express@npm:^5.2.1":
version: 5.2.1
resolution: "express@npm:5.2.1"
dependencies:
accepts: "npm:^2.0.0"
body-parser: "npm:^2.2.1"
content-disposition: "npm:^1.0.0"
content-type: "npm:^1.0.5"
cookie: "npm:^0.7.1"
cookie-signature: "npm:^1.2.1"
debug: "npm:^4.4.0"
depd: "npm:^2.0.0"
encodeurl: "npm:^2.0.0"
escape-html: "npm:^1.0.3"
etag: "npm:^1.8.1"
finalhandler: "npm:^2.1.0"
fresh: "npm:^2.0.0"
http-errors: "npm:^2.0.0"
merge-descriptors: "npm:^2.0.0"
mime-types: "npm:^3.0.0"
on-finished: "npm:^2.4.1"
once: "npm:^1.4.0"
parseurl: "npm:^1.3.3"
proxy-addr: "npm:^2.0.7"
qs: "npm:^6.14.0"
range-parser: "npm:^1.2.1"
router: "npm:^2.2.0"
send: "npm:^1.1.0"
serve-static: "npm:^2.2.0"
statuses: "npm:^2.0.1"
type-is: "npm:^2.0.1"
vary: "npm:^1.1.2"
checksum: 10/4aa545d89702ac83f645c77abda1b57bcabe288f0b380fb5580fac4e323ea0eb533005c8e666b4e19152fb16d4abf11ba87b22aa9a10857a0485cd86b94639bd
languageName: node
linkType: hard
"extend@npm:^3.0.0":
version: 3.0.2
resolution: "extend@npm:3.0.2"
@@ -7854,6 +7998,13 @@ __metadata:
languageName: node
linkType: hard
"hono@npm:^4.11.4":
version: 4.12.14
resolution: "hono@npm:4.12.14"
checksum: 10/2e620adb89e773a7e982dee8d8c9917f79c71b89cb051b5bc0d2aa91b5318373e90ba7a236bdc4762dc36026c1d7e5e979d6b48cf1dbbe44fc524a5c4df227d9
languageName: node
linkType: hard
"hookified@npm:^1.8.2, hookified@npm:^1.9.1":
version: 1.9.1
resolution: "hookified@npm:1.9.1"
@@ -7900,6 +8051,19 @@ __metadata:
languageName: node
linkType: hard
"http-errors@npm:~2.0.1":
version: 2.0.1
resolution: "http-errors@npm:2.0.1"
dependencies:
depd: "npm:~2.0.0"
inherits: "npm:~2.0.4"
setprototypeof: "npm:~1.2.0"
statuses: "npm:~2.0.2"
toidentifier: "npm:~1.0.1"
checksum: 10/9fe31bc0edf36566c87048aed1d3d0cbe03552564adc3541626a0613f542d753fbcb13bdfcec0a3a530dbe1714bb566c89d46244616b66bddd26ac413b06a207
languageName: node
linkType: hard
"http-proxy-agent@npm:^4.0.1":
version: 4.0.1
resolution: "http-proxy-agent@npm:4.0.1"
@@ -7989,6 +8153,15 @@ __metadata:
languageName: node
linkType: hard
"iconv-lite@npm:^0.7.0, iconv-lite@npm:~0.7.0":
version: 0.7.2
resolution: "iconv-lite@npm:0.7.2"
dependencies:
safer-buffer: "npm:>= 2.1.2 < 3.0.0"
checksum: 10/24c937b532f868e938386b62410b303b7c767ce3d08dc2829cbe59464d5a26ef86ae5ad1af6b34eec43ddfea39e7d101638644b0178d67262fa87015d59f983a
languageName: node
linkType: hard
"ieee754@npm:^1.1.13, ieee754@npm:^1.2.1":
version: 1.2.1
resolution: "ieee754@npm:1.2.1"
@@ -8056,7 +8229,7 @@ __metadata:
languageName: node
linkType: hard
"inherits@npm:2, inherits@npm:2.0.4, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.0, inherits@npm:~2.0.3":
"inherits@npm:2, inherits@npm:2.0.4, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.0, inherits@npm:~2.0.3, inherits@npm:~2.0.4":
version: 2.0.4
resolution: "inherits@npm:2.0.4"
checksum: 10/cd45e923bee15186c07fa4c89db0aace24824c482fb887b528304694b2aa6ff8a898da8657046a5dcf3e46cd6db6c61629551f9215f208d7c3f157cf9b290521
@@ -8094,6 +8267,13 @@ __metadata:
languageName: node
linkType: hard
"ip-address@npm:10.1.0":
version: 10.1.0
resolution: "ip-address@npm:10.1.0"
checksum: 10/a6979629d1ad9c1fb424bc25182203fad739b40225aebc55ec6243bbff5035faf7b9ed6efab3a097de6e713acbbfde944baacfa73e11852bb43989c45a68d79e
languageName: node
linkType: hard
"ip-address@npm:^9.0.5":
version: 9.0.5
resolution: "ip-address@npm:9.0.5"
@@ -8888,6 +9068,13 @@ __metadata:
languageName: node
linkType: hard
"jose@npm:^6.1.3":
version: 6.2.2
resolution: "jose@npm:6.2.2"
checksum: 10/fd66f24916d2507dbde0ca77ede86377e7d7eae42c7f94db0dfd014b0c6ce5041365dc8e757a44e54cd7606ea5e9c757aa544b51e55cb8a736e83320db1b8258
languageName: node
linkType: hard
"joycon@npm:^3.1.1":
version: 3.1.1
resolution: "joycon@npm:3.1.1"
@@ -8969,6 +9156,13 @@ __metadata:
languageName: node
linkType: hard
"json-schema-typed@npm:^8.0.2":
version: 8.0.2
resolution: "json-schema-typed@npm:8.0.2"
checksum: 10/fa866d1fe91e3a94aa4fe007861475cd03dcaf47b719861cab171ef2f8598478007c634d29ae45de94ee34ddff4e13414c63ea5ff06c5b868b613142c699d511
languageName: node
linkType: hard
"json5@npm:^1.0.1":
version: 1.0.2
resolution: "json5@npm:1.0.2"
@@ -10991,6 +11185,13 @@ __metadata:
languageName: node
linkType: hard
"pkce-challenge@npm:^5.0.0":
version: 5.0.1
resolution: "pkce-challenge@npm:5.0.1"
checksum: 10/51d11f68d5a78617cfb2e9c2706dadcc2cbe55ffb55b21d42a6ed848ac5159db2657bf6c966a5a414119aa839ceb64240afea35e9e1c06946b57606ed0b43789
languageName: node
linkType: hard
"pkg-dir@npm:^4.2.0":
version: 4.2.0
resolution: "pkg-dir@npm:4.2.0"
@@ -11489,6 +11690,15 @@ __metadata:
languageName: node
linkType: hard
"qs@npm:^6.14.1":
version: 6.15.1
resolution: "qs@npm:6.15.1"
dependencies:
side-channel: "npm:^1.1.0"
checksum: 10/ec10b9957446b3f4a38000940f6374720b4e2985209b89df197066038c951472ea24cd98d6bc6df73a0cbec75bc056f638032e3fb447345017ff7e0f0a2693ac
languageName: node
linkType: hard
"querystring@npm:0.2.0":
version: 0.2.0
resolution: "querystring@npm:0.2.0"
@@ -11538,6 +11748,18 @@ __metadata:
languageName: node
linkType: hard
"raw-body@npm:^3.0.1":
version: 3.0.2
resolution: "raw-body@npm:3.0.2"
dependencies:
bytes: "npm:~3.1.2"
http-errors: "npm:~2.0.1"
iconv-lite: "npm:~0.7.0"
unpipe: "npm:~1.0.0"
checksum: 10/4168c82157bd69175d5bd960e59b74e253e237b358213694946a427a6f750a18b8e150f036fed3421b3e83294b071a4e2bb01037a79ccacdac05360c63d3ebba
languageName: node
linkType: hard
"rc@npm:1.2.8, rc@npm:^1.2.7, rc@npm:^1.2.8":
version: 1.2.8
resolution: "rc@npm:1.2.8"
@@ -12059,7 +12281,7 @@ __metadata:
languageName: node
linkType: hard
"setprototypeof@npm:1.2.0":
"setprototypeof@npm:1.2.0, setprototypeof@npm:~1.2.0":
version: 1.2.0
resolution: "setprototypeof@npm:1.2.0"
checksum: 10/fde1630422502fbbc19e6844346778f99d449986b2f9cdcceb8326730d2f3d9964dbcb03c02aaadaefffecd0f2c063315ebea8b3ad895914bf1afc1747fc172e
@@ -12484,7 +12706,7 @@ __metadata:
languageName: node
linkType: hard
"statuses@npm:^2.0.1":
"statuses@npm:^2.0.1, statuses@npm:~2.0.2":
version: 2.0.2
resolution: "statuses@npm:2.0.2"
checksum: 10/6927feb50c2a75b2a4caab2c565491f7a93ad3d8dbad7b1398d52359e9243a20e2ebe35e33726dee945125ef7a515e9097d8a1b910ba2bbd818265a2f6c39879
@@ -12976,7 +13198,7 @@ __metadata:
languageName: node
linkType: hard
"toidentifier@npm:1.0.1":
"toidentifier@npm:1.0.1, toidentifier@npm:~1.0.1":
version: 1.0.1
resolution: "toidentifier@npm:1.0.1"
checksum: 10/952c29e2a85d7123239b5cfdd889a0dde47ab0497f0913d70588f19c53f7e0b5327c95f4651e413c74b785147f9637b17410ac8c846d5d4a20a5a33eb6dc3a45
@@ -13398,7 +13620,7 @@ __metadata:
languageName: node
linkType: hard
"unpipe@npm:1.0.0":
"unpipe@npm:1.0.0, unpipe@npm:~1.0.0":
version: 1.0.0
resolution: "unpipe@npm:1.0.0"
checksum: 10/4fa18d8d8d977c55cb09715385c203197105e10a6d220087ec819f50cb68870f02942244f1017565484237f1f8c5d3cd413631b1ae104d3096f24fdfde1b4aa2
@@ -13552,6 +13774,7 @@ __metadata:
"@grpc/grpc-js": "npm:^1.14.1"
"@grpc/proto-loader": "npm:^0.8.0"
"@liaoliaots/nestjs-redis": "npm:^9"
"@modelcontextprotocol/sdk": "npm:^1.29.0"
"@nestjs/axios": "npm:^3.0.2"
"@nestjs/bullmq": "npm:^11.0.2"
"@nestjs/cli": "npm:^11.0.0"
@@ -13653,6 +13876,7 @@ __metadata:
whatsapp-web.js: "github:devlikeapro/whatsapp-web.js#fork-main-2026-02-18"
write-file-atomic: "npm:^6.0.0"
yaml: "npm:^2.7.1"
zod: "npm:^4.3.6"
dependenciesMeta:
bufferutil:
optional: true
@@ -14105,9 +14329,25 @@ __metadata:
languageName: node
linkType: hard
"zod-to-json-schema@npm:^3.25.1":
version: 3.25.2
resolution: "zod-to-json-schema@npm:3.25.2"
peerDependencies:
zod: ^3.25.28 || ^4
checksum: 10/7035328654113f1a0b8e4c2d34a06f918c93650ef8a50d4fb30ad8f22e47d5762c163af9c82494756b34776bae3c41c26cfc6945105b0eee7dceb528cc07e665
languageName: node
linkType: hard
"zod@npm:^3.24.1":
version: 3.25.63
resolution: "zod@npm:3.25.63"
checksum: 10/ef3de3e94b27c94fd9c6b8d8fcd61bf6702494c91dcfb84f585f2822bcdf1d96305c953c264a221af79745795517d9d5b5ce59889d2a8d23967e6096945049bd
languageName: node
linkType: hard
"zod@npm:^3.25 || ^4.0, zod@npm:^4.3.6":
version: 4.3.6
resolution: "zod@npm:4.3.6"
checksum: 10/25fc0f62e01b557b4644bf0b393bbaf47542ab30877c37837ea8caf314a8713d220c7d7fe51f68ffa72f0e1018ddfa34d96f1973d23033f5a2a5a9b6b9d9da01
languageName: node
linkType: hard