mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-11 07:58:06 -05:00
Compare commits
25 Commits
v0.5.32
...
SIM-514-us
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f81c0ba9bf | ||
|
|
6c10f31a40 | ||
|
|
896e9674c2 | ||
|
|
f2450d3c26 | ||
|
|
cfbe4a4790 | ||
|
|
1f22d7a9ec | ||
|
|
2259bfcb8f | ||
|
|
85af046754 | ||
|
|
57f3697dd5 | ||
|
|
a15ac7360d | ||
|
|
93217438ef | ||
|
|
7b5405e968 | ||
|
|
1ae3b47f5c | ||
|
|
3120a785df | ||
|
|
8775e76c32 | ||
|
|
9a6c68789d | ||
|
|
08bc1125bd | ||
|
|
f4f74da1dc | ||
|
|
de330d80f5 | ||
|
|
b7228d57f7 | ||
|
|
dcbeca1abe | ||
|
|
27ea333974 | ||
|
|
9861d3a0ac | ||
|
|
fdbf8be79b | ||
|
|
6f4f4e22f0 |
@@ -188,7 +188,6 @@ DATABASE_URL="postgresql://postgres:your_password@localhost:5432/simstudio"
|
||||
|
||||
Then run the migrations:
|
||||
```bash
|
||||
cd apps/sim # Required so drizzle picks correct .env file
|
||||
bunx drizzle-kit migrate --config=./drizzle.config.ts
|
||||
```
|
||||
|
||||
|
||||
@@ -3335,6 +3335,24 @@ export function SalesforceIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function ServiceNowIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 1570 1403'
|
||||
width='48'
|
||||
height='48'
|
||||
>
|
||||
<path
|
||||
fill='#62d84e'
|
||||
fillRule='evenodd'
|
||||
d='M1228.4 138.9c129.2 88.9 228.9 214.3 286.3 360.2 57.5 145.8 70 305.5 36 458.5S1437.8 1250 1324 1357.9c-13.3 12.9-28.8 23.4-45.8 30.8-17 7.5-35.2 11.9-53.7 12.9-18.5 1.1-37.1-1.1-54.8-6.6-17.7-5.4-34.3-13.9-49.1-25.2-48.2-35.9-101.8-63.8-158.8-82.6-57.1-18.9-116.7-28.5-176.8-28.5s-119.8 9.6-176.8 28.5c-57 18.8-110.7 46.7-158.9 82.6-14.6 11.2-31 19.8-48.6 25.3s-36 7.8-54.4 6.8c-18.4-.9-36.5-5.1-53.4-12.4s-32.4-17.5-45.8-30.2C132.5 1251 53 1110.8 19 956.8s-20.9-314.6 37.6-461c58.5-146.5 159.6-272 290.3-360.3S631.8.1 789.6.5c156.8 1.3 309.6 49.6 438.8 138.4m-291.8 1014c48.2-19.2 92-48 128.7-84.6 36.7-36.7 65.5-80.4 84.7-128.6 19.2-48.1 28.4-99.7 27-151.5 0-103.9-41.3-203.5-114.8-277S889 396.4 785 396.4s-203.7 41.3-277.2 114.8S393 684.3 393 788.2c-1.4 51.8 7.8 103.4 27 151.5 19.2 48.2 48 91.9 84.7 128.6 36.7 36.6 80.5 65.4 128.6 84.6 48.2 19.2 99.8 28.4 151.7 27 51.8 1.4 103.4-7.8 151.6-27'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ApolloIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -85,6 +85,7 @@ import {
|
||||
SendgridIcon,
|
||||
SentryIcon,
|
||||
SerperIcon,
|
||||
ServiceNowIcon,
|
||||
SftpIcon,
|
||||
ShopifyIcon,
|
||||
SlackIcon,
|
||||
@@ -139,6 +140,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
webflow: WebflowIcon,
|
||||
pinecone: PineconeIcon,
|
||||
apollo: ApolloIcon,
|
||||
servicenow: ServiceNowIcon,
|
||||
whatsapp: WhatsAppIcon,
|
||||
typeform: TypeformIcon,
|
||||
qdrant: QdrantIcon,
|
||||
|
||||
108
apps/docs/content/docs/de/tools/servicenow.mdx
Normal file
108
apps/docs/content/docs/de/tools/servicenow.mdx
Normal file
@@ -0,0 +1,108 @@
|
||||
---
|
||||
title: ServiceNow
|
||||
description: Erstellen, lesen, aktualisieren, löschen und Massenimport von
|
||||
ServiceNow-Datensätzen
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="servicenow"
|
||||
color="#032D42"
|
||||
/>
|
||||
|
||||
## Nutzungsanleitung
|
||||
|
||||
Integrieren Sie ServiceNow in Ihren Workflow. Kann Datensätze in jeder ServiceNow-Tabelle erstellen, lesen, aktualisieren und löschen (Vorfälle, Aufgaben, Benutzer usw.). Unterstützt Massenimport-Operationen für Datenmigration und ETL.
|
||||
|
||||
## Tools
|
||||
|
||||
### `servicenow_create_record`
|
||||
|
||||
Erstellen eines neuen Datensatzes in einer ServiceNow-Tabelle
|
||||
|
||||
#### Eingabe
|
||||
|
||||
| Parameter | Typ | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | Ja | ServiceNow-Instanz-URL \(z. B. https://instance.service-now.com\) |
|
||||
| `credential` | string | Nein | ServiceNow OAuth-Anmeldeinformations-ID |
|
||||
| `tableName` | string | Ja | Tabellenname \(z. B. incident, task, sys_user\) |
|
||||
| `fields` | json | Ja | Felder, die für den Datensatz festgelegt werden sollen \(JSON-Objekt\) |
|
||||
|
||||
#### Ausgabe
|
||||
|
||||
| Parameter | Typ | Beschreibung |
|
||||
| --------- | ---- | ----------- |
|
||||
| `record` | json | Erstellter ServiceNow-Datensatz mit sys_id und anderen Feldern |
|
||||
| `metadata` | json | Metadaten der Operation |
|
||||
|
||||
### `servicenow_read_record`
|
||||
|
||||
Lesen von Datensätzen aus einer ServiceNow-Tabelle
|
||||
|
||||
#### Eingabe
|
||||
|
||||
| Parameter | Typ | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | Nein | ServiceNow-Instanz-URL \(automatisch aus OAuth erkannt, falls nicht angegeben\) |
|
||||
| `credential` | string | Nein | ServiceNow OAuth-Anmeldeinformations-ID |
|
||||
| `tableName` | string | Ja | Tabellenname |
|
||||
| `sysId` | string | Nein | Spezifische Datensatz-sys_id |
|
||||
| `number` | string | Nein | Datensatznummer \(z. B. INC0010001\) |
|
||||
| `query` | string | Nein | Kodierte Abfragezeichenfolge \(z. B. "active=true^priority=1"\) |
|
||||
| `limit` | number | Nein | Maximale Anzahl der zurückzugebenden Datensätze |
|
||||
| `fields` | string | Nein | Durch Kommas getrennte Liste der zurückzugebenden Felder |
|
||||
|
||||
#### Ausgabe
|
||||
|
||||
| Parameter | Typ | Beschreibung |
|
||||
| --------- | ---- | ----------- |
|
||||
| `records` | array | Array von ServiceNow-Datensätzen |
|
||||
| `metadata` | json | Metadaten der Operation |
|
||||
|
||||
### `servicenow_update_record`
|
||||
|
||||
Einen bestehenden Datensatz in einer ServiceNow-Tabelle aktualisieren
|
||||
|
||||
#### Eingabe
|
||||
|
||||
| Parameter | Typ | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | Nein | ServiceNow-Instanz-URL \(wird automatisch aus OAuth erkannt, falls nicht angegeben\) |
|
||||
| `credential` | string | Nein | ServiceNow-OAuth-Credential-ID |
|
||||
| `tableName` | string | Ja | Tabellenname |
|
||||
| `sysId` | string | Ja | Sys_id des zu aktualisierenden Datensatzes |
|
||||
| `fields` | json | Ja | Zu aktualisierende Felder \(JSON-Objekt\) |
|
||||
|
||||
#### Ausgabe
|
||||
|
||||
| Parameter | Typ | Beschreibung |
|
||||
| --------- | ---- | ----------- |
|
||||
| `record` | json | Aktualisierter ServiceNow-Datensatz |
|
||||
| `metadata` | json | Metadaten der Operation |
|
||||
|
||||
### `servicenow_delete_record`
|
||||
|
||||
Einen Datensatz aus einer ServiceNow-Tabelle löschen
|
||||
|
||||
#### Eingabe
|
||||
|
||||
| Parameter | Typ | Erforderlich | Beschreibung |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | Nein | ServiceNow-Instanz-URL \(wird automatisch aus OAuth erkannt, falls nicht angegeben\) |
|
||||
| `credential` | string | Nein | ServiceNow-OAuth-Credential-ID |
|
||||
| `tableName` | string | Ja | Tabellenname |
|
||||
| `sysId` | string | Ja | Sys_id des zu löschenden Datensatzes |
|
||||
|
||||
#### Ausgabe
|
||||
|
||||
| Parameter | Typ | Beschreibung |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Ob das Löschen erfolgreich war |
|
||||
| `metadata` | json | Metadaten der Operation |
|
||||
|
||||
## Hinweise
|
||||
|
||||
- Kategorie: `tools`
|
||||
- Typ: `servicenow`
|
||||
@@ -80,6 +80,7 @@
|
||||
"sendgrid",
|
||||
"sentry",
|
||||
"serper",
|
||||
"servicenow",
|
||||
"sftp",
|
||||
"sharepoint",
|
||||
"shopify",
|
||||
|
||||
111
apps/docs/content/docs/en/tools/servicenow.mdx
Normal file
111
apps/docs/content/docs/en/tools/servicenow.mdx
Normal file
@@ -0,0 +1,111 @@
|
||||
---
|
||||
title: ServiceNow
|
||||
description: Create, read, update, delete, and bulk import ServiceNow records
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="servicenow"
|
||||
color="#032D42"
|
||||
/>
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate ServiceNow into your workflow. Can create, read, update, and delete records in any ServiceNow table (incidents, tasks, users, etc.). Supports bulk import operations for data migration and ETL.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `servicenow_create_record`
|
||||
|
||||
Create a new record in a ServiceNow table
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com\) |
|
||||
| `credential` | string | No | ServiceNow OAuth credential ID |
|
||||
| `tableName` | string | Yes | Table name \(e.g., incident, task, sys_user\) |
|
||||
| `fields` | json | Yes | Fields to set on the record \(JSON object\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `record` | json | Created ServiceNow record with sys_id and other fields |
|
||||
| `metadata` | json | Operation metadata |
|
||||
|
||||
### `servicenow_read_record`
|
||||
|
||||
Read records from a ServiceNow table
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | No | ServiceNow instance URL \(auto-detected from OAuth if not provided\) |
|
||||
| `credential` | string | No | ServiceNow OAuth credential ID |
|
||||
| `tableName` | string | Yes | Table name |
|
||||
| `sysId` | string | No | Specific record sys_id |
|
||||
| `number` | string | No | Record number \(e.g., INC0010001\) |
|
||||
| `query` | string | No | Encoded query string \(e.g., "active=true^priority=1"\) |
|
||||
| `limit` | number | No | Maximum number of records to return |
|
||||
| `fields` | string | No | Comma-separated list of fields to return |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `records` | array | Array of ServiceNow records |
|
||||
| `metadata` | json | Operation metadata |
|
||||
|
||||
### `servicenow_update_record`
|
||||
|
||||
Update an existing record in a ServiceNow table
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | No | ServiceNow instance URL \(auto-detected from OAuth if not provided\) |
|
||||
| `credential` | string | No | ServiceNow OAuth credential ID |
|
||||
| `tableName` | string | Yes | Table name |
|
||||
| `sysId` | string | Yes | Record sys_id to update |
|
||||
| `fields` | json | Yes | Fields to update \(JSON object\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `record` | json | Updated ServiceNow record |
|
||||
| `metadata` | json | Operation metadata |
|
||||
|
||||
### `servicenow_delete_record`
|
||||
|
||||
Delete a record from a ServiceNow table
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | No | ServiceNow instance URL \(auto-detected from OAuth if not provided\) |
|
||||
| `credential` | string | No | ServiceNow OAuth credential ID |
|
||||
| `tableName` | string | Yes | Table name |
|
||||
| `sysId` | string | Yes | Record sys_id to delete |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether the deletion was successful |
|
||||
| `metadata` | json | Operation metadata |
|
||||
|
||||
|
||||
|
||||
## Notes
|
||||
|
||||
- Category: `tools`
|
||||
- Type: `servicenow`
|
||||
107
apps/docs/content/docs/es/tools/servicenow.mdx
Normal file
107
apps/docs/content/docs/es/tools/servicenow.mdx
Normal file
@@ -0,0 +1,107 @@
|
||||
---
|
||||
title: ServiceNow
|
||||
description: Crea, lee, actualiza, elimina e importa masivamente registros de ServiceNow
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="servicenow"
|
||||
color="#032D42"
|
||||
/>
|
||||
|
||||
## Instrucciones de uso
|
||||
|
||||
Integra ServiceNow en tu flujo de trabajo. Puede crear, leer, actualizar y eliminar registros en cualquier tabla de ServiceNow (incidentes, tareas, usuarios, etc.). Admite operaciones de importación masiva para migración de datos y ETL.
|
||||
|
||||
## Herramientas
|
||||
|
||||
### `servicenow_create_record`
|
||||
|
||||
Crea un nuevo registro en una tabla de ServiceNow
|
||||
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Requerido | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | Sí | URL de la instancia de ServiceNow \(ej., https://instance.service-now.com\) |
|
||||
| `credential` | string | No | ID de credencial OAuth de ServiceNow |
|
||||
| `tableName` | string | Sí | Nombre de la tabla \(ej., incident, task, sys_user\) |
|
||||
| `fields` | json | Sí | Campos a establecer en el registro \(objeto JSON\) |
|
||||
|
||||
#### Salida
|
||||
|
||||
| Parámetro | Tipo | Descripción |
|
||||
| --------- | ---- | ----------- |
|
||||
| `record` | json | Registro de ServiceNow creado con sys_id y otros campos |
|
||||
| `metadata` | json | Metadatos de la operación |
|
||||
|
||||
### `servicenow_read_record`
|
||||
|
||||
Lee registros de una tabla de ServiceNow
|
||||
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Requerido | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | No | URL de la instancia de ServiceNow \(detectada automáticamente desde OAuth si no se proporciona\) |
|
||||
| `credential` | string | No | ID de credencial OAuth de ServiceNow |
|
||||
| `tableName` | string | Sí | Nombre de la tabla |
|
||||
| `sysId` | string | No | sys_id específico del registro |
|
||||
| `number` | string | No | Número de registro \(ej., INC0010001\) |
|
||||
| `query` | string | No | Cadena de consulta codificada \(ej., "active=true^priority=1"\) |
|
||||
| `limit` | number | No | Número máximo de registros a devolver |
|
||||
| `fields` | string | No | Lista de campos separados por comas a devolver |
|
||||
|
||||
#### Salida
|
||||
|
||||
| Parámetro | Tipo | Descripción |
|
||||
| --------- | ---- | ----------- |
|
||||
| `records` | array | Array de registros de ServiceNow |
|
||||
| `metadata` | json | Metadatos de la operación |
|
||||
|
||||
### `servicenow_update_record`
|
||||
|
||||
Actualizar un registro existente en una tabla de ServiceNow
|
||||
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Requerido | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | No | URL de la instancia de ServiceNow \(detectada automáticamente desde OAuth si no se proporciona\) |
|
||||
| `credential` | string | No | ID de credencial OAuth de ServiceNow |
|
||||
| `tableName` | string | Sí | Nombre de la tabla |
|
||||
| `sysId` | string | Sí | sys_id del registro a actualizar |
|
||||
| `fields` | json | Sí | Campos a actualizar \(objeto JSON\) |
|
||||
|
||||
#### Salida
|
||||
|
||||
| Parámetro | Tipo | Descripción |
|
||||
| --------- | ---- | ----------- |
|
||||
| `record` | json | Registro de ServiceNow actualizado |
|
||||
| `metadata` | json | Metadatos de la operación |
|
||||
|
||||
### `servicenow_delete_record`
|
||||
|
||||
Eliminar un registro de una tabla de ServiceNow
|
||||
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Requerido | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | No | URL de la instancia de ServiceNow \(detectada automáticamente desde OAuth si no se proporciona\) |
|
||||
| `credential` | string | No | ID de credencial OAuth de ServiceNow |
|
||||
| `tableName` | string | Sí | Nombre de la tabla |
|
||||
| `sysId` | string | Sí | sys_id del registro a eliminar |
|
||||
|
||||
#### Salida
|
||||
|
||||
| Parámetro | Tipo | Descripción |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Si la eliminación fue exitosa |
|
||||
| `metadata` | json | Metadatos de la operación |
|
||||
|
||||
## Notas
|
||||
|
||||
- Categoría: `tools`
|
||||
- Tipo: `servicenow`
|
||||
108
apps/docs/content/docs/fr/tools/servicenow.mdx
Normal file
108
apps/docs/content/docs/fr/tools/servicenow.mdx
Normal file
@@ -0,0 +1,108 @@
|
||||
---
|
||||
title: ServiceNow
|
||||
description: Créer, lire, mettre à jour, supprimer et importer en masse des
|
||||
enregistrements ServiceNow
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="servicenow"
|
||||
color="#032D42"
|
||||
/>
|
||||
|
||||
## Instructions d'utilisation
|
||||
|
||||
Intégrez ServiceNow dans votre flux de travail. Permet de créer, lire, mettre à jour et supprimer des enregistrements dans n'importe quelle table ServiceNow (incidents, tâches, utilisateurs, etc.). Prend en charge les opérations d'importation en masse pour la migration de données et l'ETL.
|
||||
|
||||
## Outils
|
||||
|
||||
### `servicenow_create_record`
|
||||
|
||||
Créer un nouvel enregistrement dans une table ServiceNow
|
||||
|
||||
#### Entrée
|
||||
|
||||
| Paramètre | Type | Requis | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | Oui | URL de l'instance ServiceNow \(par exemple, https://instance.service-now.com\) |
|
||||
| `credential` | string | Non | ID d'identification OAuth ServiceNow |
|
||||
| `tableName` | string | Oui | Nom de la table \(par exemple, incident, task, sys_user\) |
|
||||
| `fields` | json | Oui | Champs à définir sur l'enregistrement \(objet JSON\) |
|
||||
|
||||
#### Sortie
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `record` | json | Enregistrement ServiceNow créé avec sys_id et autres champs |
|
||||
| `metadata` | json | Métadonnées de l'opération |
|
||||
|
||||
### `servicenow_read_record`
|
||||
|
||||
Lire des enregistrements d'une table ServiceNow
|
||||
|
||||
#### Entrée
|
||||
|
||||
| Paramètre | Type | Requis | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | Non | URL de l'instance ServiceNow \(détectée automatiquement depuis OAuth si non fournie\) |
|
||||
| `credential` | string | Non | ID d'identification OAuth ServiceNow |
|
||||
| `tableName` | string | Oui | Nom de la table |
|
||||
| `sysId` | string | Non | sys_id spécifique de l'enregistrement |
|
||||
| `number` | string | Non | Numéro d'enregistrement \(par exemple, INC0010001\) |
|
||||
| `query` | string | Non | Chaîne de requête encodée \(par exemple, "active=true^priority=1"\) |
|
||||
| `limit` | number | Non | Nombre maximum d'enregistrements à retourner |
|
||||
| `fields` | string | Non | Liste de champs séparés par des virgules à retourner |
|
||||
|
||||
#### Sortie
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `records` | array | Tableau des enregistrements ServiceNow |
|
||||
| `metadata` | json | Métadonnées de l'opération |
|
||||
|
||||
### `servicenow_update_record`
|
||||
|
||||
Mettre à jour un enregistrement existant dans une table ServiceNow
|
||||
|
||||
#### Entrée
|
||||
|
||||
| Paramètre | Type | Requis | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | Non | URL de l'instance ServiceNow (détectée automatiquement depuis OAuth si non fournie) |
|
||||
| `credential` | string | Non | ID des identifiants OAuth ServiceNow |
|
||||
| `tableName` | string | Oui | Nom de la table |
|
||||
| `sysId` | string | Oui | sys_id de l'enregistrement à mettre à jour |
|
||||
| `fields` | json | Oui | Champs à mettre à jour (objet JSON) |
|
||||
|
||||
#### Sortie
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `record` | json | Enregistrement ServiceNow mis à jour |
|
||||
| `metadata` | json | Métadonnées de l'opération |
|
||||
|
||||
### `servicenow_delete_record`
|
||||
|
||||
Supprimer un enregistrement d'une table ServiceNow
|
||||
|
||||
#### Entrée
|
||||
|
||||
| Paramètre | Type | Requis | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | Non | URL de l'instance ServiceNow (détectée automatiquement depuis OAuth si non fournie) |
|
||||
| `credential` | string | Non | ID des identifiants OAuth ServiceNow |
|
||||
| `tableName` | string | Oui | Nom de la table |
|
||||
| `sysId` | string | Oui | sys_id de l'enregistrement à supprimer |
|
||||
|
||||
#### Sortie
|
||||
|
||||
| Paramètre | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Indique si la suppression a réussi |
|
||||
| `metadata` | json | Métadonnées de l'opération |
|
||||
|
||||
## Notes
|
||||
|
||||
- Catégorie : `tools`
|
||||
- Type : `servicenow`
|
||||
107
apps/docs/content/docs/ja/tools/servicenow.mdx
Normal file
107
apps/docs/content/docs/ja/tools/servicenow.mdx
Normal file
@@ -0,0 +1,107 @@
|
||||
---
|
||||
title: ServiceNow
|
||||
description: ServiceNowレコードの作成、読み取り、更新、削除、一括インポート
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="servicenow"
|
||||
color="#032D42"
|
||||
/>
|
||||
|
||||
## 使用方法
|
||||
|
||||
ServiceNowをワークフローに統合します。任意のServiceNowテーブル(インシデント、タスク、ユーザーなど)のレコードを作成、読み取り、更新、削除できます。データ移行とETLのための一括インポート操作をサポートします。
|
||||
|
||||
## ツール
|
||||
|
||||
### `servicenow_create_record`
|
||||
|
||||
ServiceNowテーブルに新しいレコードを作成
|
||||
|
||||
#### 入力
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | はい | ServiceNowインスタンスURL(例:https://instance.service-now.com) |
|
||||
| `credential` | string | いいえ | ServiceNow OAuth認証情報ID |
|
||||
| `tableName` | string | はい | テーブル名(例:incident、task、sys_user) |
|
||||
| `fields` | json | はい | レコードに設定するフィールド(JSONオブジェクト) |
|
||||
|
||||
#### 出力
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `record` | json | sys_idおよびその他のフィールドを含む作成されたServiceNowレコード |
|
||||
| `metadata` | json | 操作メタデータ |
|
||||
|
||||
### `servicenow_read_record`
|
||||
|
||||
ServiceNowテーブルからレコードを読み取り
|
||||
|
||||
#### 入力
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | いいえ | ServiceNowインスタンスURL(指定されていない場合はOAuthから自動検出) |
|
||||
| `credential` | string | いいえ | ServiceNow OAuth認証情報ID |
|
||||
| `tableName` | string | はい | テーブル名 |
|
||||
| `sysId` | string | いいえ | 特定のレコードsys_id |
|
||||
| `number` | string | いいえ | レコード番号(例:INC0010001) |
|
||||
| `query` | string | いいえ | エンコードされたクエリ文字列(例:"active=true^priority=1") |
|
||||
| `limit` | number | いいえ | 返す最大レコード数 |
|
||||
| `fields` | string | いいえ | 返すフィールドのカンマ区切りリスト |
|
||||
|
||||
#### 出力
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `records` | array | ServiceNowレコードの配列 |
|
||||
| `metadata` | json | 操作メタデータ |
|
||||
|
||||
### `servicenow_update_record`
|
||||
|
||||
ServiceNowテーブル内の既存のレコードを更新します
|
||||
|
||||
#### 入力
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | いいえ | ServiceNowインスタンスURL(指定されていない場合はOAuthから自動検出) |
|
||||
| `credential` | string | いいえ | ServiceNow OAuth認証情報ID |
|
||||
| `tableName` | string | はい | テーブル名 |
|
||||
| `sysId` | string | はい | 更新するレコードのsys_id |
|
||||
| `fields` | json | はい | 更新するフィールド(JSONオブジェクト) |
|
||||
|
||||
#### 出力
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `record` | json | 更新されたServiceNowレコード |
|
||||
| `metadata` | json | 操作メタデータ |
|
||||
|
||||
### `servicenow_delete_record`
|
||||
|
||||
ServiceNowテーブルからレコードを削除します
|
||||
|
||||
#### 入力
|
||||
|
||||
| パラメータ | 型 | 必須 | 説明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | いいえ | ServiceNowインスタンスURL(指定されていない場合はOAuthから自動検出) |
|
||||
| `credential` | string | いいえ | ServiceNow OAuth認証情報ID |
|
||||
| `tableName` | string | はい | テーブル名 |
|
||||
| `sysId` | string | はい | 削除するレコードのsys_id |
|
||||
|
||||
#### 出力
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | 削除が成功したかどうか |
|
||||
| `metadata` | json | 操作メタデータ |
|
||||
|
||||
## 注記
|
||||
|
||||
- カテゴリー: `tools`
|
||||
- タイプ: `servicenow`
|
||||
107
apps/docs/content/docs/zh/tools/servicenow.mdx
Normal file
107
apps/docs/content/docs/zh/tools/servicenow.mdx
Normal file
@@ -0,0 +1,107 @@
|
||||
---
|
||||
title: ServiceNow
|
||||
description: 创建、读取、更新、删除及批量导入 ServiceNow 记录
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="servicenow"
|
||||
color="#032D42"
|
||||
/>
|
||||
|
||||
## 使用说明
|
||||
|
||||
将 ServiceNow 集成到您的工作流程中。可在任意 ServiceNow 表(如事件、任务、用户等)中创建、读取、更新和删除记录。支持批量导入操作,便于数据迁移和 ETL。
|
||||
|
||||
## 工具
|
||||
|
||||
### `servicenow_create_record`
|
||||
|
||||
在 ServiceNow 表中创建新记录
|
||||
|
||||
#### 输入
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | 是 | ServiceNow 实例 URL(例如:https://instance.service-now.com) |
|
||||
| `credential` | string | 否 | ServiceNow OAuth 凭证 ID |
|
||||
| `tableName` | string | 是 | 表名(例如:incident、task、sys_user) |
|
||||
| `fields` | json | 是 | 要设置在记录上的字段(JSON 对象) |
|
||||
|
||||
#### 输出
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `record` | json | 创建的 ServiceNow 记录,包含 sys_id 及其他字段 |
|
||||
| `metadata` | json | 操作元数据 |
|
||||
|
||||
### `servicenow_read_record`
|
||||
|
||||
从 ServiceNow 表中读取记录
|
||||
|
||||
#### 输入
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | 否 | ServiceNow 实例 URL(如未提供,将通过 OAuth 自动检测) |
|
||||
| `credential` | string | 否 | ServiceNow OAuth 凭证 ID |
|
||||
| `tableName` | string | 是 | 表名 |
|
||||
| `sysId` | string | 否 | 指定记录 sys_id |
|
||||
| `number` | string | 否 | 记录编号(例如:INC0010001) |
|
||||
| `query` | string | 否 | 编码查询字符串(例如:"active=true^priority=1") |
|
||||
| `limit` | number | 否 | 返回的最大记录数 |
|
||||
| `fields` | string | 否 | 要返回的字段列表(以逗号分隔) |
|
||||
|
||||
#### 输出
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `records` | array | ServiceNow 记录数组 |
|
||||
| `metadata` | json | 操作元数据 |
|
||||
|
||||
### `servicenow_update_record`
|
||||
|
||||
更新 ServiceNow 表中的现有记录
|
||||
|
||||
#### 输入
|
||||
|
||||
| 参数 | 类型 | 是否必填 | 描述 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | 否 | ServiceNow 实例 URL(如果未提供,将通过 OAuth 自动检测) |
|
||||
| `credential` | string | 否 | ServiceNow OAuth 凭证 ID |
|
||||
| `tableName` | string | 是 | 表名 |
|
||||
| `sysId` | string | 是 | 要更新的记录 sys_id |
|
||||
| `fields` | json | 是 | 要更新的字段(JSON 对象) |
|
||||
|
||||
#### 输出
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `record` | json | 已更新的 ServiceNow 记录 |
|
||||
| `metadata` | json | 操作元数据 |
|
||||
|
||||
### `servicenow_delete_record`
|
||||
|
||||
从 ServiceNow 表中删除记录
|
||||
|
||||
#### 输入
|
||||
|
||||
| 参数 | 类型 | 是否必填 | 描述 |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | 否 | ServiceNow 实例 URL(如果未提供,将通过 OAuth 自动检测) |
|
||||
| `credential` | string | 否 | ServiceNow OAuth 凭证 ID |
|
||||
| `tableName` | string | 是 | 表名 |
|
||||
| `sysId` | string | 是 | 要删除的记录 sys_id |
|
||||
|
||||
#### 输出
|
||||
|
||||
| 参数 | 类型 | 描述 |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | 删除是否成功 |
|
||||
| `metadata` | json | 操作元数据 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 分类:`tools`
|
||||
- 类型:`servicenow`
|
||||
@@ -49822,3 +49822,37 @@ checksums:
|
||||
content/472: dbc5fceeefb3ab5fa505394becafef4e
|
||||
content/473: b3f310d5ef115bea5a8b75bf25d7ea9a
|
||||
content/474: 27c398e669b297cea076e4ce4cc0c5eb
|
||||
9a28da736b42bf8de55126d4c06b6150:
|
||||
meta/title: 418d5c8a18ad73520b38765741601f32
|
||||
meta/description: 2b5a9723c7a45d2be5001d5d056b7c7b
|
||||
content/0: 1b031fb0c62c46b177aeed5c3d3f8f80
|
||||
content/1: e72670f88454b5b1c955b029de5fa8b5
|
||||
content/2: 821e6394b0a953e2b0842b04ae8f3105
|
||||
content/3: 7fa671d05a60d4f25b4980405c2c7278
|
||||
content/4: 9c8aa3f09c9b2bd50ea4cdff3598ea4e
|
||||
content/5: 263633aee6db9332de806ae50d87de05
|
||||
content/6: 5a7e2171e5f73fec5eae21a50e5de661
|
||||
content/7: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/8: 10d2d4eccb4b8923f048980dc16e43e1
|
||||
content/9: bcadfc362b69078beee0088e5936c98b
|
||||
content/10: d81ef802f80143282cf4e534561a9570
|
||||
content/11: 02233e6212003c1d121424cfd8b86b62
|
||||
content/12: efe2c6dd368708de68a1addbfdb11b0c
|
||||
content/13: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/14: 0f3295854b7de5dbfab1ebd2a130b498
|
||||
content/15: bcadfc362b69078beee0088e5936c98b
|
||||
content/16: 953f353184dc27db1f20156db2a9ad90
|
||||
content/17: 2011e87d0555cd0ab133ef2d35e7a37b
|
||||
content/18: dbf08acb413d845ec419e45b1f986bdb
|
||||
content/19: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/20: 3a8417b390ec7d3d55b1920c721e9006
|
||||
content/21: bcadfc362b69078beee0088e5936c98b
|
||||
content/22: c06a5bb458242baa23d34957034c2fe7
|
||||
content/23: ff043e912417bc29ac7c64520160c07d
|
||||
content/24: 9c2175ab469cb6ff9e62bc8bdcf7621d
|
||||
content/25: 371d0e46b4bd2c23f559b8bc112f6955
|
||||
content/26: 67e6ba04cf67f92e714ed94e7483dec5
|
||||
content/27: bcadfc362b69078beee0088e5936c98b
|
||||
content/28: fd0f38eb3fe5cf95be366a4ff6b4fb90
|
||||
content/29: b3f310d5ef115bea5a8b75bf25d7ea9a
|
||||
content/30: 4a7b2c644e487f3d12b6a6b54f8c6773
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"dev": "next dev --port 3001",
|
||||
"dev": "next dev --port 7322",
|
||||
"build": "fumadocs-mdx && NODE_OPTIONS='--max-old-space-size=8192' next build",
|
||||
"start": "next start",
|
||||
"postinstall": "fumadocs-mdx",
|
||||
|
||||
@@ -109,7 +109,7 @@ export default function Footer({ fullWidth = false }: FooterProps) {
|
||||
{FOOTER_BLOCKS.map((block) => (
|
||||
<Link
|
||||
key={block}
|
||||
href={`https://docs.sim.ai/blocks/${block.toLowerCase().replaceAll(' ', '-')}`}
|
||||
href={`https://docs.sim.ai/blocks/${block.toLowerCase().replace(' ', '-')}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
|
||||
@@ -159,7 +159,7 @@ describe('OAuth Utils', () => {
|
||||
|
||||
const result = await refreshTokenIfNeeded('request-id', mockCredential, 'credential-id')
|
||||
|
||||
expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token')
|
||||
expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token', undefined)
|
||||
expect(mockDb.update).toHaveBeenCalled()
|
||||
expect(mockDb.set).toHaveBeenCalled()
|
||||
expect(result).toEqual({ accessToken: 'new-token', refreshed: true })
|
||||
@@ -239,7 +239,7 @@ describe('OAuth Utils', () => {
|
||||
|
||||
const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id')
|
||||
|
||||
expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token')
|
||||
expect(mockRefreshOAuthToken).toHaveBeenCalledWith('google', 'refresh-token', undefined)
|
||||
expect(mockDb.update).toHaveBeenCalled()
|
||||
expect(mockDb.set).toHaveBeenCalled()
|
||||
expect(token).toBe('new-token')
|
||||
|
||||
@@ -18,6 +18,7 @@ interface AccountInsertData {
|
||||
updatedAt: Date
|
||||
refreshToken?: string
|
||||
idToken?: string
|
||||
accessTokenExpiresAt?: Date
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,6 +104,7 @@ export async function getOAuthToken(userId: string, providerId: string): Promise
|
||||
accessToken: account.accessToken,
|
||||
refreshToken: account.refreshToken,
|
||||
accessTokenExpiresAt: account.accessTokenExpiresAt,
|
||||
idToken: account.idToken,
|
||||
})
|
||||
.from(account)
|
||||
.where(and(eq(account.userId, userId), eq(account.providerId, providerId)))
|
||||
@@ -130,7 +132,14 @@ export async function getOAuthToken(userId: string, providerId: string): Promise
|
||||
|
||||
try {
|
||||
// Use the existing refreshOAuthToken function
|
||||
const refreshResult = await refreshOAuthToken(providerId, credential.refreshToken!)
|
||||
// For ServiceNow, pass the instance URL (stored in idToken) for the token endpoint
|
||||
const instanceUrl =
|
||||
providerId === 'servicenow' ? (credential.idToken ?? undefined) : undefined
|
||||
const refreshResult = await refreshOAuthToken(
|
||||
providerId,
|
||||
credential.refreshToken!,
|
||||
instanceUrl
|
||||
)
|
||||
|
||||
if (!refreshResult) {
|
||||
logger.error(`Failed to refresh token for user ${userId}, provider ${providerId}`, {
|
||||
@@ -213,9 +222,13 @@ export async function refreshAccessTokenIfNeeded(
|
||||
if (shouldRefresh) {
|
||||
logger.info(`[${requestId}] Token expired, attempting to refresh for credential`)
|
||||
try {
|
||||
// For ServiceNow, pass the instance URL (stored in idToken) for the token endpoint
|
||||
const instanceUrl =
|
||||
credential.providerId === 'servicenow' ? (credential.idToken ?? undefined) : undefined
|
||||
const refreshedToken = await refreshOAuthToken(
|
||||
credential.providerId,
|
||||
credential.refreshToken!
|
||||
credential.refreshToken!,
|
||||
instanceUrl
|
||||
)
|
||||
|
||||
if (!refreshedToken) {
|
||||
@@ -287,7 +300,14 @@ export async function refreshTokenIfNeeded(
|
||||
}
|
||||
|
||||
try {
|
||||
const refreshResult = await refreshOAuthToken(credential.providerId, credential.refreshToken!)
|
||||
// For ServiceNow, pass the instance URL (stored in idToken) for the token endpoint
|
||||
const instanceUrl =
|
||||
credential.providerId === 'servicenow' ? (credential.idToken ?? undefined) : undefined
|
||||
const refreshResult = await refreshOAuthToken(
|
||||
credential.providerId,
|
||||
credential.refreshToken!,
|
||||
instanceUrl
|
||||
)
|
||||
|
||||
if (!refreshResult) {
|
||||
logger.error(`[${requestId}] Failed to refresh token for credential`)
|
||||
|
||||
166
apps/sim/app/api/auth/oauth2/callback/servicenow/route.ts
Normal file
166
apps/sim/app/api/auth/oauth2/callback/servicenow/route.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('ServiceNowCallback')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=unauthorized`)
|
||||
}
|
||||
|
||||
const { searchParams } = request.nextUrl
|
||||
const code = searchParams.get('code')
|
||||
const state = searchParams.get('state')
|
||||
const error = searchParams.get('error')
|
||||
const errorDescription = searchParams.get('error_description')
|
||||
|
||||
// Handle OAuth errors from ServiceNow
|
||||
if (error) {
|
||||
logger.error('ServiceNow OAuth error:', { error, errorDescription })
|
||||
return NextResponse.redirect(
|
||||
`${baseUrl}/workspace?error=servicenow_auth_error&message=${encodeURIComponent(errorDescription || error)}`
|
||||
)
|
||||
}
|
||||
|
||||
const storedState = request.cookies.get('servicenow_oauth_state')?.value
|
||||
const storedInstanceUrl = request.cookies.get('servicenow_instance_url')?.value
|
||||
|
||||
const clientId = env.SERVICENOW_CLIENT_ID
|
||||
const clientSecret = env.SERVICENOW_CLIENT_SECRET
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
logger.error('ServiceNow credentials not configured')
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=servicenow_config_error`)
|
||||
}
|
||||
|
||||
// Validate state parameter
|
||||
if (!state || state !== storedState) {
|
||||
logger.error('State mismatch in ServiceNow OAuth callback')
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=servicenow_state_mismatch`)
|
||||
}
|
||||
|
||||
// Validate authorization code
|
||||
if (!code) {
|
||||
logger.error('No code received from ServiceNow')
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=servicenow_no_code`)
|
||||
}
|
||||
|
||||
// Validate instance URL
|
||||
if (!storedInstanceUrl) {
|
||||
logger.error('No instance URL stored')
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=servicenow_no_instance`)
|
||||
}
|
||||
|
||||
const redirectUri = `${baseUrl}/api/auth/oauth2/callback/servicenow`
|
||||
|
||||
// Exchange authorization code for access token
|
||||
const tokenResponse = await fetch(`${storedInstanceUrl}/oauth_token.do`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: code,
|
||||
redirect_uri: redirectUri,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
}).toString(),
|
||||
})
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorText = await tokenResponse.text()
|
||||
logger.error('Failed to exchange code for token:', {
|
||||
status: tokenResponse.status,
|
||||
body: errorText,
|
||||
})
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=servicenow_token_error`)
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json()
|
||||
const accessToken = tokenData.access_token
|
||||
const refreshToken = tokenData.refresh_token
|
||||
const expiresIn = tokenData.expires_in
|
||||
// ServiceNow always grants 'useraccount' scope but returns empty string
|
||||
const scope = tokenData.scope || 'useraccount'
|
||||
|
||||
logger.info('ServiceNow token exchange successful:', {
|
||||
hasAccessToken: !!accessToken,
|
||||
hasRefreshToken: !!refreshToken,
|
||||
expiresIn,
|
||||
})
|
||||
|
||||
if (!accessToken) {
|
||||
logger.error('No access token in response')
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=servicenow_no_token`)
|
||||
}
|
||||
|
||||
// Redirect to store endpoint with token data in cookies
|
||||
const storeUrl = new URL(`${baseUrl}/api/auth/oauth2/servicenow/store`)
|
||||
|
||||
const response = NextResponse.redirect(storeUrl)
|
||||
|
||||
// Store token data in secure cookies for the store endpoint
|
||||
response.cookies.set('servicenow_pending_token', accessToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60, // 1 minute
|
||||
path: '/',
|
||||
})
|
||||
|
||||
if (refreshToken) {
|
||||
response.cookies.set('servicenow_pending_refresh_token', refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60,
|
||||
path: '/',
|
||||
})
|
||||
}
|
||||
|
||||
response.cookies.set('servicenow_pending_instance', storedInstanceUrl, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60,
|
||||
path: '/',
|
||||
})
|
||||
|
||||
response.cookies.set('servicenow_pending_scope', scope || '', {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60,
|
||||
path: '/',
|
||||
})
|
||||
|
||||
if (expiresIn) {
|
||||
response.cookies.set('servicenow_pending_expires_in', expiresIn.toString(), {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60,
|
||||
path: '/',
|
||||
})
|
||||
}
|
||||
|
||||
// Clean up OAuth state cookies
|
||||
response.cookies.delete('servicenow_oauth_state')
|
||||
response.cookies.delete('servicenow_instance_url')
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
logger.error('Error in ServiceNow OAuth callback:', error)
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=servicenow_callback_error`)
|
||||
}
|
||||
}
|
||||
142
apps/sim/app/api/auth/oauth2/servicenow/store/route.ts
Normal file
142
apps/sim/app/api/auth/oauth2/servicenow/store/route.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { db } from '@sim/db'
|
||||
import { account } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { safeAccountInsert } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
const logger = createLogger('ServiceNowStore')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn('Unauthorized attempt to store ServiceNow token')
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=unauthorized`)
|
||||
}
|
||||
|
||||
// Retrieve token data from cookies
|
||||
const accessToken = request.cookies.get('servicenow_pending_token')?.value
|
||||
const refreshToken = request.cookies.get('servicenow_pending_refresh_token')?.value
|
||||
const instanceUrl = request.cookies.get('servicenow_pending_instance')?.value
|
||||
const scope = request.cookies.get('servicenow_pending_scope')?.value
|
||||
const expiresInStr = request.cookies.get('servicenow_pending_expires_in')?.value
|
||||
|
||||
if (!accessToken || !instanceUrl) {
|
||||
logger.error('Missing token or instance URL in cookies')
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=servicenow_missing_data`)
|
||||
}
|
||||
|
||||
// Validate the token by fetching user info from ServiceNow
|
||||
const userResponse = await fetch(
|
||||
`${instanceUrl}/api/now/table/sys_user?sysparm_query=user_name=${encodeURIComponent('javascript:gs.getUserName()')}&sysparm_limit=1`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Alternative: Use the instance info endpoint instead
|
||||
let accountIdentifier = instanceUrl
|
||||
let userInfo: Record<string, unknown> | null = null
|
||||
|
||||
// Try to get current user info
|
||||
try {
|
||||
const whoamiResponse = await fetch(`${instanceUrl}/api/now/ui/user/current_user`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (whoamiResponse.ok) {
|
||||
const whoamiData = await whoamiResponse.json()
|
||||
userInfo = whoamiData.result
|
||||
if (userInfo?.user_sys_id) {
|
||||
accountIdentifier = userInfo.user_sys_id as string
|
||||
} else if (userInfo?.user_name) {
|
||||
accountIdentifier = userInfo.user_name as string
|
||||
}
|
||||
logger.info('Retrieved ServiceNow user info', { accountIdentifier })
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('Could not retrieve ServiceNow user info, using instance URL as identifier')
|
||||
}
|
||||
|
||||
// Calculate expiration time
|
||||
const now = new Date()
|
||||
const expiresIn = expiresInStr ? Number.parseInt(expiresInStr, 10) : 3600 // Default to 1 hour
|
||||
const accessTokenExpiresAt = new Date(now.getTime() + expiresIn * 1000)
|
||||
|
||||
// Check for existing ServiceNow account for this user
|
||||
const existing = await db.query.account.findFirst({
|
||||
where: and(eq(account.userId, session.user.id), eq(account.providerId, 'servicenow')),
|
||||
})
|
||||
|
||||
// ServiceNow always grants 'useraccount' scope but returns empty string
|
||||
const effectiveScope = scope?.trim() ? scope : 'useraccount'
|
||||
|
||||
const accountData = {
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken || null,
|
||||
accountId: accountIdentifier,
|
||||
scope: effectiveScope,
|
||||
updatedAt: now,
|
||||
accessTokenExpiresAt: accessTokenExpiresAt,
|
||||
idToken: instanceUrl, // Store instance URL in idToken for API calls
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
await db.update(account).set(accountData).where(eq(account.id, existing.id))
|
||||
logger.info('Updated existing ServiceNow account', { accountId: existing.id })
|
||||
} else {
|
||||
await safeAccountInsert(
|
||||
{
|
||||
id: `servicenow_${session.user.id}_${Date.now()}`,
|
||||
userId: session.user.id,
|
||||
providerId: 'servicenow',
|
||||
accountId: accountData.accountId,
|
||||
accessToken: accountData.accessToken,
|
||||
refreshToken: accountData.refreshToken || undefined,
|
||||
accessTokenExpiresAt: accountData.accessTokenExpiresAt,
|
||||
scope: accountData.scope,
|
||||
idToken: accountData.idToken,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
{ provider: 'ServiceNow', identifier: instanceUrl }
|
||||
)
|
||||
logger.info('Created new ServiceNow account')
|
||||
}
|
||||
|
||||
// Get return URL from cookie
|
||||
const returnUrl = request.cookies.get('servicenow_return_url')?.value
|
||||
|
||||
const redirectUrl = returnUrl || `${baseUrl}/workspace`
|
||||
const finalUrl = new URL(redirectUrl)
|
||||
finalUrl.searchParams.set('servicenow_connected', 'true')
|
||||
|
||||
const response = NextResponse.redirect(finalUrl.toString())
|
||||
|
||||
// Clean up all ServiceNow cookies
|
||||
response.cookies.delete('servicenow_pending_token')
|
||||
response.cookies.delete('servicenow_pending_refresh_token')
|
||||
response.cookies.delete('servicenow_pending_instance')
|
||||
response.cookies.delete('servicenow_pending_scope')
|
||||
response.cookies.delete('servicenow_pending_expires_in')
|
||||
response.cookies.delete('servicenow_return_url')
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
logger.error('Error storing ServiceNow token:', error)
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=servicenow_store_error`)
|
||||
}
|
||||
}
|
||||
264
apps/sim/app/api/auth/servicenow/authorize/route.ts
Normal file
264
apps/sim/app/api/auth/servicenow/authorize/route.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('ServiceNowAuthorize')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* ServiceNow OAuth scopes
|
||||
* useraccount - Default scope for user account access
|
||||
* Note: ServiceNow always returns 'useraccount' in OAuth responses regardless of requested scopes.
|
||||
* Table API permissions are configured at the OAuth application level in ServiceNow.
|
||||
*/
|
||||
const SERVICENOW_SCOPES = 'useraccount'
|
||||
|
||||
/**
|
||||
* Validates a ServiceNow instance URL format
|
||||
*/
|
||||
function isValidInstanceUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
return (
|
||||
parsed.protocol === 'https:' &&
|
||||
(parsed.hostname.endsWith('.service-now.com') || parsed.hostname.endsWith('.servicenow.com'))
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const clientId = env.SERVICENOW_CLIENT_ID
|
||||
|
||||
if (!clientId) {
|
||||
logger.error('SERVICENOW_CLIENT_ID not configured')
|
||||
return NextResponse.json({ error: 'ServiceNow client ID not configured' }, { status: 500 })
|
||||
}
|
||||
|
||||
const instanceUrl = request.nextUrl.searchParams.get('instanceUrl')
|
||||
const returnUrl = request.nextUrl.searchParams.get('returnUrl')
|
||||
|
||||
if (!instanceUrl) {
|
||||
const returnUrlParam = returnUrl ? encodeURIComponent(returnUrl) : ''
|
||||
return new NextResponse(
|
||||
`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Connect ServiceNow Instance</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #81B5A1 0%, #5A8A75 100%);
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
|
||||
text-align: center;
|
||||
max-width: 450px;
|
||||
width: 90%;
|
||||
}
|
||||
h2 {
|
||||
color: #111827;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
p {
|
||||
color: #6b7280;
|
||||
margin: 0 0 1.5rem 0;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: #81B5A1;
|
||||
box-shadow: 0 0 0 3px rgba(129, 181, 161, 0.2);
|
||||
}
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: #81B5A1;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
button:hover {
|
||||
background: #6A9A87;
|
||||
}
|
||||
.help {
|
||||
font-size: 0.875rem;
|
||||
color: #9ca3af;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.error {
|
||||
color: #dc2626;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h2>Connect Your ServiceNow Instance</h2>
|
||||
<p>Enter your ServiceNow instance URL to continue</p>
|
||||
<div id="error" class="error"></div>
|
||||
<form onsubmit="handleSubmit(event)">
|
||||
<input
|
||||
type="text"
|
||||
id="instanceUrl"
|
||||
placeholder="https://mycompany.service-now.com"
|
||||
required
|
||||
/>
|
||||
<button type="submit">Connect Instance</button>
|
||||
</form>
|
||||
<p class="help">Your instance URL looks like: https://yourcompany.service-now.com</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const returnUrl = '${returnUrlParam}';
|
||||
function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
const errorEl = document.getElementById('error');
|
||||
let instanceUrl = document.getElementById('instanceUrl').value.trim();
|
||||
|
||||
// Ensure https:// prefix
|
||||
if (!instanceUrl.startsWith('https://') && !instanceUrl.startsWith('http://')) {
|
||||
instanceUrl = 'https://' + instanceUrl;
|
||||
}
|
||||
|
||||
// Validate the URL format
|
||||
try {
|
||||
const parsed = new URL(instanceUrl);
|
||||
if (!parsed.hostname.endsWith('.service-now.com') && !parsed.hostname.endsWith('.servicenow.com')) {
|
||||
errorEl.textContent = 'Please enter a valid ServiceNow instance URL (e.g., https://yourcompany.service-now.com)';
|
||||
errorEl.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
// Clean the URL (remove trailing slashes, paths)
|
||||
instanceUrl = parsed.origin;
|
||||
} catch {
|
||||
errorEl.textContent = 'Please enter a valid URL';
|
||||
errorEl.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
let url = window.location.pathname + '?instanceUrl=' + encodeURIComponent(instanceUrl);
|
||||
if (returnUrl) {
|
||||
url += '&returnUrl=' + returnUrl;
|
||||
}
|
||||
window.location.href = url;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Validate instance URL
|
||||
if (!isValidInstanceUrl(instanceUrl)) {
|
||||
logger.error('Invalid ServiceNow instance URL:', { instanceUrl })
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Invalid ServiceNow instance URL. Must be a valid .service-now.com or .servicenow.com domain.',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Clean the instance URL
|
||||
const parsedUrl = new URL(instanceUrl)
|
||||
const cleanInstanceUrl = parsedUrl.origin
|
||||
|
||||
const baseUrl = getBaseUrl()
|
||||
const redirectUri = `${baseUrl}/api/auth/oauth2/callback/servicenow`
|
||||
|
||||
const state = crypto.randomUUID()
|
||||
|
||||
// ServiceNow OAuth authorization URL
|
||||
const oauthUrl =
|
||||
`${cleanInstanceUrl}/oauth_auth.do?` +
|
||||
new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
state: state,
|
||||
scope: SERVICENOW_SCOPES,
|
||||
}).toString()
|
||||
|
||||
logger.info('Initiating ServiceNow OAuth:', {
|
||||
instanceUrl: cleanInstanceUrl,
|
||||
requestedScopes: SERVICENOW_SCOPES,
|
||||
redirectUri,
|
||||
returnUrl: returnUrl || 'not specified',
|
||||
})
|
||||
|
||||
const response = NextResponse.redirect(oauthUrl)
|
||||
|
||||
// Store state and instance URL in cookies for validation in callback
|
||||
response.cookies.set('servicenow_oauth_state', state, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 10, // 10 minutes
|
||||
path: '/',
|
||||
})
|
||||
|
||||
response.cookies.set('servicenow_instance_url', cleanInstanceUrl, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 10,
|
||||
path: '/',
|
||||
})
|
||||
|
||||
if (returnUrl) {
|
||||
response.cookies.set('servicenow_return_url', returnUrl, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 10,
|
||||
path: '/',
|
||||
})
|
||||
}
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
logger.error('Error initiating ServiceNow authorization:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -303,6 +303,14 @@ export async function POST(req: NextRequest) {
|
||||
apiVersion: 'preview',
|
||||
endpoint: env.AZURE_OPENAI_ENDPOINT,
|
||||
}
|
||||
} else if (providerEnv === 'vertex') {
|
||||
providerConfig = {
|
||||
provider: 'vertex',
|
||||
model: modelToUse,
|
||||
apiKey: env.COPILOT_API_KEY,
|
||||
vertexProject: env.VERTEX_PROJECT,
|
||||
vertexLocation: env.VERTEX_LOCATION,
|
||||
}
|
||||
} else {
|
||||
providerConfig = {
|
||||
provider: providerEnv,
|
||||
|
||||
@@ -66,6 +66,14 @@ export async function POST(req: NextRequest) {
|
||||
apiVersion: env.AZURE_OPENAI_API_VERSION,
|
||||
endpoint: env.AZURE_OPENAI_ENDPOINT,
|
||||
}
|
||||
} else if (providerEnv === 'vertex') {
|
||||
providerConfig = {
|
||||
provider: 'vertex',
|
||||
model: modelToUse,
|
||||
apiKey: env.COPILOT_API_KEY,
|
||||
vertexProject: env.VERTEX_PROJECT,
|
||||
vertexLocation: env.VERTEX_LOCATION,
|
||||
}
|
||||
} else {
|
||||
providerConfig = {
|
||||
provider: providerEnv,
|
||||
|
||||
@@ -6,7 +6,22 @@ import {
|
||||
workflowDeploymentVersion,
|
||||
workflowExecutionLogs,
|
||||
} from '@sim/db/schema'
|
||||
import { and, desc, eq, gte, inArray, isNotNull, isNull, lte, or, type SQL, sql } from 'drizzle-orm'
|
||||
import {
|
||||
and,
|
||||
desc,
|
||||
eq,
|
||||
gt,
|
||||
gte,
|
||||
inArray,
|
||||
isNotNull,
|
||||
isNull,
|
||||
lt,
|
||||
lte,
|
||||
ne,
|
||||
or,
|
||||
type SQL,
|
||||
sql,
|
||||
} from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
@@ -22,14 +37,19 @@ const QueryParamsSchema = z.object({
|
||||
limit: z.coerce.number().optional().default(100),
|
||||
offset: z.coerce.number().optional().default(0),
|
||||
level: z.string().optional(),
|
||||
workflowIds: z.string().optional(), // Comma-separated list of workflow IDs
|
||||
folderIds: z.string().optional(), // Comma-separated list of folder IDs
|
||||
triggers: z.string().optional(), // Comma-separated list of trigger types
|
||||
workflowIds: z.string().optional(),
|
||||
folderIds: z.string().optional(),
|
||||
triggers: z.string().optional(),
|
||||
startDate: z.string().optional(),
|
||||
endDate: z.string().optional(),
|
||||
search: z.string().optional(),
|
||||
workflowName: z.string().optional(),
|
||||
folderName: z.string().optional(),
|
||||
executionId: z.string().optional(),
|
||||
costOperator: z.enum(['=', '>', '<', '>=', '<=', '!=']).optional(),
|
||||
costValue: z.coerce.number().optional(),
|
||||
durationOperator: z.enum(['=', '>', '<', '>=', '<=', '!=']).optional(),
|
||||
durationValue: z.coerce.number().optional(),
|
||||
workspaceId: z.string(),
|
||||
})
|
||||
|
||||
@@ -49,7 +69,6 @@ export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const params = QueryParamsSchema.parse(Object.fromEntries(searchParams.entries()))
|
||||
|
||||
// Conditionally select columns based on detail level to optimize performance
|
||||
const selectColumns =
|
||||
params.details === 'full'
|
||||
? {
|
||||
@@ -63,9 +82,9 @@ export async function GET(request: NextRequest) {
|
||||
startedAt: workflowExecutionLogs.startedAt,
|
||||
endedAt: workflowExecutionLogs.endedAt,
|
||||
totalDurationMs: workflowExecutionLogs.totalDurationMs,
|
||||
executionData: workflowExecutionLogs.executionData, // Large field - only in full mode
|
||||
executionData: workflowExecutionLogs.executionData,
|
||||
cost: workflowExecutionLogs.cost,
|
||||
files: workflowExecutionLogs.files, // Large field - only in full mode
|
||||
files: workflowExecutionLogs.files,
|
||||
createdAt: workflowExecutionLogs.createdAt,
|
||||
workflowName: workflow.name,
|
||||
workflowDescription: workflow.description,
|
||||
@@ -82,7 +101,6 @@ export async function GET(request: NextRequest) {
|
||||
deploymentVersionName: workflowDeploymentVersion.name,
|
||||
}
|
||||
: {
|
||||
// Basic mode - exclude large fields for better performance
|
||||
id: workflowExecutionLogs.id,
|
||||
workflowId: workflowExecutionLogs.workflowId,
|
||||
executionId: workflowExecutionLogs.executionId,
|
||||
@@ -93,9 +111,9 @@ export async function GET(request: NextRequest) {
|
||||
startedAt: workflowExecutionLogs.startedAt,
|
||||
endedAt: workflowExecutionLogs.endedAt,
|
||||
totalDurationMs: workflowExecutionLogs.totalDurationMs,
|
||||
executionData: sql<null>`NULL`, // Exclude large execution data in basic mode
|
||||
executionData: sql<null>`NULL`,
|
||||
cost: workflowExecutionLogs.cost,
|
||||
files: sql<null>`NULL`, // Exclude files in basic mode
|
||||
files: sql<null>`NULL`,
|
||||
createdAt: workflowExecutionLogs.createdAt,
|
||||
workflowName: workflow.name,
|
||||
workflowDescription: workflow.description,
|
||||
@@ -109,7 +127,7 @@ export async function GET(request: NextRequest) {
|
||||
pausedTotalPauseCount: pausedExecutions.totalPauseCount,
|
||||
pausedResumedCount: pausedExecutions.resumedCount,
|
||||
deploymentVersion: workflowDeploymentVersion.version,
|
||||
deploymentVersionName: sql<null>`NULL`, // Only needed in full mode for details panel
|
||||
deploymentVersionName: sql<null>`NULL`,
|
||||
}
|
||||
|
||||
const baseQuery = db
|
||||
@@ -139,34 +157,28 @@ export async function GET(request: NextRequest) {
|
||||
)
|
||||
)
|
||||
|
||||
// Build additional conditions for the query
|
||||
let conditions: SQL | undefined
|
||||
|
||||
// Filter by level with support for derived statuses (running, pending)
|
||||
if (params.level && params.level !== 'all') {
|
||||
const levels = params.level.split(',').filter(Boolean)
|
||||
const levelConditions: SQL[] = []
|
||||
|
||||
for (const level of levels) {
|
||||
if (level === 'error') {
|
||||
// Direct database field
|
||||
levelConditions.push(eq(workflowExecutionLogs.level, 'error'))
|
||||
} else if (level === 'info') {
|
||||
// Completed info logs only (not running, not pending)
|
||||
const condition = and(
|
||||
eq(workflowExecutionLogs.level, 'info'),
|
||||
isNotNull(workflowExecutionLogs.endedAt)
|
||||
)
|
||||
if (condition) levelConditions.push(condition)
|
||||
} else if (level === 'running') {
|
||||
// Running logs: info level with no endedAt
|
||||
const condition = and(
|
||||
eq(workflowExecutionLogs.level, 'info'),
|
||||
isNull(workflowExecutionLogs.endedAt)
|
||||
)
|
||||
if (condition) levelConditions.push(condition)
|
||||
} else if (level === 'pending') {
|
||||
// Pending logs: info level with pause status indicators
|
||||
const condition = and(
|
||||
eq(workflowExecutionLogs.level, 'info'),
|
||||
or(
|
||||
@@ -189,7 +201,6 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by specific workflow IDs
|
||||
if (params.workflowIds) {
|
||||
const workflowIds = params.workflowIds.split(',').filter(Boolean)
|
||||
if (workflowIds.length > 0) {
|
||||
@@ -197,7 +208,6 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by folder IDs
|
||||
if (params.folderIds) {
|
||||
const folderIds = params.folderIds.split(',').filter(Boolean)
|
||||
if (folderIds.length > 0) {
|
||||
@@ -205,7 +215,6 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by triggers
|
||||
if (params.triggers) {
|
||||
const triggers = params.triggers.split(',').filter(Boolean)
|
||||
if (triggers.length > 0 && !triggers.includes('all')) {
|
||||
@@ -213,7 +222,6 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by date range
|
||||
if (params.startDate) {
|
||||
conditions = and(
|
||||
conditions,
|
||||
@@ -224,33 +232,79 @@ export async function GET(request: NextRequest) {
|
||||
conditions = and(conditions, lte(workflowExecutionLogs.startedAt, new Date(params.endDate)))
|
||||
}
|
||||
|
||||
// Filter by search query
|
||||
if (params.search) {
|
||||
const searchTerm = `%${params.search}%`
|
||||
// With message removed, restrict search to executionId only
|
||||
conditions = and(conditions, sql`${workflowExecutionLogs.executionId} ILIKE ${searchTerm}`)
|
||||
}
|
||||
|
||||
// Filter by workflow name (from advanced search input)
|
||||
if (params.workflowName) {
|
||||
const nameTerm = `%${params.workflowName}%`
|
||||
conditions = and(conditions, sql`${workflow.name} ILIKE ${nameTerm}`)
|
||||
}
|
||||
|
||||
// Filter by folder name (best-effort text match when present on workflows)
|
||||
if (params.folderName) {
|
||||
const folderTerm = `%${params.folderName}%`
|
||||
conditions = and(conditions, sql`${workflow.name} ILIKE ${folderTerm}`)
|
||||
}
|
||||
|
||||
// Execute the query using the optimized join
|
||||
if (params.executionId) {
|
||||
conditions = and(conditions, eq(workflowExecutionLogs.executionId, params.executionId))
|
||||
}
|
||||
|
||||
if (params.costOperator && params.costValue !== undefined) {
|
||||
const costField = sql`(${workflowExecutionLogs.cost}->>'total')::numeric`
|
||||
switch (params.costOperator) {
|
||||
case '=':
|
||||
conditions = and(conditions, sql`${costField} = ${params.costValue}`)
|
||||
break
|
||||
case '>':
|
||||
conditions = and(conditions, sql`${costField} > ${params.costValue}`)
|
||||
break
|
||||
case '<':
|
||||
conditions = and(conditions, sql`${costField} < ${params.costValue}`)
|
||||
break
|
||||
case '>=':
|
||||
conditions = and(conditions, sql`${costField} >= ${params.costValue}`)
|
||||
break
|
||||
case '<=':
|
||||
conditions = and(conditions, sql`${costField} <= ${params.costValue}`)
|
||||
break
|
||||
case '!=':
|
||||
conditions = and(conditions, sql`${costField} != ${params.costValue}`)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (params.durationOperator && params.durationValue !== undefined) {
|
||||
const durationField = workflowExecutionLogs.totalDurationMs
|
||||
switch (params.durationOperator) {
|
||||
case '=':
|
||||
conditions = and(conditions, eq(durationField, params.durationValue))
|
||||
break
|
||||
case '>':
|
||||
conditions = and(conditions, gt(durationField, params.durationValue))
|
||||
break
|
||||
case '<':
|
||||
conditions = and(conditions, lt(durationField, params.durationValue))
|
||||
break
|
||||
case '>=':
|
||||
conditions = and(conditions, gte(durationField, params.durationValue))
|
||||
break
|
||||
case '<=':
|
||||
conditions = and(conditions, lte(durationField, params.durationValue))
|
||||
break
|
||||
case '!=':
|
||||
conditions = and(conditions, ne(durationField, params.durationValue))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const logs = await baseQuery
|
||||
.where(conditions)
|
||||
.orderBy(desc(workflowExecutionLogs.startedAt))
|
||||
.limit(params.limit)
|
||||
.offset(params.offset)
|
||||
|
||||
// Get total count for pagination using the same join structure
|
||||
const countQuery = db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(workflowExecutionLogs)
|
||||
@@ -279,13 +333,10 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const count = countResult[0]?.count || 0
|
||||
|
||||
// Block executions are now extracted from trace spans instead of separate table
|
||||
const blockExecutionsByExecution: Record<string, any[]> = {}
|
||||
|
||||
// Create clean trace spans from block executions
|
||||
const createTraceSpans = (blockExecutions: any[]) => {
|
||||
return blockExecutions.map((block, index) => {
|
||||
// For error blocks, include error information in the output
|
||||
let output = block.outputData
|
||||
if (block.status === 'error' && block.errorMessage) {
|
||||
output = {
|
||||
@@ -314,7 +365,6 @@ export async function GET(request: NextRequest) {
|
||||
})
|
||||
}
|
||||
|
||||
// Extract cost information from block executions
|
||||
const extractCostSummary = (blockExecutions: any[]) => {
|
||||
let totalCost = 0
|
||||
let totalInputCost = 0
|
||||
@@ -333,7 +383,6 @@ export async function GET(request: NextRequest) {
|
||||
totalPromptTokens += block.cost.tokens?.prompt || 0
|
||||
totalCompletionTokens += block.cost.tokens?.completion || 0
|
||||
|
||||
// Track per-model costs
|
||||
if (block.cost.model) {
|
||||
if (!models.has(block.cost.model)) {
|
||||
models.set(block.cost.model, {
|
||||
@@ -363,34 +412,29 @@ export async function GET(request: NextRequest) {
|
||||
prompt: totalPromptTokens,
|
||||
completion: totalCompletionTokens,
|
||||
},
|
||||
models: Object.fromEntries(models), // Convert Map to object for JSON serialization
|
||||
models: Object.fromEntries(models),
|
||||
}
|
||||
}
|
||||
|
||||
// Transform to clean log format with workflow data included
|
||||
const enhancedLogs = logs.map((log) => {
|
||||
const blockExecutions = blockExecutionsByExecution[log.executionId] || []
|
||||
|
||||
// Only process trace spans and detailed cost in full mode
|
||||
let traceSpans = []
|
||||
let finalOutput: any
|
||||
let costSummary = (log.cost as any) || { total: 0 }
|
||||
|
||||
if (params.details === 'full' && log.executionData) {
|
||||
// Use stored trace spans if available, otherwise create from block executions
|
||||
const storedTraceSpans = (log.executionData as any)?.traceSpans
|
||||
traceSpans =
|
||||
storedTraceSpans && Array.isArray(storedTraceSpans) && storedTraceSpans.length > 0
|
||||
? storedTraceSpans
|
||||
: createTraceSpans(blockExecutions)
|
||||
|
||||
// Prefer stored cost JSON; otherwise synthesize from blocks
|
||||
costSummary =
|
||||
log.cost && Object.keys(log.cost as any).length > 0
|
||||
? (log.cost as any)
|
||||
: extractCostSummary(blockExecutions)
|
||||
|
||||
// Include finalOutput if present on executionData
|
||||
try {
|
||||
const fo = (log.executionData as any)?.finalOutput
|
||||
if (fo !== undefined) finalOutput = fo
|
||||
|
||||
129
apps/sim/app/api/mcp/discover/route.ts
Normal file
129
apps/sim/app/api/mcp/discover/route.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { db } from '@sim/db'
|
||||
import { permissions, workflowMcpServer, workspace } from '@sim/db/schema'
|
||||
import { and, eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('McpDiscoverAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* GET - Discover all published MCP servers available to the authenticated user
|
||||
*
|
||||
* This endpoint allows external MCP clients to discover available servers
|
||||
* using just their API key, without needing to know workspace IDs.
|
||||
*
|
||||
* Authentication: API Key (X-API-Key header) or Session
|
||||
*
|
||||
* Returns all published MCP servers from workspaces the user has access to.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Authenticate the request
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Authentication required. Provide X-API-Key header with your Sim API key.',
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const userId = auth.userId
|
||||
|
||||
// Get all workspaces the user has access to via permissions table
|
||||
const userWorkspacePermissions = await db
|
||||
.select({ entityId: permissions.entityId })
|
||||
.from(permissions)
|
||||
.where(and(eq(permissions.userId, userId), eq(permissions.entityType, 'workspace')))
|
||||
|
||||
const workspaceIds = userWorkspacePermissions.map((w) => w.entityId)
|
||||
|
||||
if (workspaceIds.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
servers: [],
|
||||
message: 'No workspaces found for this user',
|
||||
})
|
||||
}
|
||||
|
||||
// Get all published MCP servers from user's workspaces with tool count
|
||||
const servers = await db
|
||||
.select({
|
||||
id: workflowMcpServer.id,
|
||||
name: workflowMcpServer.name,
|
||||
description: workflowMcpServer.description,
|
||||
workspaceId: workflowMcpServer.workspaceId,
|
||||
workspaceName: workspace.name,
|
||||
isPublished: workflowMcpServer.isPublished,
|
||||
publishedAt: workflowMcpServer.publishedAt,
|
||||
toolCount: sql<number>`(
|
||||
SELECT COUNT(*)::int
|
||||
FROM "workflow_mcp_tool"
|
||||
WHERE "workflow_mcp_tool"."server_id" = "workflow_mcp_server"."id"
|
||||
)`.as('tool_count'),
|
||||
})
|
||||
.from(workflowMcpServer)
|
||||
.leftJoin(workspace, eq(workflowMcpServer.workspaceId, workspace.id))
|
||||
.where(
|
||||
and(
|
||||
eq(workflowMcpServer.isPublished, true),
|
||||
sql`${workflowMcpServer.workspaceId} IN ${workspaceIds}`
|
||||
)
|
||||
)
|
||||
.orderBy(workflowMcpServer.name)
|
||||
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
// Format response with connection URLs
|
||||
const formattedServers = servers.map((server) => ({
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
description: server.description,
|
||||
workspace: {
|
||||
id: server.workspaceId,
|
||||
name: server.workspaceName,
|
||||
},
|
||||
toolCount: server.toolCount || 0,
|
||||
publishedAt: server.publishedAt,
|
||||
urls: {
|
||||
http: `${baseUrl}/api/mcp/serve/${server.id}`,
|
||||
sse: `${baseUrl}/api/mcp/serve/${server.id}/sse`,
|
||||
},
|
||||
}))
|
||||
|
||||
logger.info(`User ${userId} discovered ${formattedServers.length} MCP servers`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
servers: formattedServers,
|
||||
authentication: {
|
||||
method: 'API Key',
|
||||
header: 'X-API-Key',
|
||||
description: 'Include your Sim API key in the X-API-Key header for all MCP requests',
|
||||
},
|
||||
usage: {
|
||||
listTools: {
|
||||
method: 'POST',
|
||||
body: '{"jsonrpc":"2.0","id":1,"method":"tools/list"}',
|
||||
},
|
||||
callTool: {
|
||||
method: 'POST',
|
||||
body: '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"TOOL_NAME","arguments":{}}}',
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error discovering MCP servers:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to discover MCP servers' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
360
apps/sim/app/api/mcp/serve/[serverId]/route.ts
Normal file
360
apps/sim/app/api/mcp/serve/[serverId]/route.ts
Normal file
@@ -0,0 +1,360 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('WorkflowMcpServeAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface RouteParams {
|
||||
serverId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP JSON-RPC Request
|
||||
*/
|
||||
interface JsonRpcRequest {
|
||||
jsonrpc: '2.0'
|
||||
id: string | number
|
||||
method: string
|
||||
params?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP JSON-RPC Response
|
||||
*/
|
||||
interface JsonRpcResponse {
|
||||
jsonrpc: '2.0'
|
||||
id: string | number
|
||||
result?: unknown
|
||||
error?: {
|
||||
code: number
|
||||
message: string
|
||||
data?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create JSON-RPC success response
|
||||
*/
|
||||
function createJsonRpcResponse(id: string | number, result: unknown): JsonRpcResponse {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create JSON-RPC error response
|
||||
*/
|
||||
function createJsonRpcError(
|
||||
id: string | number,
|
||||
code: number,
|
||||
message: string,
|
||||
data?: unknown
|
||||
): JsonRpcResponse {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: { code, message, data },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the server exists and is published
|
||||
*/
|
||||
async function validateServer(serverId: string) {
|
||||
const [server] = await db
|
||||
.select({
|
||||
id: workflowMcpServer.id,
|
||||
name: workflowMcpServer.name,
|
||||
workspaceId: workflowMcpServer.workspaceId,
|
||||
isPublished: workflowMcpServer.isPublished,
|
||||
})
|
||||
.from(workflowMcpServer)
|
||||
.where(eq(workflowMcpServer.id, serverId))
|
||||
.limit(1)
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
/**
|
||||
* GET - Server info and capabilities (MCP initialize)
|
||||
*/
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
|
||||
const { serverId } = await params
|
||||
|
||||
try {
|
||||
const server = await validateServer(serverId)
|
||||
|
||||
if (!server) {
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (!server.isPublished) {
|
||||
return NextResponse.json({ error: 'Server is not published' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Return server capabilities
|
||||
return NextResponse.json({
|
||||
name: server.name,
|
||||
version: '1.0.0',
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
instructions: `This MCP server exposes workflow tools from Sim Studio. Each tool executes a deployed workflow.`,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error getting MCP server info:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST - Handle MCP JSON-RPC requests
|
||||
*/
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
|
||||
const { serverId } = await params
|
||||
|
||||
try {
|
||||
// Validate server
|
||||
const server = await validateServer(serverId)
|
||||
|
||||
if (!server) {
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (!server.isPublished) {
|
||||
return NextResponse.json({ error: 'Server is not published' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Authenticate the request
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Parse JSON-RPC request
|
||||
const body = await request.json()
|
||||
const rpcRequest = body as JsonRpcRequest
|
||||
|
||||
if (rpcRequest.jsonrpc !== '2.0' || !rpcRequest.method) {
|
||||
return NextResponse.json(createJsonRpcError(rpcRequest?.id || 0, -32600, 'Invalid Request'), {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
// Handle different MCP methods
|
||||
switch (rpcRequest.method) {
|
||||
case 'initialize':
|
||||
return NextResponse.json(
|
||||
createJsonRpcResponse(rpcRequest.id, {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
serverInfo: {
|
||||
name: server.name,
|
||||
version: '1.0.0',
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
case 'tools/list':
|
||||
return handleToolsList(rpcRequest, serverId)
|
||||
|
||||
case 'tools/call': {
|
||||
// Get the API key from the request to forward to the workflow execute call
|
||||
const apiKey =
|
||||
request.headers.get('X-API-Key') ||
|
||||
request.headers.get('Authorization')?.replace('Bearer ', '')
|
||||
return handleToolsCall(rpcRequest, serverId, auth.userId, server.workspaceId, apiKey)
|
||||
}
|
||||
|
||||
case 'ping':
|
||||
return NextResponse.json(createJsonRpcResponse(rpcRequest.id, {}))
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
createJsonRpcError(rpcRequest.id, -32601, `Method not found: ${rpcRequest.method}`),
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error handling MCP request:', error)
|
||||
return NextResponse.json(createJsonRpcError(0, -32603, 'Internal error'), { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle tools/list method
|
||||
*/
|
||||
async function handleToolsList(
|
||||
rpcRequest: JsonRpcRequest,
|
||||
serverId: string
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
const tools = await db
|
||||
.select({
|
||||
id: workflowMcpTool.id,
|
||||
toolName: workflowMcpTool.toolName,
|
||||
toolDescription: workflowMcpTool.toolDescription,
|
||||
parameterSchema: workflowMcpTool.parameterSchema,
|
||||
isEnabled: workflowMcpTool.isEnabled,
|
||||
workflowId: workflowMcpTool.workflowId,
|
||||
})
|
||||
.from(workflowMcpTool)
|
||||
.where(eq(workflowMcpTool.serverId, serverId))
|
||||
|
||||
const mcpTools = tools
|
||||
.filter((tool) => tool.isEnabled)
|
||||
.map((tool) => ({
|
||||
name: tool.toolName,
|
||||
description: tool.toolDescription || `Execute workflow tool: ${tool.toolName}`,
|
||||
inputSchema: tool.parameterSchema || {
|
||||
type: 'object',
|
||||
properties: {
|
||||
input: {
|
||||
type: 'object',
|
||||
description: 'Input data for the workflow',
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
return NextResponse.json(createJsonRpcResponse(rpcRequest.id, { tools: mcpTools }))
|
||||
} catch (error) {
|
||||
logger.error('Error listing tools:', error)
|
||||
return NextResponse.json(createJsonRpcError(rpcRequest.id, -32603, 'Failed to list tools'), {
|
||||
status: 500,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle tools/call method
|
||||
*/
|
||||
async function handleToolsCall(
|
||||
rpcRequest: JsonRpcRequest,
|
||||
serverId: string,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
apiKey?: string | null
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
const params = rpcRequest.params as
|
||||
| { name: string; arguments?: Record<string, unknown> }
|
||||
| undefined
|
||||
|
||||
if (!params?.name) {
|
||||
return NextResponse.json(
|
||||
createJsonRpcError(rpcRequest.id, -32602, 'Invalid params: tool name required'),
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Find the tool
|
||||
const [tool] = await db
|
||||
.select({
|
||||
id: workflowMcpTool.id,
|
||||
toolName: workflowMcpTool.toolName,
|
||||
workflowId: workflowMcpTool.workflowId,
|
||||
isEnabled: workflowMcpTool.isEnabled,
|
||||
})
|
||||
.from(workflowMcpTool)
|
||||
.where(eq(workflowMcpTool.serverId, serverId))
|
||||
.then((tools) => tools.filter((t) => t.toolName === params.name))
|
||||
|
||||
if (!tool) {
|
||||
return NextResponse.json(
|
||||
createJsonRpcError(rpcRequest.id, -32602, `Tool not found: ${params.name}`),
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!tool.isEnabled) {
|
||||
return NextResponse.json(
|
||||
createJsonRpcError(rpcRequest.id, -32602, `Tool is disabled: ${params.name}`),
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify workflow is still deployed
|
||||
const [workflowRecord] = await db
|
||||
.select({ id: workflow.id, isDeployed: workflow.isDeployed })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, tool.workflowId))
|
||||
.limit(1)
|
||||
|
||||
if (!workflowRecord || !workflowRecord.isDeployed) {
|
||||
return NextResponse.json(
|
||||
createJsonRpcError(rpcRequest.id, -32603, 'Workflow is not deployed'),
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Execute the workflow
|
||||
const baseUrl = getBaseUrl()
|
||||
const executeUrl = `${baseUrl}/api/workflows/${tool.workflowId}/execute`
|
||||
|
||||
logger.info(`Executing workflow ${tool.workflowId} via MCP tool ${params.name}`)
|
||||
|
||||
// Build headers for the internal execute call
|
||||
const executeHeaders: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
// Forward the API key for authentication
|
||||
if (apiKey) {
|
||||
executeHeaders['X-API-Key'] = apiKey
|
||||
}
|
||||
|
||||
const executeResponse = await fetch(executeUrl, {
|
||||
method: 'POST',
|
||||
headers: executeHeaders,
|
||||
body: JSON.stringify({
|
||||
input: params.arguments || {},
|
||||
triggerType: 'mcp',
|
||||
}),
|
||||
})
|
||||
|
||||
const executeResult = await executeResponse.json()
|
||||
|
||||
if (!executeResponse.ok) {
|
||||
return NextResponse.json(
|
||||
createJsonRpcError(
|
||||
rpcRequest.id,
|
||||
-32603,
|
||||
executeResult.error || 'Workflow execution failed'
|
||||
),
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Format response for MCP
|
||||
const content = [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(executeResult.output || executeResult, null, 2),
|
||||
},
|
||||
]
|
||||
|
||||
return NextResponse.json(
|
||||
createJsonRpcResponse(rpcRequest.id, {
|
||||
content,
|
||||
isError: !executeResult.success,
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Error calling tool:', error)
|
||||
return NextResponse.json(createJsonRpcError(rpcRequest.id, -32603, 'Tool execution failed'), {
|
||||
status: 500,
|
||||
})
|
||||
}
|
||||
}
|
||||
197
apps/sim/app/api/mcp/serve/[serverId]/sse/route.ts
Normal file
197
apps/sim/app/api/mcp/serve/[serverId]/sse/route.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* MCP SSE/HTTP Endpoint
|
||||
*
|
||||
* Implements MCP protocol using the official @modelcontextprotocol/sdk
|
||||
* with a Next.js-compatible transport adapter.
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { workflowMcpServer } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { SSE_HEADERS } from '@/lib/core/utils/sse'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { createMcpSseStream, handleMcpRequest } from '@/lib/mcp/workflow-mcp-server'
|
||||
|
||||
const logger = createLogger('WorkflowMcpSSE')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
interface RouteParams {
|
||||
serverId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the server exists and is published
|
||||
*/
|
||||
async function validateServer(serverId: string) {
|
||||
const [server] = await db
|
||||
.select({
|
||||
id: workflowMcpServer.id,
|
||||
name: workflowMcpServer.name,
|
||||
workspaceId: workflowMcpServer.workspaceId,
|
||||
isPublished: workflowMcpServer.isPublished,
|
||||
})
|
||||
.from(workflowMcpServer)
|
||||
.where(eq(workflowMcpServer.id, serverId))
|
||||
.limit(1)
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
/**
|
||||
* GET - SSE endpoint for MCP protocol
|
||||
* Establishes a Server-Sent Events connection for MCP notifications
|
||||
*/
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
|
||||
const { serverId } = await params
|
||||
|
||||
try {
|
||||
// Validate server exists and is published
|
||||
const server = await validateServer(serverId)
|
||||
|
||||
if (!server) {
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (!server.isPublished) {
|
||||
return NextResponse.json({ error: 'Server is not published' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Check authentication
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const apiKey =
|
||||
request.headers.get('X-API-Key') ||
|
||||
request.headers.get('Authorization')?.replace('Bearer ', '')
|
||||
|
||||
// Create SSE stream using the SDK-based server
|
||||
const stream = createMcpSseStream({
|
||||
serverId,
|
||||
serverName: server.name,
|
||||
userId: auth.userId,
|
||||
workspaceId: server.workspaceId,
|
||||
apiKey,
|
||||
})
|
||||
|
||||
return new NextResponse(stream, {
|
||||
headers: {
|
||||
...SSE_HEADERS,
|
||||
'X-MCP-Server-Id': serverId,
|
||||
'X-MCP-Server-Name': server.name,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error establishing SSE connection:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST - Handle MCP JSON-RPC messages
|
||||
* This is the primary endpoint for MCP protocol messages using the SDK
|
||||
*/
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
|
||||
const { serverId } = await params
|
||||
|
||||
try {
|
||||
// Validate server
|
||||
const server = await validateServer(serverId)
|
||||
|
||||
if (!server) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: null,
|
||||
error: { code: -32000, message: 'Server not found' },
|
||||
},
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!server.isPublished) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: null,
|
||||
error: { code: -32000, message: 'Server is not published' },
|
||||
},
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check authentication
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: null,
|
||||
error: { code: -32000, message: 'Unauthorized' },
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const apiKey =
|
||||
request.headers.get('X-API-Key') ||
|
||||
request.headers.get('Authorization')?.replace('Bearer ', '')
|
||||
|
||||
// Handle the request using the SDK-based server
|
||||
return handleMcpRequest(
|
||||
{
|
||||
serverId,
|
||||
serverName: server.name,
|
||||
userId: auth.userId,
|
||||
workspaceId: server.workspaceId,
|
||||
apiKey,
|
||||
},
|
||||
request
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Error handling MCP POST request:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: null,
|
||||
error: { code: -32603, message: 'Internal error' },
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE - Handle session termination
|
||||
* MCP clients may send DELETE to end a session
|
||||
*/
|
||||
export async function DELETE(request: NextRequest, { params }: { params: Promise<RouteParams> }) {
|
||||
const { serverId } = await params
|
||||
|
||||
try {
|
||||
// Validate server exists
|
||||
const server = await validateServer(serverId)
|
||||
|
||||
if (!server) {
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check authentication
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
logger.info(`MCP session terminated for server ${serverId}`)
|
||||
|
||||
return new NextResponse(null, { status: 204 })
|
||||
} catch (error) {
|
||||
logger.error('Error handling MCP DELETE request:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { NextRequest } from 'next/server'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { withMcpAuth } from '@/lib/mcp/middleware'
|
||||
import { mcpService } from '@/lib/mcp/service'
|
||||
import type { McpServerStatusConfig } from '@/lib/mcp/types'
|
||||
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
||||
|
||||
const logger = createLogger('McpServerRefreshAPI')
|
||||
@@ -50,6 +51,12 @@ export const POST = withMcpAuth<{ id: string }>('read')(
|
||||
let toolCount = 0
|
||||
let lastError: string | null = null
|
||||
|
||||
const currentStatusConfig: McpServerStatusConfig =
|
||||
(server.statusConfig as McpServerStatusConfig | null) ?? {
|
||||
consecutiveFailures: 0,
|
||||
lastSuccessfulDiscovery: null,
|
||||
}
|
||||
|
||||
try {
|
||||
const tools = await mcpService.discoverServerTools(userId, serverId, workspaceId)
|
||||
connectionStatus = 'connected'
|
||||
@@ -63,20 +70,40 @@ export const POST = withMcpAuth<{ id: string }>('read')(
|
||||
logger.warn(`[${requestId}] Failed to connect to server ${serverId}:`, error)
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const newStatusConfig =
|
||||
connectionStatus === 'connected'
|
||||
? { consecutiveFailures: 0, lastSuccessfulDiscovery: now.toISOString() }
|
||||
: {
|
||||
consecutiveFailures: currentStatusConfig.consecutiveFailures + 1,
|
||||
lastSuccessfulDiscovery: currentStatusConfig.lastSuccessfulDiscovery,
|
||||
}
|
||||
|
||||
const [refreshedServer] = await db
|
||||
.update(mcpServers)
|
||||
.set({
|
||||
lastToolsRefresh: new Date(),
|
||||
lastToolsRefresh: now,
|
||||
connectionStatus,
|
||||
lastError,
|
||||
lastConnected: connectionStatus === 'connected' ? new Date() : server.lastConnected,
|
||||
lastConnected: connectionStatus === 'connected' ? now : server.lastConnected,
|
||||
toolCount,
|
||||
updatedAt: new Date(),
|
||||
statusConfig: newStatusConfig,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(mcpServers.id, serverId))
|
||||
.returning()
|
||||
|
||||
logger.info(`[${requestId}] Successfully refreshed MCP server: ${serverId}`)
|
||||
if (connectionStatus === 'connected') {
|
||||
logger.info(
|
||||
`[${requestId}] Successfully refreshed MCP server: ${serverId} (${toolCount} tools)`
|
||||
)
|
||||
await mcpService.clearCache(workspaceId)
|
||||
} else {
|
||||
logger.warn(
|
||||
`[${requestId}] Refresh completed for MCP server ${serverId} but connection failed: ${lastError}`
|
||||
)
|
||||
}
|
||||
|
||||
return createMcpSuccessResponse({
|
||||
status: connectionStatus,
|
||||
toolCount,
|
||||
|
||||
@@ -48,6 +48,19 @@ export const PATCH = withMcpAuth<{ id: string }>('write')(
|
||||
// Remove workspaceId from body to prevent it from being updated
|
||||
const { workspaceId: _, ...updateData } = body
|
||||
|
||||
// Get the current server to check if URL is changing
|
||||
const [currentServer] = await db
|
||||
.select({ url: mcpServers.url })
|
||||
.from(mcpServers)
|
||||
.where(
|
||||
and(
|
||||
eq(mcpServers.id, serverId),
|
||||
eq(mcpServers.workspaceId, workspaceId),
|
||||
isNull(mcpServers.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
const [updatedServer] = await db
|
||||
.update(mcpServers)
|
||||
.set({
|
||||
@@ -71,8 +84,12 @@ export const PATCH = withMcpAuth<{ id: string }>('write')(
|
||||
)
|
||||
}
|
||||
|
||||
// Clear MCP service cache after update
|
||||
mcpService.clearCache(workspaceId)
|
||||
// Only clear cache if URL changed (requires re-discovery)
|
||||
const urlChanged = body.url && currentServer?.url !== body.url
|
||||
if (urlChanged) {
|
||||
await mcpService.clearCache(workspaceId)
|
||||
logger.info(`[${requestId}] Cleared cache due to URL change`)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Successfully updated MCP server: ${serverId}`)
|
||||
return createMcpSuccessResponse({ server: updatedServer })
|
||||
|
||||
@@ -117,12 +117,14 @@ export const POST = withMcpAuth('write')(
|
||||
timeout: body.timeout || 30000,
|
||||
retries: body.retries || 3,
|
||||
enabled: body.enabled !== false,
|
||||
connectionStatus: 'connected',
|
||||
lastConnected: new Date(),
|
||||
updatedAt: new Date(),
|
||||
deletedAt: null,
|
||||
})
|
||||
.where(eq(mcpServers.id, serverId))
|
||||
|
||||
mcpService.clearCache(workspaceId)
|
||||
await mcpService.clearCache(workspaceId)
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Successfully updated MCP server: ${body.name} (ID: ${serverId})`
|
||||
@@ -145,12 +147,14 @@ export const POST = withMcpAuth('write')(
|
||||
timeout: body.timeout || 30000,
|
||||
retries: body.retries || 3,
|
||||
enabled: body.enabled !== false,
|
||||
connectionStatus: 'connected',
|
||||
lastConnected: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning()
|
||||
|
||||
mcpService.clearCache(workspaceId)
|
||||
await mcpService.clearCache(workspaceId)
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Successfully registered MCP server: ${body.name} (ID: ${serverId})`
|
||||
@@ -212,7 +216,7 @@ export const DELETE = withMcpAuth('admin')(
|
||||
)
|
||||
}
|
||||
|
||||
mcpService.clearCache(workspaceId)
|
||||
await mcpService.clearCache(workspaceId)
|
||||
|
||||
logger.info(`[${requestId}] Successfully deleted MCP server: ${serverId}`)
|
||||
return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` })
|
||||
|
||||
103
apps/sim/app/api/mcp/tools/stored/route.ts
Normal file
103
apps/sim/app/api/mcp/tools/stored/route.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflow, workflowBlocks } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { withMcpAuth } from '@/lib/mcp/middleware'
|
||||
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
||||
|
||||
const logger = createLogger('McpStoredToolsAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface StoredMcpTool {
|
||||
workflowId: string
|
||||
workflowName: string
|
||||
serverId: string
|
||||
serverUrl?: string
|
||||
toolName: string
|
||||
schema?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* GET - Get all stored MCP tools from workflows in the workspace
|
||||
*
|
||||
* Scans all workflows in the workspace and extracts MCP tools that have been
|
||||
* added to agent blocks. Returns the stored state of each tool for comparison
|
||||
* against current server state.
|
||||
*/
|
||||
export const GET = withMcpAuth('read')(
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }) => {
|
||||
try {
|
||||
logger.info(`[${requestId}] Fetching stored MCP tools for workspace ${workspaceId}`)
|
||||
|
||||
// Get all workflows in workspace
|
||||
const workflows = await db
|
||||
.select({
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
})
|
||||
.from(workflow)
|
||||
.where(eq(workflow.workspaceId, workspaceId))
|
||||
|
||||
const workflowMap = new Map(workflows.map((w) => [w.id, w.name]))
|
||||
const workflowIds = workflows.map((w) => w.id)
|
||||
|
||||
if (workflowIds.length === 0) {
|
||||
return createMcpSuccessResponse({ tools: [] })
|
||||
}
|
||||
|
||||
// Get all agent blocks from these workflows
|
||||
const agentBlocks = await db
|
||||
.select({
|
||||
workflowId: workflowBlocks.workflowId,
|
||||
subBlocks: workflowBlocks.subBlocks,
|
||||
})
|
||||
.from(workflowBlocks)
|
||||
.where(eq(workflowBlocks.type, 'agent'))
|
||||
|
||||
const storedTools: StoredMcpTool[] = []
|
||||
|
||||
for (const block of agentBlocks) {
|
||||
if (!workflowMap.has(block.workflowId)) continue
|
||||
|
||||
const subBlocks = block.subBlocks as Record<string, unknown> | null
|
||||
if (!subBlocks) continue
|
||||
|
||||
const toolsSubBlock = subBlocks.tools as Record<string, unknown> | undefined
|
||||
const toolsValue = toolsSubBlock?.value
|
||||
|
||||
if (!toolsValue || !Array.isArray(toolsValue)) continue
|
||||
|
||||
for (const tool of toolsValue) {
|
||||
if (tool.type !== 'mcp') continue
|
||||
|
||||
const params = tool.params as Record<string, unknown> | undefined
|
||||
if (!params?.serverId || !params?.toolName) continue
|
||||
|
||||
storedTools.push({
|
||||
workflowId: block.workflowId,
|
||||
workflowName: workflowMap.get(block.workflowId) || 'Untitled',
|
||||
serverId: params.serverId as string,
|
||||
serverUrl: params.serverUrl as string | undefined,
|
||||
toolName: params.toolName as string,
|
||||
schema: tool.schema as Record<string, unknown> | undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Found ${storedTools.length} stored MCP tools across ${workflows.length} workflows`
|
||||
)
|
||||
|
||||
return createMcpSuccessResponse({ tools: storedTools })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error fetching stored MCP tools:`, error)
|
||||
return createMcpErrorResponse(
|
||||
error instanceof Error ? error : new Error('Failed to fetch stored MCP tools'),
|
||||
'Failed to fetch stored MCP tools',
|
||||
500
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
150
apps/sim/app/api/mcp/workflow-servers/[id]/publish/route.ts
Normal file
150
apps/sim/app/api/mcp/workflow-servers/[id]/publish/route.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { withMcpAuth } from '@/lib/mcp/middleware'
|
||||
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
||||
|
||||
const logger = createLogger('WorkflowMcpServerPublishAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
/**
|
||||
* POST - Publish a workflow MCP server (make it accessible via OAuth)
|
||||
*/
|
||||
export const POST = withMcpAuth<RouteParams>('admin')(
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
||||
try {
|
||||
const { id: serverId } = await params
|
||||
|
||||
logger.info(`[${requestId}] Publishing workflow MCP server: ${serverId}`)
|
||||
|
||||
const [existingServer] = await db
|
||||
.select({ id: workflowMcpServer.id, isPublished: workflowMcpServer.isPublished })
|
||||
.from(workflowMcpServer)
|
||||
.where(
|
||||
and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!existingServer) {
|
||||
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
|
||||
}
|
||||
|
||||
if (existingServer.isPublished) {
|
||||
return createMcpErrorResponse(
|
||||
new Error('Server is already published'),
|
||||
'Server is already published',
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
// Check if server has at least one tool
|
||||
const tools = await db
|
||||
.select({ id: workflowMcpTool.id })
|
||||
.from(workflowMcpTool)
|
||||
.where(eq(workflowMcpTool.serverId, serverId))
|
||||
.limit(1)
|
||||
|
||||
if (tools.length === 0) {
|
||||
return createMcpErrorResponse(
|
||||
new Error(
|
||||
'Cannot publish server without any tools. Add at least one workflow as a tool first.'
|
||||
),
|
||||
'Server has no tools',
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
const [updatedServer] = await db
|
||||
.update(workflowMcpServer)
|
||||
.set({
|
||||
isPublished: true,
|
||||
publishedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(workflowMcpServer.id, serverId))
|
||||
.returning()
|
||||
|
||||
const baseUrl = getBaseUrl()
|
||||
const mcpServerUrl = `${baseUrl}/api/mcp/serve/${serverId}/sse`
|
||||
|
||||
logger.info(`[${requestId}] Successfully published workflow MCP server: ${serverId}`)
|
||||
|
||||
return createMcpSuccessResponse({
|
||||
server: updatedServer,
|
||||
mcpServerUrl,
|
||||
message: 'Server published successfully. External MCP clients can now connect using OAuth.',
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error publishing workflow MCP server:`, error)
|
||||
return createMcpErrorResponse(
|
||||
error instanceof Error ? error : new Error('Failed to publish workflow MCP server'),
|
||||
'Failed to publish workflow MCP server',
|
||||
500
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* DELETE - Unpublish a workflow MCP server
|
||||
*/
|
||||
export const DELETE = withMcpAuth<RouteParams>('admin')(
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
||||
try {
|
||||
const { id: serverId } = await params
|
||||
|
||||
logger.info(`[${requestId}] Unpublishing workflow MCP server: ${serverId}`)
|
||||
|
||||
const [existingServer] = await db
|
||||
.select({ id: workflowMcpServer.id, isPublished: workflowMcpServer.isPublished })
|
||||
.from(workflowMcpServer)
|
||||
.where(
|
||||
and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!existingServer) {
|
||||
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
|
||||
}
|
||||
|
||||
if (!existingServer.isPublished) {
|
||||
return createMcpErrorResponse(
|
||||
new Error('Server is not published'),
|
||||
'Server is not published',
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
const [updatedServer] = await db
|
||||
.update(workflowMcpServer)
|
||||
.set({
|
||||
isPublished: false,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(workflowMcpServer.id, serverId))
|
||||
.returning()
|
||||
|
||||
logger.info(`[${requestId}] Successfully unpublished workflow MCP server: ${serverId}`)
|
||||
|
||||
return createMcpSuccessResponse({
|
||||
server: updatedServer,
|
||||
message: 'Server unpublished successfully. External MCP clients can no longer connect.',
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error unpublishing workflow MCP server:`, error)
|
||||
return createMcpErrorResponse(
|
||||
error instanceof Error ? error : new Error('Failed to unpublish workflow MCP server'),
|
||||
'Failed to unpublish workflow MCP server',
|
||||
500
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
157
apps/sim/app/api/mcp/workflow-servers/[id]/route.ts
Normal file
157
apps/sim/app/api/mcp/workflow-servers/[id]/route.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
||||
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
||||
|
||||
const logger = createLogger('WorkflowMcpServerAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
/**
|
||||
* GET - Get a specific workflow MCP server with its tools
|
||||
*/
|
||||
export const GET = withMcpAuth<RouteParams>('read')(
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
||||
try {
|
||||
const { id: serverId } = await params
|
||||
|
||||
logger.info(`[${requestId}] Getting workflow MCP server: ${serverId}`)
|
||||
|
||||
const [server] = await db
|
||||
.select({
|
||||
id: workflowMcpServer.id,
|
||||
workspaceId: workflowMcpServer.workspaceId,
|
||||
createdBy: workflowMcpServer.createdBy,
|
||||
name: workflowMcpServer.name,
|
||||
description: workflowMcpServer.description,
|
||||
isPublished: workflowMcpServer.isPublished,
|
||||
publishedAt: workflowMcpServer.publishedAt,
|
||||
createdAt: workflowMcpServer.createdAt,
|
||||
updatedAt: workflowMcpServer.updatedAt,
|
||||
})
|
||||
.from(workflowMcpServer)
|
||||
.where(
|
||||
and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!server) {
|
||||
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
|
||||
}
|
||||
|
||||
const tools = await db
|
||||
.select()
|
||||
.from(workflowMcpTool)
|
||||
.where(eq(workflowMcpTool.serverId, serverId))
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Found workflow MCP server: ${server.name} with ${tools.length} tools`
|
||||
)
|
||||
|
||||
return createMcpSuccessResponse({ server, tools })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error getting workflow MCP server:`, error)
|
||||
return createMcpErrorResponse(
|
||||
error instanceof Error ? error : new Error('Failed to get workflow MCP server'),
|
||||
'Failed to get workflow MCP server',
|
||||
500
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* PATCH - Update a workflow MCP server
|
||||
*/
|
||||
export const PATCH = withMcpAuth<RouteParams>('write')(
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
||||
try {
|
||||
const { id: serverId } = await params
|
||||
const body = getParsedBody(request) || (await request.json())
|
||||
|
||||
logger.info(`[${requestId}] Updating workflow MCP server: ${serverId}`)
|
||||
|
||||
const [existingServer] = await db
|
||||
.select({ id: workflowMcpServer.id })
|
||||
.from(workflowMcpServer)
|
||||
.where(
|
||||
and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!existingServer) {
|
||||
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
if (body.name !== undefined) {
|
||||
updateData.name = body.name.trim()
|
||||
}
|
||||
if (body.description !== undefined) {
|
||||
updateData.description = body.description?.trim() || null
|
||||
}
|
||||
|
||||
const [updatedServer] = await db
|
||||
.update(workflowMcpServer)
|
||||
.set(updateData)
|
||||
.where(eq(workflowMcpServer.id, serverId))
|
||||
.returning()
|
||||
|
||||
logger.info(`[${requestId}] Successfully updated workflow MCP server: ${serverId}`)
|
||||
|
||||
return createMcpSuccessResponse({ server: updatedServer })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error updating workflow MCP server:`, error)
|
||||
return createMcpErrorResponse(
|
||||
error instanceof Error ? error : new Error('Failed to update workflow MCP server'),
|
||||
'Failed to update workflow MCP server',
|
||||
500
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* DELETE - Delete a workflow MCP server and all its tools
|
||||
*/
|
||||
export const DELETE = withMcpAuth<RouteParams>('admin')(
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
||||
try {
|
||||
const { id: serverId } = await params
|
||||
|
||||
logger.info(`[${requestId}] Deleting workflow MCP server: ${serverId}`)
|
||||
|
||||
const [deletedServer] = await db
|
||||
.delete(workflowMcpServer)
|
||||
.where(
|
||||
and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId))
|
||||
)
|
||||
.returning()
|
||||
|
||||
if (!deletedServer) {
|
||||
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Successfully deleted workflow MCP server: ${serverId}`)
|
||||
|
||||
return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error deleting workflow MCP server:`, error)
|
||||
return createMcpErrorResponse(
|
||||
error instanceof Error ? error : new Error('Failed to delete workflow MCP server'),
|
||||
'Failed to delete workflow MCP server',
|
||||
500
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,178 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
||||
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
||||
|
||||
const logger = createLogger('WorkflowMcpToolAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
toolId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* GET - Get a specific tool
|
||||
*/
|
||||
export const GET = withMcpAuth<RouteParams>('read')(
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
||||
try {
|
||||
const { id: serverId, toolId } = await params
|
||||
|
||||
logger.info(`[${requestId}] Getting tool ${toolId} from server ${serverId}`)
|
||||
|
||||
// Verify server exists and belongs to workspace
|
||||
const [server] = await db
|
||||
.select({ id: workflowMcpServer.id })
|
||||
.from(workflowMcpServer)
|
||||
.where(
|
||||
and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!server) {
|
||||
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
|
||||
}
|
||||
|
||||
const [tool] = await db
|
||||
.select()
|
||||
.from(workflowMcpTool)
|
||||
.where(and(eq(workflowMcpTool.id, toolId), eq(workflowMcpTool.serverId, serverId)))
|
||||
.limit(1)
|
||||
|
||||
if (!tool) {
|
||||
return createMcpErrorResponse(new Error('Tool not found'), 'Tool not found', 404)
|
||||
}
|
||||
|
||||
return createMcpSuccessResponse({ tool })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error getting tool:`, error)
|
||||
return createMcpErrorResponse(
|
||||
error instanceof Error ? error : new Error('Failed to get tool'),
|
||||
'Failed to get tool',
|
||||
500
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* PATCH - Update a tool's configuration
|
||||
*/
|
||||
export const PATCH = withMcpAuth<RouteParams>('write')(
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
||||
try {
|
||||
const { id: serverId, toolId } = await params
|
||||
const body = getParsedBody(request) || (await request.json())
|
||||
|
||||
logger.info(`[${requestId}] Updating tool ${toolId} in server ${serverId}`)
|
||||
|
||||
// Verify server exists and belongs to workspace
|
||||
const [server] = await db
|
||||
.select({ id: workflowMcpServer.id })
|
||||
.from(workflowMcpServer)
|
||||
.where(
|
||||
and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!server) {
|
||||
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
|
||||
}
|
||||
|
||||
const [existingTool] = await db
|
||||
.select({ id: workflowMcpTool.id })
|
||||
.from(workflowMcpTool)
|
||||
.where(and(eq(workflowMcpTool.id, toolId), eq(workflowMcpTool.serverId, serverId)))
|
||||
.limit(1)
|
||||
|
||||
if (!existingTool) {
|
||||
return createMcpErrorResponse(new Error('Tool not found'), 'Tool not found', 404)
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
if (body.toolName !== undefined) {
|
||||
updateData.toolName = body.toolName.trim()
|
||||
}
|
||||
if (body.toolDescription !== undefined) {
|
||||
updateData.toolDescription = body.toolDescription?.trim() || null
|
||||
}
|
||||
if (body.parameterSchema !== undefined) {
|
||||
updateData.parameterSchema = body.parameterSchema
|
||||
}
|
||||
if (body.isEnabled !== undefined) {
|
||||
updateData.isEnabled = body.isEnabled
|
||||
}
|
||||
|
||||
const [updatedTool] = await db
|
||||
.update(workflowMcpTool)
|
||||
.set(updateData)
|
||||
.where(eq(workflowMcpTool.id, toolId))
|
||||
.returning()
|
||||
|
||||
logger.info(`[${requestId}] Successfully updated tool ${toolId}`)
|
||||
|
||||
return createMcpSuccessResponse({ tool: updatedTool })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error updating tool:`, error)
|
||||
return createMcpErrorResponse(
|
||||
error instanceof Error ? error : new Error('Failed to update tool'),
|
||||
'Failed to update tool',
|
||||
500
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* DELETE - Remove a tool from an MCP server
|
||||
*/
|
||||
export const DELETE = withMcpAuth<RouteParams>('write')(
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
||||
try {
|
||||
const { id: serverId, toolId } = await params
|
||||
|
||||
logger.info(`[${requestId}] Deleting tool ${toolId} from server ${serverId}`)
|
||||
|
||||
// Verify server exists and belongs to workspace
|
||||
const [server] = await db
|
||||
.select({ id: workflowMcpServer.id })
|
||||
.from(workflowMcpServer)
|
||||
.where(
|
||||
and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!server) {
|
||||
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
|
||||
}
|
||||
|
||||
const [deletedTool] = await db
|
||||
.delete(workflowMcpTool)
|
||||
.where(and(eq(workflowMcpTool.id, toolId), eq(workflowMcpTool.serverId, serverId)))
|
||||
.returning()
|
||||
|
||||
if (!deletedTool) {
|
||||
return createMcpErrorResponse(new Error('Tool not found'), 'Tool not found', 404)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Successfully deleted tool ${toolId}`)
|
||||
|
||||
return createMcpSuccessResponse({ message: `Tool ${toolId} deleted successfully` })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error deleting tool:`, error)
|
||||
return createMcpErrorResponse(
|
||||
error instanceof Error ? error : new Error('Failed to delete tool'),
|
||||
'Failed to delete tool',
|
||||
500
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
226
apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts
Normal file
226
apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
||||
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
||||
import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils'
|
||||
|
||||
const logger = createLogger('WorkflowMcpToolsAPI')
|
||||
|
||||
/**
|
||||
* Check if a workflow has a valid start block by loading from database
|
||||
*/
|
||||
async function hasValidStartBlock(workflowId: string): Promise<boolean> {
|
||||
try {
|
||||
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
|
||||
return hasValidStartBlockInState(normalizedData)
|
||||
} catch (error) {
|
||||
logger.warn('Error checking for start block:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
/**
|
||||
* GET - List all tools for a workflow MCP server
|
||||
*/
|
||||
export const GET = withMcpAuth<RouteParams>('read')(
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
||||
try {
|
||||
const { id: serverId } = await params
|
||||
|
||||
logger.info(`[${requestId}] Listing tools for workflow MCP server: ${serverId}`)
|
||||
|
||||
// Verify server exists and belongs to workspace
|
||||
const [server] = await db
|
||||
.select({ id: workflowMcpServer.id })
|
||||
.from(workflowMcpServer)
|
||||
.where(
|
||||
and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!server) {
|
||||
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
|
||||
}
|
||||
|
||||
// Get tools with workflow details
|
||||
const tools = await db
|
||||
.select({
|
||||
id: workflowMcpTool.id,
|
||||
serverId: workflowMcpTool.serverId,
|
||||
workflowId: workflowMcpTool.workflowId,
|
||||
toolName: workflowMcpTool.toolName,
|
||||
toolDescription: workflowMcpTool.toolDescription,
|
||||
parameterSchema: workflowMcpTool.parameterSchema,
|
||||
isEnabled: workflowMcpTool.isEnabled,
|
||||
createdAt: workflowMcpTool.createdAt,
|
||||
updatedAt: workflowMcpTool.updatedAt,
|
||||
workflowName: workflow.name,
|
||||
workflowDescription: workflow.description,
|
||||
isDeployed: workflow.isDeployed,
|
||||
})
|
||||
.from(workflowMcpTool)
|
||||
.leftJoin(workflow, eq(workflowMcpTool.workflowId, workflow.id))
|
||||
.where(eq(workflowMcpTool.serverId, serverId))
|
||||
|
||||
logger.info(`[${requestId}] Found ${tools.length} tools for server ${serverId}`)
|
||||
|
||||
return createMcpSuccessResponse({ tools })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error listing tools:`, error)
|
||||
return createMcpErrorResponse(
|
||||
error instanceof Error ? error : new Error('Failed to list tools'),
|
||||
'Failed to list tools',
|
||||
500
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* POST - Add a workflow as a tool to an MCP server
|
||||
*/
|
||||
export const POST = withMcpAuth<RouteParams>('write')(
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
||||
try {
|
||||
const { id: serverId } = await params
|
||||
const body = getParsedBody(request) || (await request.json())
|
||||
|
||||
logger.info(`[${requestId}] Adding tool to workflow MCP server: ${serverId}`, {
|
||||
workflowId: body.workflowId,
|
||||
})
|
||||
|
||||
if (!body.workflowId) {
|
||||
return createMcpErrorResponse(
|
||||
new Error('Missing required field: workflowId'),
|
||||
'Missing required field',
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
// Verify server exists and belongs to workspace
|
||||
const [server] = await db
|
||||
.select({ id: workflowMcpServer.id })
|
||||
.from(workflowMcpServer)
|
||||
.where(
|
||||
and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!server) {
|
||||
return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404)
|
||||
}
|
||||
|
||||
// Verify workflow exists and is deployed
|
||||
const [workflowRecord] = await db
|
||||
.select({
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
description: workflow.description,
|
||||
isDeployed: workflow.isDeployed,
|
||||
workspaceId: workflow.workspaceId,
|
||||
})
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, body.workflowId))
|
||||
.limit(1)
|
||||
|
||||
if (!workflowRecord) {
|
||||
return createMcpErrorResponse(new Error('Workflow not found'), 'Workflow not found', 404)
|
||||
}
|
||||
|
||||
// Verify workflow belongs to the same workspace
|
||||
if (workflowRecord.workspaceId !== workspaceId) {
|
||||
return createMcpErrorResponse(
|
||||
new Error('Workflow does not belong to this workspace'),
|
||||
'Access denied',
|
||||
403
|
||||
)
|
||||
}
|
||||
|
||||
if (!workflowRecord.isDeployed) {
|
||||
return createMcpErrorResponse(
|
||||
new Error('Workflow must be deployed before adding as a tool'),
|
||||
'Workflow not deployed',
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
// Verify workflow has a valid start block
|
||||
const hasStartBlock = await hasValidStartBlock(body.workflowId)
|
||||
if (!hasStartBlock) {
|
||||
return createMcpErrorResponse(
|
||||
new Error('Workflow must have a Start block to be used as an MCP tool'),
|
||||
'No start block found',
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
// Check if tool already exists for this workflow
|
||||
const [existingTool] = await db
|
||||
.select({ id: workflowMcpTool.id })
|
||||
.from(workflowMcpTool)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowMcpTool.serverId, serverId),
|
||||
eq(workflowMcpTool.workflowId, body.workflowId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingTool) {
|
||||
return createMcpErrorResponse(
|
||||
new Error('This workflow is already added as a tool to this server'),
|
||||
'Tool already exists',
|
||||
409
|
||||
)
|
||||
}
|
||||
|
||||
// Generate tool name and description
|
||||
const toolName = body.toolName?.trim() || sanitizeToolName(workflowRecord.name)
|
||||
const toolDescription =
|
||||
body.toolDescription?.trim() ||
|
||||
workflowRecord.description ||
|
||||
`Execute ${workflowRecord.name} workflow`
|
||||
|
||||
// Create the tool
|
||||
const toolId = crypto.randomUUID()
|
||||
const [tool] = await db
|
||||
.insert(workflowMcpTool)
|
||||
.values({
|
||||
id: toolId,
|
||||
serverId,
|
||||
workflowId: body.workflowId,
|
||||
toolName,
|
||||
toolDescription,
|
||||
parameterSchema: body.parameterSchema || {},
|
||||
isEnabled: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning()
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Successfully added tool ${toolName} (workflow: ${body.workflowId}) to server ${serverId}`
|
||||
)
|
||||
|
||||
return createMcpSuccessResponse({ tool }, 201)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error adding tool:`, error)
|
||||
return createMcpErrorResponse(
|
||||
error instanceof Error ? error : new Error('Failed to add tool'),
|
||||
'Failed to add tool',
|
||||
500
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
107
apps/sim/app/api/mcp/workflow-servers/route.ts
Normal file
107
apps/sim/app/api/mcp/workflow-servers/route.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflowMcpServer } from '@sim/db/schema'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
||||
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
||||
|
||||
const logger = createLogger('WorkflowMcpServersAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* GET - List all workflow MCP servers for the workspace
|
||||
*/
|
||||
export const GET = withMcpAuth('read')(
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }) => {
|
||||
try {
|
||||
logger.info(`[${requestId}] Listing workflow MCP servers for workspace ${workspaceId}`)
|
||||
|
||||
const servers = await db
|
||||
.select({
|
||||
id: workflowMcpServer.id,
|
||||
workspaceId: workflowMcpServer.workspaceId,
|
||||
createdBy: workflowMcpServer.createdBy,
|
||||
name: workflowMcpServer.name,
|
||||
description: workflowMcpServer.description,
|
||||
isPublished: workflowMcpServer.isPublished,
|
||||
publishedAt: workflowMcpServer.publishedAt,
|
||||
createdAt: workflowMcpServer.createdAt,
|
||||
updatedAt: workflowMcpServer.updatedAt,
|
||||
toolCount: sql<number>`(
|
||||
SELECT COUNT(*)::int
|
||||
FROM "workflow_mcp_tool"
|
||||
WHERE "workflow_mcp_tool"."server_id" = "workflow_mcp_server"."id"
|
||||
)`.as('tool_count'),
|
||||
})
|
||||
.from(workflowMcpServer)
|
||||
.where(eq(workflowMcpServer.workspaceId, workspaceId))
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Listed ${servers.length} workflow MCP servers for workspace ${workspaceId}`
|
||||
)
|
||||
return createMcpSuccessResponse({ servers })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error listing workflow MCP servers:`, error)
|
||||
return createMcpErrorResponse(
|
||||
error instanceof Error ? error : new Error('Failed to list workflow MCP servers'),
|
||||
'Failed to list workflow MCP servers',
|
||||
500
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* POST - Create a new workflow MCP server
|
||||
*/
|
||||
export const POST = withMcpAuth('write')(
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }) => {
|
||||
try {
|
||||
const body = getParsedBody(request) || (await request.json())
|
||||
|
||||
logger.info(`[${requestId}] Creating workflow MCP server:`, {
|
||||
name: body.name,
|
||||
workspaceId,
|
||||
})
|
||||
|
||||
if (!body.name) {
|
||||
return createMcpErrorResponse(
|
||||
new Error('Missing required field: name'),
|
||||
'Missing required field',
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
const serverId = crypto.randomUUID()
|
||||
|
||||
const [server] = await db
|
||||
.insert(workflowMcpServer)
|
||||
.values({
|
||||
id: serverId,
|
||||
workspaceId,
|
||||
createdBy: userId,
|
||||
name: body.name.trim(),
|
||||
description: body.description?.trim() || null,
|
||||
isPublished: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning()
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Successfully created workflow MCP server: ${body.name} (ID: ${serverId})`
|
||||
)
|
||||
|
||||
return createMcpSuccessResponse({ server }, 201)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error creating workflow MCP server:`, error)
|
||||
return createMcpErrorResponse(
|
||||
error instanceof Error ? error : new Error('Failed to create workflow MCP server'),
|
||||
'Failed to create workflow MCP server',
|
||||
500
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -35,6 +35,8 @@ export async function POST(request: NextRequest) {
|
||||
apiKey,
|
||||
azureEndpoint,
|
||||
azureApiVersion,
|
||||
vertexProject,
|
||||
vertexLocation,
|
||||
responseFormat,
|
||||
workflowId,
|
||||
workspaceId,
|
||||
@@ -58,6 +60,8 @@ export async function POST(request: NextRequest) {
|
||||
hasApiKey: !!apiKey,
|
||||
hasAzureEndpoint: !!azureEndpoint,
|
||||
hasAzureApiVersion: !!azureApiVersion,
|
||||
hasVertexProject: !!vertexProject,
|
||||
hasVertexLocation: !!vertexLocation,
|
||||
hasResponseFormat: !!responseFormat,
|
||||
workflowId,
|
||||
stream: !!stream,
|
||||
@@ -104,6 +108,8 @@ export async function POST(request: NextRequest) {
|
||||
apiKey: finalApiKey,
|
||||
azureEndpoint,
|
||||
azureApiVersion,
|
||||
vertexProject,
|
||||
vertexLocation,
|
||||
responseFormat,
|
||||
workflowId,
|
||||
workspaceId,
|
||||
|
||||
@@ -1,17 +1,121 @@
|
||||
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
|
||||
import { db, workflow, workflowDeploymentVersion, workflowMcpTool } from '@sim/db'
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { deployWorkflow } from '@/lib/workflows/persistence/utils'
|
||||
import {
|
||||
extractInputFormatFromBlocks,
|
||||
generateToolInputSchema,
|
||||
} from '@/lib/mcp/workflow-tool-schema'
|
||||
import { deployWorkflow, loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
|
||||
const logger = createLogger('WorkflowDeployAPI')
|
||||
|
||||
/**
|
||||
* Check if a workflow has a valid start block by loading from database
|
||||
*/
|
||||
async function hasValidStartBlock(workflowId: string): Promise<boolean> {
|
||||
try {
|
||||
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
|
||||
return hasValidStartBlockInState(normalizedData)
|
||||
} catch (error) {
|
||||
logger.warn('Error checking for start block:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
/**
|
||||
* Extract input format from workflow blocks and generate MCP tool parameter schema
|
||||
*/
|
||||
async function generateMcpToolSchema(workflowId: string): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
|
||||
if (!normalizedData?.blocks) {
|
||||
return { type: 'object', properties: {} }
|
||||
}
|
||||
|
||||
const inputFormat = extractInputFormatFromBlocks(normalizedData.blocks)
|
||||
if (!inputFormat || inputFormat.length === 0) {
|
||||
return { type: 'object', properties: {} }
|
||||
}
|
||||
|
||||
return generateToolInputSchema(inputFormat) as unknown as Record<string, unknown>
|
||||
} catch (error) {
|
||||
logger.warn('Error generating MCP tool schema:', error)
|
||||
return { type: 'object', properties: {} }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update all MCP tools that reference this workflow with the latest parameter schema.
|
||||
* If the workflow no longer has a start block, remove all MCP tools.
|
||||
*/
|
||||
async function syncMcpToolsOnDeploy(workflowId: string, requestId: string): Promise<void> {
|
||||
try {
|
||||
// Get all MCP tools that use this workflow
|
||||
const tools = await db
|
||||
.select({ id: workflowMcpTool.id })
|
||||
.from(workflowMcpTool)
|
||||
.where(eq(workflowMcpTool.workflowId, workflowId))
|
||||
|
||||
if (tools.length === 0) {
|
||||
logger.debug(`[${requestId}] No MCP tools to sync for workflow: ${workflowId}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if workflow still has a valid start block
|
||||
const hasStart = await hasValidStartBlock(workflowId)
|
||||
if (!hasStart) {
|
||||
// No start block - remove all MCP tools for this workflow
|
||||
await db.delete(workflowMcpTool).where(eq(workflowMcpTool.workflowId, workflowId))
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Removed ${tools.length} MCP tool(s) - workflow no longer has a start block: ${workflowId}`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate the latest parameter schema
|
||||
const parameterSchema = await generateMcpToolSchema(workflowId)
|
||||
|
||||
// Update all tools with the new schema
|
||||
await db
|
||||
.update(workflowMcpTool)
|
||||
.set({
|
||||
parameterSchema,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(workflowMcpTool.workflowId, workflowId))
|
||||
|
||||
logger.info(`[${requestId}] Synced ${tools.length} MCP tool(s) for workflow: ${workflowId}`)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error syncing MCP tools:`, error)
|
||||
// Don't throw - this is a non-critical operation
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all MCP tools that reference this workflow when undeploying
|
||||
*/
|
||||
async function removeMcpToolsOnUndeploy(workflowId: string, requestId: string): Promise<void> {
|
||||
try {
|
||||
const result = await db
|
||||
.delete(workflowMcpTool)
|
||||
.where(eq(workflowMcpTool.workflowId, workflowId))
|
||||
|
||||
logger.info(`[${requestId}] Removed MCP tools for undeployed workflow: ${workflowId}`)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error removing MCP tools:`, error)
|
||||
// Don't throw - this is a non-critical operation
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = generateRequestId()
|
||||
const { id } = await params
|
||||
@@ -119,6 +223,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
logger.info(`[${requestId}] Workflow deployed successfully: ${id}`)
|
||||
|
||||
// Sync MCP tools with the latest parameter schema
|
||||
await syncMcpToolsOnDeploy(id, requestId)
|
||||
|
||||
const responseApiKeyInfo = workflowData!.workspaceId
|
||||
? 'Workspace API keys'
|
||||
: 'Personal API keys'
|
||||
@@ -167,6 +274,9 @@ export async function DELETE(
|
||||
.where(eq(workflow.id, id))
|
||||
})
|
||||
|
||||
// Remove all MCP tools that reference this workflow
|
||||
await removeMcpToolsOnUndeploy(id, requestId)
|
||||
|
||||
logger.info(`[${requestId}] Workflow undeployed successfully: ${id}`)
|
||||
|
||||
// Track workflow undeployment
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
|
||||
import { db, workflow, workflowDeploymentVersion, workflowMcpTool } from '@sim/db'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
extractInputFormatFromBlocks,
|
||||
generateToolInputSchema,
|
||||
} from '@/lib/mcp/workflow-tool-schema'
|
||||
import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
|
||||
@@ -11,6 +16,80 @@ const logger = createLogger('WorkflowActivateDeploymentAPI')
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
/**
|
||||
* Extract input format from a deployment version state and generate MCP tool parameter schema
|
||||
*/
|
||||
function generateMcpToolSchemaFromState(state: any): Record<string, unknown> {
|
||||
try {
|
||||
if (!state?.blocks) {
|
||||
return { type: 'object', properties: {} }
|
||||
}
|
||||
|
||||
const inputFormat = extractInputFormatFromBlocks(state.blocks)
|
||||
if (!inputFormat || inputFormat.length === 0) {
|
||||
return { type: 'object', properties: {} }
|
||||
}
|
||||
|
||||
return generateToolInputSchema(inputFormat) as unknown as Record<string, unknown>
|
||||
} catch (error) {
|
||||
logger.warn('Error generating MCP tool schema from state:', error)
|
||||
return { type: 'object', properties: {} }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync MCP tools when activating a deployment version.
|
||||
* If the version has no start block, remove all MCP tools.
|
||||
*/
|
||||
async function syncMcpToolsOnVersionActivate(
|
||||
workflowId: string,
|
||||
versionState: any,
|
||||
requestId: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Get all MCP tools that use this workflow
|
||||
const tools = await db
|
||||
.select({ id: workflowMcpTool.id })
|
||||
.from(workflowMcpTool)
|
||||
.where(eq(workflowMcpTool.workflowId, workflowId))
|
||||
|
||||
if (tools.length === 0) {
|
||||
logger.debug(`[${requestId}] No MCP tools to sync for workflow: ${workflowId}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the activated version has a valid start block
|
||||
if (!hasValidStartBlockInState(versionState)) {
|
||||
// No start block - remove all MCP tools for this workflow
|
||||
await db.delete(workflowMcpTool).where(eq(workflowMcpTool.workflowId, workflowId))
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Removed ${tools.length} MCP tool(s) - activated version has no start block: ${workflowId}`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate the parameter schema from the activated version's state
|
||||
const parameterSchema = generateMcpToolSchemaFromState(versionState)
|
||||
|
||||
// Update all tools with the new schema
|
||||
await db
|
||||
.update(workflowMcpTool)
|
||||
.set({
|
||||
parameterSchema,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(workflowMcpTool.workflowId, workflowId))
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Synced ${tools.length} MCP tool(s) for workflow version activation: ${workflowId}`
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error syncing MCP tools on version activate:`, error)
|
||||
// Don't throw - this is a non-critical operation
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; version: string }> }
|
||||
@@ -31,6 +110,18 @@ export async function POST(
|
||||
|
||||
const now = new Date()
|
||||
|
||||
// Get the state of the version being activated for MCP tool sync
|
||||
const [versionData] = await db
|
||||
.select({ state: workflowDeploymentVersion.state })
|
||||
.from(workflowDeploymentVersion)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowDeploymentVersion.workflowId, id),
|
||||
eq(workflowDeploymentVersion.version, versionNum)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(workflowDeploymentVersion)
|
||||
@@ -65,6 +156,11 @@ export async function POST(
|
||||
await tx.update(workflow).set(updateData).where(eq(workflow.id, id))
|
||||
})
|
||||
|
||||
// Sync MCP tools with the activated version's parameter schema
|
||||
if (versionData?.state) {
|
||||
await syncMcpToolsOnVersionActivate(id, versionData.state, requestId)
|
||||
}
|
||||
|
||||
return createSuccessResponse({ success: true, deployedAt: now })
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error activating deployment for workflow: ${id}`, error)
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
|
||||
import { db, workflow, workflowDeploymentVersion, workflowMcpTool } from '@sim/db'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
extractInputFormatFromBlocks,
|
||||
generateToolInputSchema,
|
||||
} from '@/lib/mcp/workflow-tool-schema'
|
||||
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
|
||||
@@ -13,6 +18,80 @@ const logger = createLogger('RevertToDeploymentVersionAPI')
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
/**
|
||||
* Extract input format from a deployment version state and generate MCP tool parameter schema
|
||||
*/
|
||||
function generateMcpToolSchemaFromState(state: any): Record<string, unknown> {
|
||||
try {
|
||||
if (!state?.blocks) {
|
||||
return { type: 'object', properties: {} }
|
||||
}
|
||||
|
||||
const inputFormat = extractInputFormatFromBlocks(state.blocks)
|
||||
if (!inputFormat || inputFormat.length === 0) {
|
||||
return { type: 'object', properties: {} }
|
||||
}
|
||||
|
||||
return generateToolInputSchema(inputFormat) as unknown as Record<string, unknown>
|
||||
} catch (error) {
|
||||
logger.warn('Error generating MCP tool schema from state:', error)
|
||||
return { type: 'object', properties: {} }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync MCP tools when reverting to a deployment version.
|
||||
* If the version has no start block, remove all MCP tools.
|
||||
*/
|
||||
async function syncMcpToolsOnRevert(
|
||||
workflowId: string,
|
||||
versionState: any,
|
||||
requestId: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Get all MCP tools that use this workflow
|
||||
const tools = await db
|
||||
.select({ id: workflowMcpTool.id })
|
||||
.from(workflowMcpTool)
|
||||
.where(eq(workflowMcpTool.workflowId, workflowId))
|
||||
|
||||
if (tools.length === 0) {
|
||||
logger.debug(`[${requestId}] No MCP tools to sync for workflow: ${workflowId}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the reverted version has a valid start block
|
||||
if (!hasValidStartBlockInState(versionState)) {
|
||||
// No start block - remove all MCP tools for this workflow
|
||||
await db.delete(workflowMcpTool).where(eq(workflowMcpTool.workflowId, workflowId))
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Removed ${tools.length} MCP tool(s) - reverted version has no start block: ${workflowId}`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate the parameter schema from the reverted version's state
|
||||
const parameterSchema = generateMcpToolSchemaFromState(versionState)
|
||||
|
||||
// Update all tools with the new schema
|
||||
await db
|
||||
.update(workflowMcpTool)
|
||||
.set({
|
||||
parameterSchema,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(workflowMcpTool.workflowId, workflowId))
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Synced ${tools.length} MCP tool(s) for workflow revert: ${workflowId}`
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error syncing MCP tools on revert:`, error)
|
||||
// Don't throw - this is a non-critical operation
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; version: string }> }
|
||||
@@ -87,6 +166,9 @@ export async function POST(
|
||||
.set({ lastSynced: new Date(), updatedAt: new Date() })
|
||||
.where(eq(workflow.id, id))
|
||||
|
||||
// Sync MCP tools with the reverted version's parameter schema
|
||||
await syncMcpToolsOnRevert(id, deployedState, requestId)
|
||||
|
||||
try {
|
||||
const socketServerUrl = env.SOCKET_SERVER_URL || 'http://localhost:3002'
|
||||
await fetch(`${socketServerUrl}/api/workflow-reverted`, {
|
||||
|
||||
@@ -30,7 +30,7 @@ const logger = createLogger('WorkflowExecuteAPI')
|
||||
|
||||
const ExecuteWorkflowSchema = z.object({
|
||||
selectedOutputs: z.array(z.string()).optional().default([]),
|
||||
triggerType: z.enum(['api', 'webhook', 'schedule', 'manual', 'chat']).optional(),
|
||||
triggerType: z.enum(['api', 'webhook', 'schedule', 'manual', 'chat', 'mcp']).optional(),
|
||||
stream: z.boolean().optional(),
|
||||
useDraftState: z.boolean().optional(),
|
||||
input: z.any().optional(),
|
||||
@@ -227,7 +227,7 @@ type AsyncExecutionParams = {
|
||||
workflowId: string
|
||||
userId: string
|
||||
input: any
|
||||
triggerType: 'api' | 'webhook' | 'schedule' | 'manual' | 'chat'
|
||||
triggerType: 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' | 'mcp'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -370,14 +370,15 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
})
|
||||
|
||||
const executionId = uuidv4()
|
||||
type LoggingTriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat'
|
||||
type LoggingTriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' | 'mcp'
|
||||
let loggingTriggerType: LoggingTriggerType = 'manual'
|
||||
if (
|
||||
triggerType === 'api' ||
|
||||
triggerType === 'chat' ||
|
||||
triggerType === 'webhook' ||
|
||||
triggerType === 'schedule' ||
|
||||
triggerType === 'manual'
|
||||
triggerType === 'manual' ||
|
||||
triggerType === 'mcp'
|
||||
) {
|
||||
loggingTriggerType = triggerType as LoggingTriggerType
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ const PRIMARY_BUTTON_STYLES =
|
||||
|
||||
type NotificationType = 'webhook' | 'email' | 'slack'
|
||||
type LogLevel = 'info' | 'error'
|
||||
type TriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat'
|
||||
type TriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' | 'mcp'
|
||||
type AlertRule =
|
||||
| 'none'
|
||||
| 'consecutive_failures'
|
||||
@@ -84,7 +84,7 @@ interface NotificationSettingsProps {
|
||||
}
|
||||
|
||||
const LOG_LEVELS: LogLevel[] = ['info', 'error']
|
||||
const TRIGGER_TYPES: TriggerType[] = ['api', 'webhook', 'schedule', 'manual', 'chat']
|
||||
const TRIGGER_TYPES: TriggerType[] = ['api', 'webhook', 'schedule', 'manual', 'chat', 'mcp']
|
||||
|
||||
function formatAlertConfigLabel(config: {
|
||||
rule: AlertRule
|
||||
@@ -137,7 +137,7 @@ export function NotificationSettings({
|
||||
workflowIds: [] as string[],
|
||||
allWorkflows: true,
|
||||
levelFilter: ['info', 'error'] as LogLevel[],
|
||||
triggerFilter: ['api', 'webhook', 'schedule', 'manual', 'chat'] as TriggerType[],
|
||||
triggerFilter: ['api', 'webhook', 'schedule', 'manual', 'chat', 'mcp'] as TriggerType[],
|
||||
includeFinalOutput: false,
|
||||
includeTraceSpans: false,
|
||||
includeRateLimits: false,
|
||||
@@ -207,7 +207,7 @@ export function NotificationSettings({
|
||||
workflowIds: [],
|
||||
allWorkflows: true,
|
||||
levelFilter: ['info', 'error'],
|
||||
triggerFilter: ['api', 'webhook', 'schedule', 'manual', 'chat'],
|
||||
triggerFilter: ['api', 'webhook', 'schedule', 'manual', 'chat', 'mcp'],
|
||||
includeFinalOutput: false,
|
||||
includeTraceSpans: false,
|
||||
includeRateLimits: false,
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Search, X } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Button, Popover, PopoverAnchor, PopoverContent } from '@/components/emcn'
|
||||
import { Badge, Popover, PopoverAnchor, PopoverContent } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options'
|
||||
import { getTriggerOptions } from '@/lib/logs/get-trigger-options'
|
||||
import { type ParsedFilter, parseQuery } from '@/lib/logs/query-parser'
|
||||
import {
|
||||
type FolderData,
|
||||
@@ -18,7 +16,15 @@ import { useSearchState } from '@/app/workspace/[workspaceId]/logs/hooks/use-sea
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
const logger = createLogger('AutocompleteSearch')
|
||||
function truncateFilterValue(field: string, value: string): string {
|
||||
if ((field === 'executionId' || field === 'workflowId') && value.length > 12) {
|
||||
return `...${value.slice(-6)}`
|
||||
}
|
||||
if (value.length > 20) {
|
||||
return `${value.slice(0, 17)}...`
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
interface AutocompleteSearchProps {
|
||||
value: string
|
||||
@@ -35,11 +41,8 @@ export function AutocompleteSearch({
|
||||
className,
|
||||
onOpenChange,
|
||||
}: AutocompleteSearchProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const workflows = useWorkflowRegistry((state) => state.workflows)
|
||||
const folders = useFolderStore((state) => state.folders)
|
||||
const [triggersData, setTriggersData] = useState<TriggerData[]>([])
|
||||
|
||||
const workflowsData = useMemo<WorkflowData[]>(() => {
|
||||
return Object.values(workflows).map((w) => ({
|
||||
@@ -56,32 +59,13 @@ export function AutocompleteSearch({
|
||||
}))
|
||||
}, [folders])
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceId) return
|
||||
|
||||
const fetchTriggers = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/logs/triggers?workspaceId=${workspaceId}`)
|
||||
if (!response.ok) return
|
||||
|
||||
const data = await response.json()
|
||||
const triggers: TriggerData[] = data.triggers.map((trigger: string) => {
|
||||
const metadata = getIntegrationMetadata(trigger)
|
||||
return {
|
||||
value: trigger,
|
||||
label: metadata.label,
|
||||
color: metadata.color,
|
||||
}
|
||||
})
|
||||
|
||||
setTriggersData(triggers)
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch triggers:', error)
|
||||
}
|
||||
}
|
||||
|
||||
fetchTriggers()
|
||||
}, [workspaceId])
|
||||
const triggersData = useMemo<TriggerData[]>(() => {
|
||||
return getTriggerOptions().map((t) => ({
|
||||
value: t.value,
|
||||
label: t.label,
|
||||
color: t.color,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const suggestionEngine = useMemo(() => {
|
||||
return new SearchSuggestions(workflowsData, foldersData, triggersData)
|
||||
@@ -103,7 +87,6 @@ export function AutocompleteSearch({
|
||||
suggestions,
|
||||
sections,
|
||||
highlightedIndex,
|
||||
highlightedBadgeIndex,
|
||||
inputRef,
|
||||
dropdownRef,
|
||||
handleInputChange,
|
||||
@@ -122,7 +105,6 @@ export function AutocompleteSearch({
|
||||
|
||||
const lastExternalValue = useRef(value)
|
||||
useEffect(() => {
|
||||
// Only re-initialize if value changed externally (not from user typing)
|
||||
if (value !== lastExternalValue.current) {
|
||||
lastExternalValue.current = value
|
||||
const parsed = parseQuery(value)
|
||||
@@ -130,7 +112,6 @@ export function AutocompleteSearch({
|
||||
}
|
||||
}, [value, initializeFromQuery])
|
||||
|
||||
// Initial sync on mount
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
const parsed = parseQuery(value)
|
||||
@@ -189,40 +170,49 @@ export function AutocompleteSearch({
|
||||
<div className='flex flex-1 items-center gap-[6px] overflow-x-auto pr-[6px] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
|
||||
{/* Applied Filter Badges */}
|
||||
{appliedFilters.map((filter, index) => (
|
||||
<Button
|
||||
<Badge
|
||||
key={`${filter.field}-${filter.value}-${index}`}
|
||||
variant='outline'
|
||||
className={cn(
|
||||
'h-6 flex-shrink-0 gap-1 rounded-[6px] px-2 text-[11px]',
|
||||
highlightedBadgeIndex === index && 'border'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
removeBadge(index)
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
className='h-6 shrink-0 cursor-pointer whitespace-nowrap rounded-md px-2 text-[11px]'
|
||||
onClick={() => removeBadge(index)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
removeBadge(index)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className='text-[var(--text-muted)]'>{filter.field}:</span>
|
||||
<span className='text-[var(--text-primary)]'>
|
||||
{filter.operator !== '=' && filter.operator}
|
||||
{filter.originalValue}
|
||||
{truncateFilterValue(filter.field, filter.originalValue)}
|
||||
</span>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
<X className='h-3 w-3 shrink-0' />
|
||||
</Badge>
|
||||
))}
|
||||
|
||||
{/* Text Search Badge (if present) */}
|
||||
{hasTextSearch && (
|
||||
<Button
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='h-6 flex-shrink-0 gap-1 rounded-[6px] px-2 text-[11px]'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleFiltersChange(appliedFilters, '')
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
className='h-6 shrink-0 cursor-pointer whitespace-nowrap rounded-md px-2 text-[11px]'
|
||||
onClick={() => handleFiltersChange(appliedFilters, '')}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleFiltersChange(appliedFilters, '')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className='text-[var(--text-primary)]'>"{textSearch}"</span>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
<span className='max-w-[150px] truncate text-[var(--text-primary)]'>
|
||||
"{textSearch}"
|
||||
</span>
|
||||
<X className='h-3 w-3 shrink-0' />
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Input - only current typing */}
|
||||
@@ -261,9 +251,8 @@ export function AutocompleteSearch({
|
||||
sideOffset={4}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className='max-h-96 overflow-y-auto'>
|
||||
<div className='max-h-96 overflow-y-auto px-1'>
|
||||
{sections.length > 0 ? (
|
||||
// Multi-section layout
|
||||
<div className='py-1'>
|
||||
{/* Show all results (no header) */}
|
||||
{suggestions[0]?.category === 'show-all' && (
|
||||
@@ -271,9 +260,9 @@ export function AutocompleteSearch({
|
||||
key={suggestions[0].id}
|
||||
data-index={0}
|
||||
className={cn(
|
||||
'w-full px-3 py-1.5 text-left transition-colors focus:outline-none',
|
||||
'hover:bg-[var(--surface-9)] dark:hover:bg-[var(--surface-9)]',
|
||||
highlightedIndex === 0 && 'bg-[var(--surface-9)] dark:bg-[var(--surface-9)]'
|
||||
'w-full rounded-[6px] px-3 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--border-focus)]',
|
||||
'hover:bg-[var(--surface-9)]',
|
||||
highlightedIndex === 0 && 'bg-[var(--surface-9)]'
|
||||
)}
|
||||
onMouseEnter={() => setHighlightedIndex(0)}
|
||||
onMouseDown={(e) => {
|
||||
@@ -287,7 +276,7 @@ export function AutocompleteSearch({
|
||||
|
||||
{sections.map((section) => (
|
||||
<div key={section.title}>
|
||||
<div className='border-[var(--divider)] border-t px-3 py-1.5 font-medium text-[11px] text-[var(--text-tertiary)] uppercase tracking-wide'>
|
||||
<div className='px-3 py-1.5 font-medium text-[12px] text-[var(--text-tertiary)] uppercase tracking-wide'>
|
||||
{section.title}
|
||||
</div>
|
||||
{section.suggestions.map((suggestion) => {
|
||||
@@ -301,9 +290,9 @@ export function AutocompleteSearch({
|
||||
key={suggestion.id}
|
||||
data-index={index}
|
||||
className={cn(
|
||||
'w-full px-3 py-1.5 text-left transition-colors focus:outline-none',
|
||||
'hover:bg-[var(--surface-9)] dark:hover:bg-[var(--surface-9)]',
|
||||
isHighlighted && 'bg-[var(--surface-9)] dark:bg-[var(--surface-9)]'
|
||||
'w-full rounded-[6px] px-3 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--border-focus)]',
|
||||
'hover:bg-[var(--surface-9)]',
|
||||
isHighlighted && 'bg-[var(--surface-9)]'
|
||||
)}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
onMouseDown={(e) => {
|
||||
@@ -312,19 +301,11 @@ export function AutocompleteSearch({
|
||||
}}
|
||||
>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-2'>
|
||||
{suggestion.category === 'trigger' && suggestion.color && (
|
||||
<div
|
||||
className='h-2 w-2 flex-shrink-0 rounded-full'
|
||||
style={{ backgroundColor: suggestion.color }}
|
||||
/>
|
||||
)}
|
||||
<div className='min-w-0 flex-1 truncate text-[13px]'>
|
||||
{suggestion.label}
|
||||
</div>
|
||||
<div className='min-w-0 flex-1 truncate text-[13px]'>
|
||||
{suggestion.label}
|
||||
</div>
|
||||
{suggestion.value !== suggestion.label && (
|
||||
<div className='flex-shrink-0 font-mono text-[11px] text-[var(--text-muted)]'>
|
||||
<div className='shrink-0 font-mono text-[11px] text-[var(--text-muted)]'>
|
||||
{suggestion.category === 'workflow' ||
|
||||
suggestion.category === 'folder'
|
||||
? `${suggestion.category}:`
|
||||
@@ -342,7 +323,7 @@ export function AutocompleteSearch({
|
||||
// Single section layout
|
||||
<div className='py-1'>
|
||||
{suggestionType === 'filters' && (
|
||||
<div className='border-[var(--divider)] border-b px-3 py-1.5 font-medium text-[11px] text-[var(--text-tertiary)] uppercase tracking-wide'>
|
||||
<div className='px-3 py-1.5 font-medium text-[12px] text-[var(--text-tertiary)] uppercase tracking-wide'>
|
||||
SUGGESTED FILTERS
|
||||
</div>
|
||||
)}
|
||||
@@ -352,10 +333,9 @@ export function AutocompleteSearch({
|
||||
key={suggestion.id}
|
||||
data-index={index}
|
||||
className={cn(
|
||||
'w-full px-3 py-1.5 text-left transition-colors focus:outline-none',
|
||||
'hover:bg-[var(--surface-9)] dark:hover:bg-[var(--surface-9)]',
|
||||
index === highlightedIndex &&
|
||||
'bg-[var(--surface-9)] dark:bg-[var(--surface-9)]'
|
||||
'w-full rounded-[6px] px-3 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--border-focus)]',
|
||||
'hover:bg-[var(--surface-9)]',
|
||||
index === highlightedIndex && 'bg-[var(--surface-9)]'
|
||||
)}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
onMouseDown={(e) => {
|
||||
@@ -364,17 +344,9 @@ export function AutocompleteSearch({
|
||||
}}
|
||||
>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-2'>
|
||||
{suggestion.category === 'trigger' && suggestion.color && (
|
||||
<div
|
||||
className='h-2 w-2 flex-shrink-0 rounded-full'
|
||||
style={{ backgroundColor: suggestion.color }}
|
||||
/>
|
||||
)}
|
||||
<div className='min-w-0 flex-1 text-[13px]'>{suggestion.label}</div>
|
||||
</div>
|
||||
<div className='min-w-0 flex-1 text-[13px]'>{suggestion.label}</div>
|
||||
{suggestion.description && (
|
||||
<div className='flex-shrink-0 text-[11px] text-[var(--text-muted)]'>
|
||||
<div className='shrink-0 text-[11px] text-[var(--text-muted)]'>
|
||||
{suggestion.value}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -21,7 +21,7 @@ import { useFolderStore } from '@/stores/folders/store'
|
||||
import { useFilterStore } from '@/stores/logs/filters/store'
|
||||
import { AutocompleteSearch } from './components/search'
|
||||
|
||||
const CORE_TRIGGER_TYPES = ['manual', 'api', 'schedule', 'chat', 'webhook'] as const
|
||||
const CORE_TRIGGER_TYPES = ['manual', 'api', 'schedule', 'chat', 'webhook', 'mcp'] as const
|
||||
|
||||
const TIME_RANGE_OPTIONS: ComboboxOption[] = [
|
||||
{ value: 'All time', label: 'All time' },
|
||||
|
||||
@@ -21,21 +21,15 @@ export function useSearchState({
|
||||
const [currentInput, setCurrentInput] = useState('')
|
||||
const [textSearch, setTextSearch] = useState('')
|
||||
|
||||
// Dropdown state
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [suggestions, setSuggestions] = useState<Suggestion[]>([])
|
||||
const [sections, setSections] = useState<SuggestionSection[]>([])
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
||||
|
||||
// Badge interaction
|
||||
const [highlightedBadgeIndex, setHighlightedBadgeIndex] = useState<number | null>(null)
|
||||
|
||||
// Refs
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const debounceRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// Update suggestions when input changes
|
||||
const updateSuggestions = useCallback(
|
||||
(input: string) => {
|
||||
const suggestionGroup = getSuggestions(input)
|
||||
@@ -55,13 +49,10 @@ export function useSearchState({
|
||||
[getSuggestions]
|
||||
)
|
||||
|
||||
// Handle input changes
|
||||
const handleInputChange = useCallback(
|
||||
(value: string) => {
|
||||
setCurrentInput(value)
|
||||
setHighlightedBadgeIndex(null) // Clear badge highlight on any input
|
||||
|
||||
// Debounce suggestion updates
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current)
|
||||
}
|
||||
@@ -73,11 +64,9 @@ export function useSearchState({
|
||||
[updateSuggestions, debounceMs]
|
||||
)
|
||||
|
||||
// Handle suggestion selection
|
||||
const handleSuggestionSelect = useCallback(
|
||||
(suggestion: Suggestion) => {
|
||||
if (suggestion.category === 'show-all') {
|
||||
// Treat as text search
|
||||
setTextSearch(suggestion.value)
|
||||
setCurrentInput('')
|
||||
setIsOpen(false)
|
||||
@@ -85,15 +74,12 @@ export function useSearchState({
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this is a filter-key suggestion (ends with ':')
|
||||
if (suggestion.category === 'filters' && suggestion.value.endsWith(':')) {
|
||||
// Set input to the filter key and keep dropdown open for values
|
||||
setCurrentInput(suggestion.value)
|
||||
updateSuggestions(suggestion.value)
|
||||
return
|
||||
}
|
||||
|
||||
// For filter values, workflows, folders - add as a filter
|
||||
const newFilter: ParsedFilter = {
|
||||
field: suggestion.value.split(':')[0] as any,
|
||||
operator: '=',
|
||||
@@ -110,15 +96,12 @@ export function useSearchState({
|
||||
setCurrentInput('')
|
||||
setTextSearch('')
|
||||
|
||||
// Notify parent
|
||||
onFiltersChange(updatedFilters, '')
|
||||
|
||||
// Focus back on input and reopen dropdown with empty suggestions
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
|
||||
// Show filter keys dropdown again after selection
|
||||
setTimeout(() => {
|
||||
updateSuggestions('')
|
||||
}, 50)
|
||||
@@ -126,12 +109,10 @@ export function useSearchState({
|
||||
[appliedFilters, onFiltersChange, updateSuggestions]
|
||||
)
|
||||
|
||||
// Remove a badge
|
||||
const removeBadge = useCallback(
|
||||
(index: number) => {
|
||||
const updatedFilters = appliedFilters.filter((_, i) => i !== index)
|
||||
setAppliedFilters(updatedFilters)
|
||||
setHighlightedBadgeIndex(null)
|
||||
onFiltersChange(updatedFilters, textSearch)
|
||||
|
||||
if (inputRef.current) {
|
||||
@@ -141,39 +122,22 @@ export function useSearchState({
|
||||
[appliedFilters, textSearch, onFiltersChange]
|
||||
)
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent) => {
|
||||
// Backspace on empty input - badge deletion
|
||||
if (event.key === 'Backspace' && currentInput === '') {
|
||||
event.preventDefault()
|
||||
|
||||
if (highlightedBadgeIndex !== null) {
|
||||
// Delete highlighted badge
|
||||
removeBadge(highlightedBadgeIndex)
|
||||
} else if (appliedFilters.length > 0) {
|
||||
// Highlight last badge
|
||||
setHighlightedBadgeIndex(appliedFilters.length - 1)
|
||||
if (appliedFilters.length > 0) {
|
||||
event.preventDefault()
|
||||
removeBadge(appliedFilters.length - 1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Clear badge highlight on any other key when not in dropdown navigation
|
||||
if (
|
||||
highlightedBadgeIndex !== null &&
|
||||
!['ArrowDown', 'ArrowUp', 'Enter'].includes(event.key)
|
||||
) {
|
||||
setHighlightedBadgeIndex(null)
|
||||
}
|
||||
|
||||
// Enter key
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
|
||||
if (isOpen && highlightedIndex >= 0 && suggestions[highlightedIndex]) {
|
||||
handleSuggestionSelect(suggestions[highlightedIndex])
|
||||
} else if (currentInput.trim()) {
|
||||
// Submit current input as text search
|
||||
setTextSearch(currentInput.trim())
|
||||
setCurrentInput('')
|
||||
setIsOpen(false)
|
||||
@@ -182,7 +146,6 @@ export function useSearchState({
|
||||
return
|
||||
}
|
||||
|
||||
// Dropdown navigation
|
||||
if (!isOpen) return
|
||||
|
||||
switch (event.key) {
|
||||
@@ -216,7 +179,6 @@ export function useSearchState({
|
||||
},
|
||||
[
|
||||
currentInput,
|
||||
highlightedBadgeIndex,
|
||||
appliedFilters,
|
||||
isOpen,
|
||||
highlightedIndex,
|
||||
@@ -227,12 +189,10 @@ export function useSearchState({
|
||||
]
|
||||
)
|
||||
|
||||
// Handle focus
|
||||
const handleFocus = useCallback(() => {
|
||||
updateSuggestions(currentInput)
|
||||
}, [currentInput, updateSuggestions])
|
||||
|
||||
// Handle blur
|
||||
const handleBlur = useCallback(() => {
|
||||
setTimeout(() => {
|
||||
setIsOpen(false)
|
||||
@@ -240,7 +200,6 @@ export function useSearchState({
|
||||
}, 150)
|
||||
}, [])
|
||||
|
||||
// Clear all filters
|
||||
const clearAll = useCallback(() => {
|
||||
setAppliedFilters([])
|
||||
setCurrentInput('')
|
||||
@@ -253,7 +212,6 @@ export function useSearchState({
|
||||
}
|
||||
}, [onFiltersChange])
|
||||
|
||||
// Initialize from external value (URL params, etc.)
|
||||
const initializeFromQuery = useCallback((query: string, filters: ParsedFilter[]) => {
|
||||
setAppliedFilters(filters)
|
||||
setTextSearch(query)
|
||||
@@ -261,7 +219,6 @@ export function useSearchState({
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// State
|
||||
appliedFilters,
|
||||
currentInput,
|
||||
textSearch,
|
||||
@@ -269,13 +226,10 @@ export function useSearchState({
|
||||
suggestions,
|
||||
sections,
|
||||
highlightedIndex,
|
||||
highlightedBadgeIndex,
|
||||
|
||||
// Refs
|
||||
inputRef,
|
||||
dropdownRef,
|
||||
|
||||
// Handlers
|
||||
handleInputChange,
|
||||
handleSuggestionSelect,
|
||||
handleKeyDown,
|
||||
@@ -285,7 +239,6 @@ export function useSearchState({
|
||||
clearAll,
|
||||
initializeFromQuery,
|
||||
|
||||
// Setters for external control
|
||||
setHighlightedIndex,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Badge } from '@/components/emcn'
|
||||
import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
|
||||
const CORE_TRIGGER_TYPES = ['manual', 'api', 'schedule', 'chat', 'webhook'] as const
|
||||
const CORE_TRIGGER_TYPES = ['manual', 'api', 'schedule', 'chat', 'webhook', 'mcp'] as const
|
||||
const RUNNING_COLOR = '#22c55e' as const
|
||||
const PENDING_COLOR = '#f59e0b' as const
|
||||
|
||||
|
||||
@@ -0,0 +1,861 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
AlertTriangle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Input as EmcnInput,
|
||||
Label,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverItem,
|
||||
PopoverTrigger,
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { generateToolInputSchema, sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
|
||||
import {
|
||||
useAddWorkflowMcpTool,
|
||||
useDeleteWorkflowMcpTool,
|
||||
useUpdateWorkflowMcpTool,
|
||||
useWorkflowMcpServers,
|
||||
useWorkflowMcpTools,
|
||||
type WorkflowMcpServer,
|
||||
type WorkflowMcpTool,
|
||||
} from '@/hooks/queries/workflow-mcp-servers'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
const logger = createLogger('McpToolDeploy')
|
||||
|
||||
interface McpToolDeployProps {
|
||||
workflowId: string
|
||||
workflowName: string
|
||||
workflowDescription?: string | null
|
||||
isDeployed: boolean
|
||||
onAddedToServer?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract input format from workflow blocks using SubBlockStore
|
||||
* The actual input format values are stored in useSubBlockStore, not directly in the block structure
|
||||
*/
|
||||
function extractInputFormat(
|
||||
blocks: Record<string, unknown>
|
||||
): Array<{ name: string; type: string }> {
|
||||
// Find the starter block
|
||||
for (const [blockId, block] of Object.entries(blocks)) {
|
||||
if (!block || typeof block !== 'object') continue
|
||||
|
||||
const blockObj = block as Record<string, unknown>
|
||||
const blockType = blockObj.type
|
||||
|
||||
// Check for all possible start/trigger block types
|
||||
if (
|
||||
blockType === 'starter' ||
|
||||
blockType === 'start' ||
|
||||
blockType === 'start_trigger' || // This is the unified start block type
|
||||
blockType === 'api' ||
|
||||
blockType === 'api_trigger' ||
|
||||
blockType === 'input_trigger'
|
||||
) {
|
||||
// Get the inputFormat value from the SubBlockStore (where the actual values are stored)
|
||||
const inputFormatValue = useSubBlockStore.getState().getValue(blockId, 'inputFormat')
|
||||
|
||||
if (Array.isArray(inputFormatValue) && inputFormatValue.length > 0) {
|
||||
return inputFormatValue
|
||||
.filter(
|
||||
(field: unknown): field is { name: string; type: string } =>
|
||||
field !== null &&
|
||||
typeof field === 'object' &&
|
||||
'name' in field &&
|
||||
typeof (field as { name: unknown }).name === 'string' &&
|
||||
(field as { name: string }).name.trim() !== ''
|
||||
)
|
||||
.map((field) => ({
|
||||
name: field.name.trim(),
|
||||
type: field.type || 'string',
|
||||
}))
|
||||
}
|
||||
|
||||
// Fallback: try to get from block's subBlocks structure (for backwards compatibility)
|
||||
const subBlocks = blockObj.subBlocks as Record<string, unknown> | undefined
|
||||
if (subBlocks?.inputFormat) {
|
||||
const inputFormatSubBlock = subBlocks.inputFormat as Record<string, unknown>
|
||||
const value = inputFormatSubBlock.value
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
return value
|
||||
.filter(
|
||||
(field: unknown): field is { name: string; type: string } =>
|
||||
field !== null &&
|
||||
typeof field === 'object' &&
|
||||
'name' in field &&
|
||||
typeof (field as { name: unknown }).name === 'string' &&
|
||||
(field as { name: string }).name.trim() !== ''
|
||||
)
|
||||
.map((field) => ({
|
||||
name: field.name.trim(),
|
||||
type: field.type || 'string',
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate JSON Schema from input format using the shared utility
|
||||
* Optionally applies custom descriptions from the UI
|
||||
*/
|
||||
function generateParameterSchema(
|
||||
inputFormat: Array<{ name: string; type: string }>,
|
||||
customDescriptions?: Record<string, string>
|
||||
): Record<string, unknown> {
|
||||
// Convert to InputFormatField with descriptions
|
||||
const fieldsWithDescriptions = inputFormat.map((field) => ({
|
||||
...field,
|
||||
description: customDescriptions?.[field.name]?.trim() || undefined,
|
||||
}))
|
||||
return generateToolInputSchema(fieldsWithDescriptions) as unknown as Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract parameter names from a tool's parameter schema
|
||||
*/
|
||||
function getToolParameterNames(schema: Record<string, unknown>): string[] {
|
||||
const properties = schema.properties as Record<string, unknown> | undefined
|
||||
if (!properties) return []
|
||||
return Object.keys(properties)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the tool's parameters differ from the current workflow's input format
|
||||
*/
|
||||
function hasParameterMismatch(
|
||||
tool: WorkflowMcpTool,
|
||||
currentInputFormat: Array<{ name: string; type: string }>
|
||||
): boolean {
|
||||
const toolParams = getToolParameterNames(tool.parameterSchema as Record<string, unknown>)
|
||||
const currentParams = currentInputFormat.map((f) => f.name)
|
||||
|
||||
if (toolParams.length !== currentParams.length) return true
|
||||
|
||||
const toolParamSet = new Set(toolParams)
|
||||
for (const param of currentParams) {
|
||||
if (!toolParamSet.has(param)) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Component to query tools for a single server and report back via callback.
|
||||
* This pattern avoids calling hooks in a loop.
|
||||
*/
|
||||
function ServerToolsQuery({
|
||||
workspaceId,
|
||||
server,
|
||||
workflowId,
|
||||
onData,
|
||||
}: {
|
||||
workspaceId: string
|
||||
server: WorkflowMcpServer
|
||||
workflowId: string
|
||||
onData: (serverId: string, tool: WorkflowMcpTool | null, isLoading: boolean) => void
|
||||
}) {
|
||||
const { data: tools, isLoading } = useWorkflowMcpTools(workspaceId, server.id)
|
||||
|
||||
useEffect(() => {
|
||||
const tool = tools?.find((t) => t.workflowId === workflowId) || null
|
||||
onData(server.id, tool, isLoading)
|
||||
}, [tools, isLoading, workflowId, server.id, onData])
|
||||
|
||||
return null // This component doesn't render anything
|
||||
}
|
||||
|
||||
interface ToolOnServerProps {
|
||||
server: WorkflowMcpServer
|
||||
tool: WorkflowMcpTool
|
||||
workspaceId: string
|
||||
currentInputFormat: Array<{ name: string; type: string }>
|
||||
currentParameterSchema: Record<string, unknown>
|
||||
workflowDescription: string | null | undefined
|
||||
onRemoved: (serverId: string) => void
|
||||
onUpdated: () => void
|
||||
}
|
||||
|
||||
function ToolOnServer({
|
||||
server,
|
||||
tool,
|
||||
workspaceId,
|
||||
currentInputFormat,
|
||||
currentParameterSchema,
|
||||
workflowDescription,
|
||||
onRemoved,
|
||||
onUpdated,
|
||||
}: ToolOnServerProps) {
|
||||
const deleteToolMutation = useDeleteWorkflowMcpTool()
|
||||
const updateToolMutation = useUpdateWorkflowMcpTool()
|
||||
const [showConfirm, setShowConfirm] = useState(false)
|
||||
const [showDetails, setShowDetails] = useState(false)
|
||||
|
||||
const needsUpdate = hasParameterMismatch(tool, currentInputFormat)
|
||||
const toolParams = getToolParameterNames(tool.parameterSchema as Record<string, unknown>)
|
||||
|
||||
const handleRemove = async () => {
|
||||
try {
|
||||
await deleteToolMutation.mutateAsync({
|
||||
workspaceId,
|
||||
serverId: server.id,
|
||||
toolId: tool.id,
|
||||
})
|
||||
onRemoved(server.id)
|
||||
} catch (error) {
|
||||
logger.error('Failed to remove tool:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdate = async () => {
|
||||
try {
|
||||
await updateToolMutation.mutateAsync({
|
||||
workspaceId,
|
||||
serverId: server.id,
|
||||
toolId: tool.id,
|
||||
toolDescription: workflowDescription || `Execute workflow`,
|
||||
parameterSchema: currentParameterSchema,
|
||||
})
|
||||
onUpdated()
|
||||
logger.info(`Updated tool ${tool.id} with new parameters`)
|
||||
} catch (error) {
|
||||
logger.error('Failed to update tool:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (showConfirm) {
|
||||
return (
|
||||
<div className='flex items-center justify-between rounded-[6px] border border-[var(--text-error)]/30 bg-[var(--surface-3)] px-[10px] py-[8px]'>
|
||||
<span className='text-[12px] text-[var(--text-secondary)]'>Remove from {server.name}?</span>
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => setShowConfirm(false)}
|
||||
className='h-[24px] px-[8px] text-[11px]'
|
||||
disabled={deleteToolMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={handleRemove}
|
||||
className='h-[24px] px-[8px] text-[11px] text-[var(--text-error)] hover:text-[var(--text-error)]'
|
||||
disabled={deleteToolMutation.isPending}
|
||||
>
|
||||
{deleteToolMutation.isPending ? 'Removing...' : 'Remove'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='rounded-[6px] border bg-[var(--surface-3)]'>
|
||||
<div
|
||||
className='flex cursor-pointer items-center justify-between px-[10px] py-[8px]'
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
{showDetails ? (
|
||||
<ChevronDown className='h-[12px] w-[12px] text-[var(--text-tertiary)]' />
|
||||
) : (
|
||||
<ChevronRight className='h-[12px] w-[12px] text-[var(--text-tertiary)]' />
|
||||
)}
|
||||
<span className='text-[13px] text-[var(--text-primary)]'>{server.name}</span>
|
||||
{server.isPublished && (
|
||||
<Badge variant='outline' className='text-[10px]'>
|
||||
Published
|
||||
</Badge>
|
||||
)}
|
||||
{needsUpdate && (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='border-amber-500/50 bg-amber-500/10 text-[10px] text-amber-500'
|
||||
>
|
||||
<AlertTriangle className='mr-[4px] h-[10px] w-[10px]' />
|
||||
Needs Update
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex items-center gap-[4px]' onClick={(e) => e.stopPropagation()}>
|
||||
{needsUpdate && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={handleUpdate}
|
||||
disabled={updateToolMutation.isPending}
|
||||
className='h-[24px] px-[8px] text-[11px] text-amber-500 hover:text-amber-600'
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
'mr-[4px] h-[10px] w-[10px]',
|
||||
updateToolMutation.isPending && 'animate-spin'
|
||||
)}
|
||||
/>
|
||||
{updateToolMutation.isPending ? 'Updating...' : 'Update'}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => setShowConfirm(true)}
|
||||
className='h-[24px] w-[24px] p-0 text-[var(--text-tertiary)] hover:text-[var(--text-error)]'
|
||||
>
|
||||
<Trash2 className='h-[12px] w-[12px]' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showDetails && (
|
||||
<div className='border-[var(--border)] border-t px-[10px] py-[8px]'>
|
||||
<div className='flex flex-col gap-[6px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-[11px] text-[var(--text-muted)]'>Tool Name</span>
|
||||
<span className='font-mono text-[11px] text-[var(--text-secondary)]'>
|
||||
{tool.toolName}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-start justify-between gap-[8px]'>
|
||||
<span className='flex-shrink-0 text-[11px] text-[var(--text-muted)]'>
|
||||
Description
|
||||
</span>
|
||||
<span className='text-right text-[11px] text-[var(--text-secondary)]'>
|
||||
{tool.toolDescription || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-start justify-between gap-[8px]'>
|
||||
<span className='flex-shrink-0 text-[11px] text-[var(--text-muted)]'>
|
||||
Parameters ({toolParams.length})
|
||||
</span>
|
||||
<div className='flex flex-wrap justify-end gap-[4px]'>
|
||||
{toolParams.length === 0 ? (
|
||||
<span className='text-[11px] text-[var(--text-muted)]'>None</span>
|
||||
) : (
|
||||
toolParams.map((param) => (
|
||||
<Badge key={param} variant='outline' className='text-[9px]'>
|
||||
{param}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function McpToolDeploy({
|
||||
workflowId,
|
||||
workflowName,
|
||||
workflowDescription,
|
||||
isDeployed,
|
||||
onAddedToServer,
|
||||
}: McpToolDeployProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
const {
|
||||
data: servers = [],
|
||||
isLoading: isLoadingServers,
|
||||
refetch: refetchServers,
|
||||
} = useWorkflowMcpServers(workspaceId)
|
||||
const addToolMutation = useAddWorkflowMcpTool()
|
||||
|
||||
// Get workflow blocks
|
||||
const blocks = useWorkflowStore((state) => state.blocks)
|
||||
|
||||
// Find the starter block ID to subscribe to its inputFormat changes
|
||||
const starterBlockId = useMemo(() => {
|
||||
for (const [blockId, block] of Object.entries(blocks)) {
|
||||
if (!block || typeof block !== 'object') continue
|
||||
const blockType = (block as { type?: string }).type
|
||||
// Check for all possible start/trigger block types
|
||||
if (
|
||||
blockType === 'starter' ||
|
||||
blockType === 'start' ||
|
||||
blockType === 'start_trigger' || // This is the unified start block type
|
||||
blockType === 'api' ||
|
||||
blockType === 'api_trigger' ||
|
||||
blockType === 'input_trigger'
|
||||
) {
|
||||
return blockId
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, [blocks])
|
||||
|
||||
// Subscribe to the inputFormat value in SubBlockStore for reactivity
|
||||
// Use workflowId prop directly (not activeWorkflowId from registry) to ensure we get the correct workflow's data
|
||||
const subBlockValues = useSubBlockStore((state) =>
|
||||
workflowId ? (state.workflowValues[workflowId] ?? {}) : {}
|
||||
)
|
||||
|
||||
// Extract and normalize input format - now reactive to SubBlockStore changes
|
||||
const inputFormat = useMemo(() => {
|
||||
// First try to get from SubBlockStore (where runtime values are stored)
|
||||
if (starterBlockId && subBlockValues[starterBlockId]) {
|
||||
const inputFormatValue = subBlockValues[starterBlockId].inputFormat
|
||||
|
||||
if (Array.isArray(inputFormatValue) && inputFormatValue.length > 0) {
|
||||
const filtered = inputFormatValue
|
||||
.filter(
|
||||
(field: unknown): field is { name: string; type: string } =>
|
||||
field !== null &&
|
||||
typeof field === 'object' &&
|
||||
'name' in field &&
|
||||
typeof (field as { name: unknown }).name === 'string' &&
|
||||
(field as { name: string }).name.trim() !== ''
|
||||
)
|
||||
.map((field) => ({
|
||||
name: field.name.trim(),
|
||||
type: field.type || 'string',
|
||||
}))
|
||||
if (filtered.length > 0) {
|
||||
return filtered
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try to get from block structure (for initial load or backwards compatibility)
|
||||
if (starterBlockId && blocks[starterBlockId]) {
|
||||
const startBlock = blocks[starterBlockId]
|
||||
const subBlocksValue = startBlock?.subBlocks?.inputFormat?.value as unknown
|
||||
|
||||
if (Array.isArray(subBlocksValue) && subBlocksValue.length > 0) {
|
||||
const validFields: Array<{ name: string; type: string }> = []
|
||||
for (const field of subBlocksValue) {
|
||||
if (
|
||||
field !== null &&
|
||||
typeof field === 'object' &&
|
||||
'name' in field &&
|
||||
typeof field.name === 'string' &&
|
||||
field.name.trim() !== ''
|
||||
) {
|
||||
validFields.push({
|
||||
name: field.name.trim(),
|
||||
type: typeof field.type === 'string' ? field.type : 'string',
|
||||
})
|
||||
}
|
||||
}
|
||||
if (validFields.length > 0) {
|
||||
return validFields
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Last fallback: use extractInputFormat helper
|
||||
return extractInputFormat(blocks)
|
||||
}, [starterBlockId, subBlockValues, blocks])
|
||||
|
||||
const [selectedServer, setSelectedServer] = useState<WorkflowMcpServer | null>(null)
|
||||
const [toolName, setToolName] = useState('')
|
||||
const [toolDescription, setToolDescription] = useState('')
|
||||
const [showServerSelector, setShowServerSelector] = useState(false)
|
||||
const [showParameterSchema, setShowParameterSchema] = useState(false)
|
||||
// Track custom descriptions for each parameter
|
||||
const [parameterDescriptions, setParameterDescriptions] = useState<Record<string, string>>({})
|
||||
|
||||
const parameterSchema = useMemo(
|
||||
() => generateParameterSchema(inputFormat, parameterDescriptions),
|
||||
[inputFormat, parameterDescriptions]
|
||||
)
|
||||
|
||||
// Track tools data from each server using state instead of hooks in a loop
|
||||
const [serverToolsMap, setServerToolsMap] = useState<
|
||||
Record<string, { tool: WorkflowMcpTool | null; isLoading: boolean }>
|
||||
>({})
|
||||
|
||||
// Stable callback to handle tool data from ServerToolsQuery components
|
||||
const handleServerToolData = useCallback(
|
||||
(serverId: string, tool: WorkflowMcpTool | null, isLoading: boolean) => {
|
||||
setServerToolsMap((prev) => {
|
||||
// Only update if data has changed to prevent infinite loops
|
||||
const existing = prev[serverId]
|
||||
if (existing?.tool?.id === tool?.id && existing?.isLoading === isLoading) {
|
||||
return prev
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
[serverId]: { tool, isLoading },
|
||||
}
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// Find which servers already have this workflow as a tool and get the tool info
|
||||
const serversWithThisWorkflow = useMemo(() => {
|
||||
const result: Array<{ server: WorkflowMcpServer; tool: WorkflowMcpTool }> = []
|
||||
for (const server of servers) {
|
||||
const toolInfo = serverToolsMap[server.id]
|
||||
if (toolInfo?.tool) {
|
||||
result.push({ server, tool: toolInfo.tool })
|
||||
}
|
||||
}
|
||||
return result
|
||||
}, [servers, serverToolsMap])
|
||||
|
||||
// Check if any tools need updating
|
||||
const toolsNeedingUpdate = useMemo(() => {
|
||||
return serversWithThisWorkflow.filter(({ tool }) => hasParameterMismatch(tool, inputFormat))
|
||||
}, [serversWithThisWorkflow, inputFormat])
|
||||
|
||||
// Load existing parameter descriptions from the first deployed tool
|
||||
useEffect(() => {
|
||||
if (serversWithThisWorkflow.length > 0) {
|
||||
const existingTool = serversWithThisWorkflow[0].tool
|
||||
const schema = existingTool.parameterSchema as Record<string, unknown> | undefined
|
||||
const properties = schema?.properties as Record<string, { description?: string }> | undefined
|
||||
|
||||
if (properties) {
|
||||
const descriptions: Record<string, string> = {}
|
||||
for (const [name, prop] of Object.entries(properties)) {
|
||||
// Only use description if it differs from the field name (i.e., it's custom)
|
||||
if (
|
||||
prop.description &&
|
||||
prop.description !== name &&
|
||||
prop.description !== 'Array of file objects'
|
||||
) {
|
||||
descriptions[name] = prop.description
|
||||
}
|
||||
}
|
||||
if (Object.keys(descriptions).length > 0) {
|
||||
setParameterDescriptions(descriptions)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [serversWithThisWorkflow])
|
||||
|
||||
// Reset form when selected server changes
|
||||
useEffect(() => {
|
||||
if (selectedServer) {
|
||||
setToolName(sanitizeToolName(workflowName))
|
||||
setToolDescription(workflowDescription || `Execute ${workflowName} workflow`)
|
||||
}
|
||||
}, [selectedServer, workflowName, workflowDescription])
|
||||
|
||||
const handleAddTool = useCallback(async () => {
|
||||
if (!selectedServer || !toolName.trim()) return
|
||||
|
||||
try {
|
||||
await addToolMutation.mutateAsync({
|
||||
workspaceId,
|
||||
serverId: selectedServer.id,
|
||||
workflowId,
|
||||
toolName: toolName.trim(),
|
||||
toolDescription: toolDescription.trim() || undefined,
|
||||
parameterSchema,
|
||||
})
|
||||
|
||||
setSelectedServer(null)
|
||||
setToolName('')
|
||||
setToolDescription('')
|
||||
|
||||
// Refetch servers to update tool count
|
||||
refetchServers()
|
||||
onAddedToServer?.()
|
||||
|
||||
logger.info(`Added workflow ${workflowId} as tool to server ${selectedServer.id}`)
|
||||
} catch (error) {
|
||||
logger.error('Failed to add tool:', error)
|
||||
}
|
||||
}, [
|
||||
selectedServer,
|
||||
toolName,
|
||||
toolDescription,
|
||||
workspaceId,
|
||||
workflowId,
|
||||
parameterSchema,
|
||||
addToolMutation,
|
||||
refetchServers,
|
||||
onAddedToServer,
|
||||
])
|
||||
|
||||
const handleToolChanged = useCallback(
|
||||
(removedServerId?: string) => {
|
||||
// If a tool was removed from a specific server, clear just that entry
|
||||
// The ServerToolsQuery component will re-query and update the map
|
||||
if (removedServerId) {
|
||||
setServerToolsMap((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[removedServerId]
|
||||
return next
|
||||
})
|
||||
}
|
||||
refetchServers()
|
||||
},
|
||||
[refetchServers]
|
||||
)
|
||||
|
||||
const availableServers = useMemo(() => {
|
||||
const addedServerIds = new Set(serversWithThisWorkflow.map((s) => s.server.id))
|
||||
return servers.filter((server) => !addedServerIds.has(server.id))
|
||||
}, [servers, serversWithThisWorkflow])
|
||||
|
||||
if (!isDeployed) {
|
||||
return (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-[12px] text-center'>
|
||||
<Server className='h-[32px] w-[32px] text-[var(--text-muted)]' />
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<p className='text-[14px] text-[var(--text-primary)]'>Deploy workflow first</p>
|
||||
<p className='text-[13px] text-[var(--text-muted)]'>
|
||||
You need to deploy your workflow before adding it as an MCP tool.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoadingServers) {
|
||||
return (
|
||||
<div className='flex flex-col gap-[16px]'>
|
||||
<Skeleton className='h-[60px] w-full' />
|
||||
<Skeleton className='h-[40px] w-full' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (servers.length === 0) {
|
||||
return (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-[12px] text-center'>
|
||||
<Server className='h-[32px] w-[32px] text-[var(--text-muted)]' />
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<p className='text-[14px] text-[var(--text-primary)]'>No MCP servers yet</p>
|
||||
<p className='text-[13px] text-[var(--text-muted)]'>
|
||||
Create a Workflow MCP Server in Settings → Workflow MCP Servers first.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-[16px]'>
|
||||
{/* Query tools for each server using separate components to follow Rules of Hooks */}
|
||||
{servers.map((server) => (
|
||||
<ServerToolsQuery
|
||||
key={server.id}
|
||||
workspaceId={workspaceId}
|
||||
server={server}
|
||||
workflowId={workflowId}
|
||||
onData={handleServerToolData}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<p className='text-[13px] text-[var(--text-secondary)]'>
|
||||
Add this workflow as an MCP tool to make it callable by external MCP clients like Cursor
|
||||
or Claude Desktop.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Update Warning */}
|
||||
{toolsNeedingUpdate.length > 0 && (
|
||||
<div className='flex items-center gap-[8px] rounded-[6px] border border-amber-500/30 bg-amber-500/10 px-[10px] py-[8px]'>
|
||||
<AlertTriangle className='h-[14px] w-[14px] flex-shrink-0 text-amber-500' />
|
||||
<p className='text-[12px] text-amber-600 dark:text-amber-400'>
|
||||
{toolsNeedingUpdate.length} server{toolsNeedingUpdate.length > 1 ? 's have' : ' has'}{' '}
|
||||
outdated tool definitions. Click "Update" on each to sync with current parameters.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Parameter Schema Preview */}
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setShowParameterSchema(!showParameterSchema)}
|
||||
className='flex items-center gap-[6px] text-left'
|
||||
>
|
||||
{showParameterSchema ? (
|
||||
<ChevronDown className='h-[12px] w-[12px] text-[var(--text-tertiary)]' />
|
||||
) : (
|
||||
<ChevronRight className='h-[12px] w-[12px] text-[var(--text-tertiary)]' />
|
||||
)}
|
||||
<Label className='cursor-pointer text-[13px] text-[var(--text-primary)]'>
|
||||
Current Tool Parameters ({inputFormat.length})
|
||||
</Label>
|
||||
</button>
|
||||
|
||||
{showParameterSchema && (
|
||||
<div className='rounded-[6px] border bg-[var(--surface-4)] p-[12px]'>
|
||||
{inputFormat.length === 0 ? (
|
||||
<p className='text-[12px] text-[var(--text-muted)]'>
|
||||
No parameters defined. Add input fields in the Starter block to define tool
|
||||
parameters.
|
||||
</p>
|
||||
) : (
|
||||
<div className='flex flex-col gap-[12px]'>
|
||||
{inputFormat.map((field, index) => (
|
||||
<div key={index} className='flex flex-col gap-[6px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='font-mono text-[12px] text-[var(--text-primary)]'>
|
||||
{field.name}
|
||||
</span>
|
||||
<Badge variant='outline' className='text-[10px]'>
|
||||
{field.type}
|
||||
</Badge>
|
||||
</div>
|
||||
<EmcnInput
|
||||
value={parameterDescriptions[field.name] || ''}
|
||||
onChange={(e) =>
|
||||
setParameterDescriptions((prev) => ({
|
||||
...prev,
|
||||
[field.name]: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={`Describe what "${field.name}" is for...`}
|
||||
className='h-[32px] text-[12px]'
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<p className='text-[11px] text-[var(--text-muted)]'>
|
||||
Descriptions help MCP clients understand what each parameter is for.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Servers with this workflow */}
|
||||
{serversWithThisWorkflow.length > 0 && (
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label className='text-[13px] text-[var(--text-primary)]'>
|
||||
Added to ({serversWithThisWorkflow.length})
|
||||
</Label>
|
||||
<div className='flex flex-col gap-[6px]'>
|
||||
{serversWithThisWorkflow.map(({ server, tool }) => (
|
||||
<ToolOnServer
|
||||
key={server.id}
|
||||
server={server}
|
||||
tool={tool}
|
||||
workspaceId={workspaceId}
|
||||
currentInputFormat={inputFormat}
|
||||
currentParameterSchema={parameterSchema}
|
||||
workflowDescription={workflowDescription}
|
||||
onRemoved={(serverId) => handleToolChanged(serverId)}
|
||||
onUpdated={() => handleToolChanged()}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add to new server */}
|
||||
{availableServers.length > 0 ? (
|
||||
<>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label className='text-[13px] text-[var(--text-primary)]'>Add to Server</Label>
|
||||
<Popover open={showServerSelector} onOpenChange={setShowServerSelector}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='default'
|
||||
className='h-[36px] w-full justify-between border bg-[var(--surface-3)]'
|
||||
>
|
||||
<span className={cn(!selectedServer && 'text-[var(--text-muted)]')}>
|
||||
{selectedServer?.name || 'Choose a server...'}
|
||||
</span>
|
||||
<ChevronDown className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side='bottom'
|
||||
align='start'
|
||||
sideOffset={4}
|
||||
className='w-[var(--radix-popover-trigger-width)]'
|
||||
border
|
||||
>
|
||||
{availableServers.map((server) => (
|
||||
<PopoverItem
|
||||
key={server.id}
|
||||
onClick={() => {
|
||||
setSelectedServer(server)
|
||||
setShowServerSelector(false)
|
||||
}}
|
||||
>
|
||||
<Server className='mr-[8px] h-[14px] w-[14px] text-[var(--text-tertiary)]' />
|
||||
<span>{server.name}</span>
|
||||
{server.isPublished && (
|
||||
<Badge variant='outline' className='ml-auto text-[10px]'>
|
||||
Published
|
||||
</Badge>
|
||||
)}
|
||||
</PopoverItem>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{selectedServer && (
|
||||
<>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label className='text-[13px] text-[var(--text-primary)]'>Tool Name</Label>
|
||||
<EmcnInput
|
||||
value={toolName}
|
||||
onChange={(e) => setToolName(e.target.value)}
|
||||
placeholder='e.g., book_flight'
|
||||
className='h-[36px]'
|
||||
/>
|
||||
<p className='text-[11px] text-[var(--text-muted)]'>
|
||||
Use lowercase letters, numbers, and underscores only.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label className='text-[13px] text-[var(--text-primary)]'>Description</Label>
|
||||
<EmcnInput
|
||||
value={toolDescription}
|
||||
onChange={(e) => setToolDescription(e.target.value)}
|
||||
placeholder='Describe what this tool does...'
|
||||
className='h-[36px]'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={handleAddTool}
|
||||
disabled={addToolMutation.isPending || !toolName.trim()}
|
||||
className='!bg-[var(--brand-tertiary-2)] !text-[var(--text-inverse)] hover:!bg-[var(--brand-tertiary-2)]/90'
|
||||
>
|
||||
<Plus className='mr-[6px] h-[14px] w-[14px]' />
|
||||
{addToolMutation.isPending ? 'Adding...' : 'Add to Server'}
|
||||
</Button>
|
||||
|
||||
{addToolMutation.isError && (
|
||||
<p className='text-[12px] text-[var(--text-error)]'>
|
||||
{addToolMutation.error?.message || 'Failed to add tool'}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : serversWithThisWorkflow.length > 0 ? (
|
||||
<p className='text-[13px] text-[var(--text-muted)]'>
|
||||
This workflow has been added to all available servers.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import { ApiDeploy } from './components/api/api'
|
||||
import { ChatDeploy, type ExistingChat } from './components/chat/chat'
|
||||
import { GeneralDeploy } from './components/general/general'
|
||||
import { McpToolDeploy } from './components/mcp-tool/mcp-tool'
|
||||
import { TemplateDeploy } from './components/template/template'
|
||||
|
||||
const logger = createLogger('DeployModal')
|
||||
@@ -49,7 +50,7 @@ interface WorkflowDeploymentInfo {
|
||||
needsRedeployment: boolean
|
||||
}
|
||||
|
||||
type TabView = 'general' | 'api' | 'chat' | 'template'
|
||||
type TabView = 'general' | 'api' | 'chat' | 'template' | 'mcp-tool'
|
||||
|
||||
export function DeployModal({
|
||||
open,
|
||||
@@ -552,6 +553,7 @@ export function DeployModal({
|
||||
<ModalTabsTrigger value='api'>API</ModalTabsTrigger>
|
||||
<ModalTabsTrigger value='chat'>Chat</ModalTabsTrigger>
|
||||
<ModalTabsTrigger value='template'>Template</ModalTabsTrigger>
|
||||
<ModalTabsTrigger value='mcp-tool'>MCP Tool</ModalTabsTrigger>
|
||||
</ModalTabsList>
|
||||
|
||||
<ModalBody className='min-h-0 flex-1'>
|
||||
@@ -610,6 +612,17 @@ export function DeployModal({
|
||||
/>
|
||||
)}
|
||||
</ModalTabsContent>
|
||||
|
||||
<ModalTabsContent value='mcp-tool'>
|
||||
{workflowId && (
|
||||
<McpToolDeploy
|
||||
workflowId={workflowId}
|
||||
workflowName={workflowMetadata?.name || 'Workflow'}
|
||||
workflowDescription={workflowMetadata?.description}
|
||||
isDeployed={isDeployed}
|
||||
/>
|
||||
)}
|
||||
</ModalTabsContent>
|
||||
</ModalBody>
|
||||
</ModalTabs>
|
||||
|
||||
|
||||
@@ -347,6 +347,13 @@ export function OAuthRequiredModal({
|
||||
return
|
||||
}
|
||||
|
||||
if (providerId === 'servicenow') {
|
||||
// Pass the current URL so we can redirect back after OAuth
|
||||
const returnUrl = encodeURIComponent(window.location.href)
|
||||
window.location.href = `/api/auth/servicenow/authorize?returnUrl=${returnUrl}`
|
||||
return
|
||||
}
|
||||
|
||||
await client.oauth2.link({
|
||||
providerId,
|
||||
callbackURL: window.location.href,
|
||||
|
||||
@@ -85,11 +85,11 @@ export function ShortInput({
|
||||
const persistSubBlockValueRef = useRef<(value: string) => void>(() => {})
|
||||
|
||||
const justPastedRef = useRef(false)
|
||||
|
||||
const webhookManagement = useWebhookManagement({
|
||||
blockId,
|
||||
triggerId: undefined,
|
||||
isPreview,
|
||||
useWebhookUrl,
|
||||
})
|
||||
|
||||
const wandHook = useWand({
|
||||
|
||||
@@ -18,12 +18,18 @@ interface McpTool {
|
||||
inputSchema?: any
|
||||
}
|
||||
|
||||
interface McpServer {
|
||||
id: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
interface StoredTool {
|
||||
type: 'mcp'
|
||||
title: string
|
||||
toolId: string
|
||||
params: {
|
||||
serverId: string
|
||||
serverUrl?: string
|
||||
toolName: string
|
||||
serverName: string
|
||||
}
|
||||
@@ -34,6 +40,7 @@ interface StoredTool {
|
||||
|
||||
interface McpToolsListProps {
|
||||
mcpTools: McpTool[]
|
||||
mcpServers?: McpServer[]
|
||||
searchQuery: string
|
||||
customFilter: (name: string, query: string) => number
|
||||
onToolSelect: (tool: StoredTool) => void
|
||||
@@ -45,6 +52,7 @@ interface McpToolsListProps {
|
||||
*/
|
||||
export function McpToolsList({
|
||||
mcpTools,
|
||||
mcpServers = [],
|
||||
searchQuery,
|
||||
customFilter,
|
||||
onToolSelect,
|
||||
@@ -59,44 +67,48 @@ export function McpToolsList({
|
||||
return (
|
||||
<>
|
||||
<PopoverSection>MCP Tools</PopoverSection>
|
||||
{filteredTools.map((mcpTool) => (
|
||||
<ToolCommand.Item
|
||||
key={mcpTool.id}
|
||||
value={mcpTool.name}
|
||||
onSelect={() => {
|
||||
if (disabled) return
|
||||
{filteredTools.map((mcpTool) => {
|
||||
const server = mcpServers.find((s) => s.id === mcpTool.serverId)
|
||||
return (
|
||||
<ToolCommand.Item
|
||||
key={mcpTool.id}
|
||||
value={mcpTool.name}
|
||||
onSelect={() => {
|
||||
if (disabled) return
|
||||
|
||||
const newTool: StoredTool = {
|
||||
type: 'mcp',
|
||||
title: mcpTool.name,
|
||||
toolId: mcpTool.id,
|
||||
params: {
|
||||
serverId: mcpTool.serverId,
|
||||
toolName: mcpTool.name,
|
||||
serverName: mcpTool.serverName,
|
||||
},
|
||||
isExpanded: true,
|
||||
usageControl: 'auto',
|
||||
schema: {
|
||||
...mcpTool.inputSchema,
|
||||
description: mcpTool.description,
|
||||
},
|
||||
}
|
||||
const newTool: StoredTool = {
|
||||
type: 'mcp',
|
||||
title: mcpTool.name,
|
||||
toolId: mcpTool.id,
|
||||
params: {
|
||||
serverId: mcpTool.serverId,
|
||||
serverUrl: server?.url,
|
||||
toolName: mcpTool.name,
|
||||
serverName: mcpTool.serverName,
|
||||
},
|
||||
isExpanded: true,
|
||||
usageControl: 'auto',
|
||||
schema: {
|
||||
...mcpTool.inputSchema,
|
||||
description: mcpTool.description,
|
||||
},
|
||||
}
|
||||
|
||||
onToolSelect(newTool)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='flex h-[15px] w-[15px] flex-shrink-0 items-center justify-center rounded'
|
||||
style={{ background: mcpTool.bgColor }}
|
||||
onToolSelect(newTool)
|
||||
}}
|
||||
>
|
||||
<IconComponent icon={mcpTool.icon} className='h-[11px] w-[11px] text-white' />
|
||||
</div>
|
||||
<span className='truncate' title={`${mcpTool.name} (${mcpTool.serverName})`}>
|
||||
{mcpTool.name}
|
||||
</span>
|
||||
</ToolCommand.Item>
|
||||
))}
|
||||
<div
|
||||
className='flex h-[15px] w-[15px] flex-shrink-0 items-center justify-center rounded'
|
||||
style={{ background: mcpTool.bgColor }}
|
||||
>
|
||||
<IconComponent icon={mcpTool.icon} className='h-[11px] w-[11px] text-white' />
|
||||
</div>
|
||||
<span className='truncate' title={`${mcpTool.name} (${mcpTool.serverName})`}>
|
||||
{mcpTool.name}
|
||||
</span>
|
||||
</ToolCommand.Item>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { Loader2, PlusIcon, WrenchIcon, XIcon } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Badge,
|
||||
Combobox,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
PopoverSearch,
|
||||
PopoverSection,
|
||||
PopoverTrigger,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { McpIcon } from '@/components/icons'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
@@ -55,9 +57,11 @@ import {
|
||||
type CustomTool as CustomToolDefinition,
|
||||
useCustomTools,
|
||||
} from '@/hooks/queries/custom-tools'
|
||||
import { useMcpServers } from '@/hooks/queries/mcp'
|
||||
import { useWorkflows } from '@/hooks/queries/workflows'
|
||||
import { useMcpTools } from '@/hooks/use-mcp-tools'
|
||||
import { getProviderFromModel, supportsToolUsageControl } from '@/providers/utils'
|
||||
import { useSettingsModalStore } from '@/stores/settings-modal/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import {
|
||||
formatParameterLabel,
|
||||
@@ -802,6 +806,66 @@ export function ToolInput({
|
||||
refreshTools,
|
||||
} = useMcpTools(workspaceId)
|
||||
|
||||
const { data: mcpServers = [], isLoading: mcpServersLoading } = useMcpServers(workspaceId)
|
||||
const openSettingsModal = useSettingsModalStore((state) => state.openModal)
|
||||
const mcpDataLoading = mcpLoading || mcpServersLoading
|
||||
|
||||
/**
|
||||
* Returns issue info for an MCP tool using shared validation logic.
|
||||
*/
|
||||
const getMcpToolIssue = useCallback(
|
||||
(tool: StoredTool) => {
|
||||
if (tool.type !== 'mcp') return null
|
||||
|
||||
const { getMcpToolIssue: validateTool } = require('@/lib/mcp/tool-validation')
|
||||
|
||||
return validateTool(
|
||||
{
|
||||
serverId: tool.params?.serverId as string,
|
||||
serverUrl: tool.params?.serverUrl as string | undefined,
|
||||
toolName: tool.params?.toolName as string,
|
||||
schema: tool.schema,
|
||||
},
|
||||
mcpServers.map((s) => ({
|
||||
id: s.id,
|
||||
url: s.url,
|
||||
connectionStatus: s.connectionStatus,
|
||||
lastError: s.lastError,
|
||||
})),
|
||||
mcpTools.map((t) => ({
|
||||
serverId: t.serverId,
|
||||
name: t.name,
|
||||
inputSchema: t.inputSchema,
|
||||
}))
|
||||
)
|
||||
},
|
||||
[mcpTools, mcpServers]
|
||||
)
|
||||
|
||||
const isMcpToolUnavailable = useCallback(
|
||||
(tool: StoredTool): boolean => {
|
||||
const { isToolUnavailable } = require('@/lib/mcp/tool-validation')
|
||||
return isToolUnavailable(getMcpToolIssue(tool))
|
||||
},
|
||||
[getMcpToolIssue]
|
||||
)
|
||||
|
||||
const hasMcpToolIssue = useCallback(
|
||||
(tool: StoredTool): boolean => {
|
||||
return getMcpToolIssue(tool) !== null
|
||||
},
|
||||
[getMcpToolIssue]
|
||||
)
|
||||
|
||||
// Filter out MCP tools from unavailable servers for the dropdown
|
||||
const availableMcpTools = useMemo(() => {
|
||||
return mcpTools.filter((mcpTool) => {
|
||||
const server = mcpServers.find((s) => s.id === mcpTool.serverId)
|
||||
// Only include tools from connected servers
|
||||
return server && server.connectionStatus === 'connected'
|
||||
})
|
||||
}, [mcpTools, mcpServers])
|
||||
|
||||
// Reset search query when popover opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -1849,9 +1913,10 @@ export function ToolInput({
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Display MCP tools */}
|
||||
{/* Display MCP tools (only from available servers) */}
|
||||
<McpToolsList
|
||||
mcpTools={mcpTools}
|
||||
mcpTools={availableMcpTools}
|
||||
mcpServers={mcpServers}
|
||||
searchQuery={searchQuery || ''}
|
||||
customFilter={customFilter}
|
||||
onToolSelect={handleMcpToolSelect}
|
||||
@@ -2040,9 +2105,46 @@ export function ToolInput({
|
||||
<span className='truncate font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
{isCustomTool ? customToolTitle : tool.title}
|
||||
</span>
|
||||
{isMcpTool &&
|
||||
!mcpDataLoading &&
|
||||
(() => {
|
||||
const issue = getMcpToolIssue(tool)
|
||||
if (!issue) return null
|
||||
const { getIssueBadgeLabel } = require('@/lib/mcp/tool-validation')
|
||||
const serverId = tool.params?.serverId
|
||||
return (
|
||||
<div
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
openSettingsModal({ section: 'mcp', mcpServerId: serverId })
|
||||
}}
|
||||
>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='cursor-pointer transition-colors hover:bg-[var(--warning)]/10'
|
||||
style={{
|
||||
borderColor: 'var(--warning)',
|
||||
color: 'var(--warning)',
|
||||
}}
|
||||
>
|
||||
{getIssueBadgeLabel(issue)}
|
||||
</Badge>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<span className='text-sm'>
|
||||
{issue.message} · Click to open settings
|
||||
</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
<div className='flex flex-shrink-0 items-center gap-[8px]'>
|
||||
{supportsToolControl && (
|
||||
{supportsToolControl && !(isMcpTool && isMcpToolUnavailable(tool)) && (
|
||||
<Popover
|
||||
open={usageControlPopoverIndex === toolIndex}
|
||||
onOpenChange={(open) =>
|
||||
@@ -2386,9 +2488,10 @@ export function ToolInput({
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Display MCP tools */}
|
||||
{/* Display MCP tools (only from available servers) */}
|
||||
<McpToolsList
|
||||
mcpTools={mcpTools}
|
||||
mcpTools={availableMcpTools}
|
||||
mcpServers={mcpServers}
|
||||
searchQuery={searchQuery || ''}
|
||||
customFilter={customFilter}
|
||||
onToolSelect={handleMcpToolSelect}
|
||||
|
||||
@@ -26,7 +26,7 @@ const SUBFLOW_CONFIG = {
|
||||
},
|
||||
typeKey: 'loopType' as const,
|
||||
storeKey: 'loops' as const,
|
||||
maxIterations: 100,
|
||||
maxIterations: 1000,
|
||||
configKeys: {
|
||||
iterations: 'iterations' as const,
|
||||
items: 'forEachItems' as const,
|
||||
|
||||
@@ -1741,7 +1741,7 @@ export function Terminal() {
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className='flex-1 overflow-x-auto overflow-y-auto'>
|
||||
<div className={clsx('flex-1 overflow-y-auto', !wrapText && 'overflow-x-auto')}>
|
||||
{shouldShowCodeDisplay ? (
|
||||
<OutputCodeContent
|
||||
code={selectedEntry.input.code}
|
||||
|
||||
@@ -252,23 +252,12 @@ export function useNodeUtilities(blocks: Record<string, any>) {
|
||||
*/
|
||||
const calculateLoopDimensions = useCallback(
|
||||
(nodeId: string): { width: number; height: number } => {
|
||||
const minWidth = CONTAINER_DIMENSIONS.DEFAULT_WIDTH
|
||||
const minHeight = CONTAINER_DIMENSIONS.DEFAULT_HEIGHT
|
||||
|
||||
// Match styling in subflow-node.tsx:
|
||||
// - Header section: 50px total height
|
||||
// - Content area: px-[16px] pb-[0px] pt-[16px] pr-[70px]
|
||||
// Left padding: 16px, Right padding: 64px, Top padding: 16px, Bottom padding: -6px (reduced by additional 6px from 0 to achieve 14px total reduction from original 8px)
|
||||
// - Children are positioned relative to the content area (after header, inside padding)
|
||||
const headerHeight = 50
|
||||
const leftPadding = 16
|
||||
const rightPadding = 80
|
||||
const topPadding = 16
|
||||
const bottomPadding = 16
|
||||
|
||||
const childNodes = getNodes().filter((node) => node.parentId === nodeId)
|
||||
if (childNodes.length === 0) {
|
||||
return { width: minWidth, height: minHeight }
|
||||
return {
|
||||
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
}
|
||||
}
|
||||
|
||||
let maxRight = 0
|
||||
@@ -276,21 +265,21 @@ export function useNodeUtilities(blocks: Record<string, any>) {
|
||||
|
||||
childNodes.forEach((node) => {
|
||||
const { width: nodeWidth, height: nodeHeight } = getBlockDimensions(node.id)
|
||||
|
||||
// Child positions are relative to content area's inner top-left (inside padding)
|
||||
// Calculate the rightmost and bottommost edges of children
|
||||
const rightEdge = node.position.x + nodeWidth
|
||||
const bottomEdge = node.position.y + nodeHeight
|
||||
|
||||
maxRight = Math.max(maxRight, rightEdge)
|
||||
maxBottom = Math.max(maxBottom, bottomEdge)
|
||||
maxRight = Math.max(maxRight, node.position.x + nodeWidth)
|
||||
maxBottom = Math.max(maxBottom, node.position.y + nodeHeight)
|
||||
})
|
||||
|
||||
// Container dimensions = header + padding + children bounds + padding
|
||||
// Width: left padding + max child right edge + right padding (64px)
|
||||
const width = Math.max(minWidth, leftPadding + maxRight + rightPadding)
|
||||
// Height: header + top padding + max child bottom edge + bottom padding (8px)
|
||||
const height = Math.max(minHeight, headerHeight + topPadding + maxBottom + bottomPadding)
|
||||
const width = Math.max(
|
||||
CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
CONTAINER_DIMENSIONS.LEFT_PADDING + maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING
|
||||
)
|
||||
const height = Math.max(
|
||||
CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
CONTAINER_DIMENSIONS.HEADER_HEIGHT +
|
||||
CONTAINER_DIMENSIONS.TOP_PADDING +
|
||||
maxBottom +
|
||||
CONTAINER_DIMENSIONS.BOTTOM_PADDING
|
||||
)
|
||||
|
||||
return { width, height }
|
||||
},
|
||||
|
||||
@@ -655,6 +655,7 @@ export function useWorkflowExecution() {
|
||||
setExecutor,
|
||||
setPendingBlocks,
|
||||
setActiveBlocks,
|
||||
workflows,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useShallow } from 'zustand/react/shallow'
|
||||
import type { OAuthConnectEventDetail } from '@/lib/copilot/tools/client/other/oauth-request-access'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { OAuthProvider } from '@/lib/oauth'
|
||||
import { CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
|
||||
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import {
|
||||
@@ -176,6 +177,7 @@ const WorkflowContent = React.memo(() => {
|
||||
resizeLoopNodes,
|
||||
updateNodeParent: updateNodeParentUtil,
|
||||
getNodeAnchorPosition,
|
||||
getBlockDimensions,
|
||||
} = useNodeUtilities(blocks)
|
||||
|
||||
/** Triggers immediate subflow resize without delays. */
|
||||
@@ -1501,6 +1503,66 @@ const WorkflowContent = React.memo(() => {
|
||||
// Only sync non-position changes (like selection) to store if needed
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Updates container dimensions in displayNodes during drag.
|
||||
* This allows live resizing of containers as their children are dragged.
|
||||
*/
|
||||
const updateContainerDimensionsDuringDrag = useCallback(
|
||||
(draggedNodeId: string, draggedNodePosition: { x: number; y: number }) => {
|
||||
const parentId = blocks[draggedNodeId]?.data?.parentId
|
||||
if (!parentId) return
|
||||
|
||||
setDisplayNodes((currentNodes) => {
|
||||
const childNodes = currentNodes.filter((n) => n.parentId === parentId)
|
||||
if (childNodes.length === 0) return currentNodes
|
||||
|
||||
let maxRight = 0
|
||||
let maxBottom = 0
|
||||
|
||||
childNodes.forEach((node) => {
|
||||
const nodePosition = node.id === draggedNodeId ? draggedNodePosition : node.position
|
||||
const { width: nodeWidth, height: nodeHeight } = getBlockDimensions(node.id)
|
||||
|
||||
maxRight = Math.max(maxRight, nodePosition.x + nodeWidth)
|
||||
maxBottom = Math.max(maxBottom, nodePosition.y + nodeHeight)
|
||||
})
|
||||
|
||||
const newWidth = Math.max(
|
||||
CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
CONTAINER_DIMENSIONS.LEFT_PADDING + maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING
|
||||
)
|
||||
const newHeight = Math.max(
|
||||
CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
CONTAINER_DIMENSIONS.HEADER_HEIGHT +
|
||||
CONTAINER_DIMENSIONS.TOP_PADDING +
|
||||
maxBottom +
|
||||
CONTAINER_DIMENSIONS.BOTTOM_PADDING
|
||||
)
|
||||
|
||||
return currentNodes.map((node) => {
|
||||
if (node.id === parentId) {
|
||||
const currentWidth = node.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH
|
||||
const currentHeight = node.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT
|
||||
|
||||
// Only update if dimensions changed
|
||||
if (newWidth !== currentWidth || newHeight !== currentHeight) {
|
||||
return {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
width: newWidth,
|
||||
height: newHeight,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
return node
|
||||
})
|
||||
})
|
||||
},
|
||||
[blocks, getBlockDimensions]
|
||||
)
|
||||
|
||||
/**
|
||||
* Effect to resize loops when nodes change (add/remove/position change).
|
||||
* Runs on structural changes only - not during drag (position-only changes).
|
||||
@@ -1681,6 +1743,11 @@ const WorkflowContent = React.memo(() => {
|
||||
// Get the current parent ID of the node being dragged
|
||||
const currentParentId = blocks[node.id]?.data?.parentId || null
|
||||
|
||||
// If the node is inside a container, update container dimensions during drag
|
||||
if (currentParentId) {
|
||||
updateContainerDimensionsDuringDrag(node.id, node.position)
|
||||
}
|
||||
|
||||
// Check if this is a starter block - starter blocks should never be in containers
|
||||
const isStarterBlock = node.data?.type === 'starter'
|
||||
if (isStarterBlock) {
|
||||
@@ -1812,7 +1879,14 @@ const WorkflowContent = React.memo(() => {
|
||||
}
|
||||
}
|
||||
},
|
||||
[getNodes, potentialParentId, blocks, getNodeAbsolutePosition, getNodeDepth]
|
||||
[
|
||||
getNodes,
|
||||
potentialParentId,
|
||||
blocks,
|
||||
getNodeAbsolutePosition,
|
||||
getNodeDepth,
|
||||
updateContainerDimensionsDuringDrag,
|
||||
]
|
||||
)
|
||||
|
||||
/** Captures initial parent ID and position when drag starts. */
|
||||
|
||||
@@ -423,7 +423,21 @@ export function SearchModal({
|
||||
}
|
||||
break
|
||||
case 'workspace':
|
||||
if (item.isCurrent) {
|
||||
break
|
||||
}
|
||||
if (item.href) {
|
||||
router.push(item.href)
|
||||
}
|
||||
break
|
||||
case 'workflow':
|
||||
if (!item.isCurrent && item.href) {
|
||||
router.push(item.href)
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: item.id } })
|
||||
)
|
||||
}
|
||||
break
|
||||
case 'page':
|
||||
case 'doc':
|
||||
if (item.href) {
|
||||
@@ -431,12 +445,6 @@ export function SearchModal({
|
||||
window.open(item.href, '_blank', 'noopener,noreferrer')
|
||||
} else {
|
||||
router.push(item.href)
|
||||
// Scroll to the workflow in the sidebar after navigation
|
||||
if (item.type === 'workflow') {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: item.id } })
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
@@ -9,3 +9,4 @@ export { MCP } from './mcp/mcp'
|
||||
export { SSO } from './sso/sso'
|
||||
export { Subscription } from './subscription/subscription'
|
||||
export { TeamManagement } from './team-management/team-management'
|
||||
export { WorkflowMcpServers } from './workflow-mcp-servers/workflow-mcp-servers'
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { Button } from '@/components/emcn'
|
||||
|
||||
/**
|
||||
* Formats transport type for display (e.g., "streamable-http" -> "Streamable-HTTP").
|
||||
*/
|
||||
export function formatTransportLabel(transport: string): string {
|
||||
return transport
|
||||
.split('-')
|
||||
@@ -14,10 +11,10 @@ export function formatTransportLabel(transport: string): string {
|
||||
.join('-')
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats tools count and names for display.
|
||||
*/
|
||||
function formatToolsLabel(tools: any[]): string {
|
||||
function formatToolsLabel(tools: any[], connectionStatus?: string): string {
|
||||
if (connectionStatus === 'error') {
|
||||
return 'Unable to connect'
|
||||
}
|
||||
const count = tools.length
|
||||
const plural = count !== 1 ? 's' : ''
|
||||
const names = count > 0 ? `: ${tools.map((t) => t.name).join(', ')}` : ''
|
||||
@@ -29,35 +26,41 @@ interface ServerListItemProps {
|
||||
tools: any[]
|
||||
isDeleting: boolean
|
||||
isLoadingTools?: boolean
|
||||
isRefreshing?: boolean
|
||||
onRemove: () => void
|
||||
onViewDetails: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single MCP server list item with details and delete actions.
|
||||
*/
|
||||
export function ServerListItem({
|
||||
server,
|
||||
tools,
|
||||
isDeleting,
|
||||
isLoadingTools = false,
|
||||
isRefreshing = false,
|
||||
onRemove,
|
||||
onViewDetails,
|
||||
}: ServerListItemProps) {
|
||||
const transportLabel = formatTransportLabel(server.transport || 'http')
|
||||
const toolsLabel = formatToolsLabel(tools)
|
||||
const toolsLabel = formatToolsLabel(tools, server.connectionStatus)
|
||||
const isError = server.connectionStatus === 'error'
|
||||
|
||||
return (
|
||||
<div className='flex items-center justify-between gap-[12px]'>
|
||||
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<span className='max-w-[280px] truncate font-medium text-[14px]'>
|
||||
<span className='max-w-[200px] truncate font-medium text-[14px]'>
|
||||
{server.name || 'Unnamed Server'}
|
||||
</span>
|
||||
<span className='text-[13px] text-[var(--text-secondary)]'>({transportLabel})</span>
|
||||
</div>
|
||||
<p className='truncate text-[13px] text-[var(--text-muted)]'>
|
||||
{isLoadingTools && tools.length === 0 ? 'Loading...' : toolsLabel}
|
||||
<p
|
||||
className={`truncate text-[13px] ${isError ? 'text-red-500 dark:text-red-400' : 'text-[var(--text-muted)]'}`}
|
||||
>
|
||||
{isRefreshing
|
||||
? 'Refreshing...'
|
||||
: isLoadingTools && tools.length === 0
|
||||
? 'Loading...'
|
||||
: toolsLabel}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-shrink-0 items-center gap-[4px]'>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Plus, Search } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Input as EmcnInput,
|
||||
Modal,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
} from '@/components/emcn'
|
||||
import { Input } from '@/components/ui'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getIssueBadgeLabel, getMcpToolIssue, type McpToolIssue } from '@/lib/mcp/tool-validation'
|
||||
import { checkEnvVarTrigger } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown'
|
||||
import {
|
||||
useCreateMcpServer,
|
||||
@@ -21,6 +23,7 @@ import {
|
||||
useMcpServers,
|
||||
useMcpToolsQuery,
|
||||
useRefreshMcpServer,
|
||||
useStoredMcpTools,
|
||||
} from '@/hooks/queries/mcp'
|
||||
import { useMcpServerTest } from '@/hooks/use-mcp-server-test'
|
||||
import type { InputFieldType, McpServerFormData, McpServerTestResult } from './components'
|
||||
@@ -44,6 +47,9 @@ interface McpServer {
|
||||
name?: string
|
||||
transport?: string
|
||||
url?: string
|
||||
connectionStatus?: 'connected' | 'disconnected' | 'error'
|
||||
lastError?: string | null
|
||||
lastConnected?: string
|
||||
}
|
||||
|
||||
const logger = createLogger('McpSettings')
|
||||
@@ -69,11 +75,15 @@ function getTestButtonLabel(
|
||||
return 'Test Connection'
|
||||
}
|
||||
|
||||
interface MCPProps {
|
||||
initialServerId?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP Settings component for managing Model Context Protocol servers.
|
||||
* Handles server CRUD operations, connection testing, and environment variable integration.
|
||||
*/
|
||||
export function MCP() {
|
||||
export function MCP({ initialServerId }: MCPProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
@@ -88,6 +98,7 @@ export function MCP() {
|
||||
isLoading: toolsLoading,
|
||||
isFetching: toolsFetching,
|
||||
} = useMcpToolsQuery(workspaceId)
|
||||
const { data: storedTools = [] } = useStoredMcpTools(workspaceId)
|
||||
const createServerMutation = useCreateMcpServer()
|
||||
const deleteServerMutation = useDeleteMcpServer()
|
||||
const refreshServerMutation = useRefreshMcpServer()
|
||||
@@ -106,7 +117,9 @@ export function MCP() {
|
||||
const [serverToDelete, setServerToDelete] = useState<{ id: string; name: string } | null>(null)
|
||||
|
||||
const [selectedServerId, setSelectedServerId] = useState<string | null>(null)
|
||||
const [refreshStatus, setRefreshStatus] = useState<'idle' | 'refreshing' | 'refreshed'>('idle')
|
||||
const [refreshingServers, setRefreshingServers] = useState<
|
||||
Record<string, 'refreshing' | 'refreshed'>
|
||||
>({})
|
||||
|
||||
const [showEnvVars, setShowEnvVars] = useState(false)
|
||||
const [envSearchTerm, setEnvSearchTerm] = useState('')
|
||||
@@ -114,10 +127,16 @@ export function MCP() {
|
||||
const [activeInputField, setActiveInputField] = useState<InputFieldType | null>(null)
|
||||
const [activeHeaderIndex, setActiveHeaderIndex] = useState<number | null>(null)
|
||||
|
||||
// Scroll position state for formatted text overlays
|
||||
const [urlScrollLeft, setUrlScrollLeft] = useState(0)
|
||||
const [headerScrollLeft, setHeaderScrollLeft] = useState<Record<string, number>>({})
|
||||
|
||||
// Auto-select server when initialServerId is provided
|
||||
useEffect(() => {
|
||||
if (initialServerId && servers.some((s) => s.id === initialServerId)) {
|
||||
setSelectedServerId(initialServerId)
|
||||
}
|
||||
}, [initialServerId, servers])
|
||||
|
||||
/**
|
||||
* Resets environment variable dropdown state.
|
||||
*/
|
||||
@@ -237,6 +256,7 @@ export function MCP() {
|
||||
|
||||
/**
|
||||
* Adds a new MCP server after validating and testing the connection.
|
||||
* Only creates the server if connection test succeeds.
|
||||
*/
|
||||
const handleAddServer = useCallback(async () => {
|
||||
if (!formData.name.trim()) return
|
||||
@@ -253,12 +273,12 @@ export function MCP() {
|
||||
workspaceId,
|
||||
}
|
||||
|
||||
if (!testResult) {
|
||||
const result = await testConnection(serverConfig)
|
||||
if (!result.success) return
|
||||
}
|
||||
const connectionResult = await testConnection(serverConfig)
|
||||
|
||||
if (testResult && !testResult.success) return
|
||||
if (!connectionResult.success) {
|
||||
logger.error('Connection test failed, server not added:', connectionResult.error)
|
||||
return
|
||||
}
|
||||
|
||||
await createServerMutation.mutateAsync({
|
||||
workspaceId,
|
||||
@@ -279,15 +299,7 @@ export function MCP() {
|
||||
} finally {
|
||||
setIsAddingServer(false)
|
||||
}
|
||||
}, [
|
||||
formData,
|
||||
testResult,
|
||||
testConnection,
|
||||
createServerMutation,
|
||||
workspaceId,
|
||||
headersToRecord,
|
||||
resetForm,
|
||||
])
|
||||
}, [formData, testConnection, createServerMutation, workspaceId, headersToRecord, resetForm])
|
||||
|
||||
/**
|
||||
* Opens the delete confirmation dialog for an MCP server.
|
||||
@@ -297,9 +309,6 @@ export function MCP() {
|
||||
setShowDeleteDialog(true)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Confirms and executes the server deletion.
|
||||
*/
|
||||
const confirmDeleteServer = useCallback(async () => {
|
||||
if (!serverToDelete) return
|
||||
|
||||
@@ -399,14 +408,24 @@ export function MCP() {
|
||||
const handleRefreshServer = useCallback(
|
||||
async (serverId: string) => {
|
||||
try {
|
||||
setRefreshStatus('refreshing')
|
||||
setRefreshingServers((prev) => ({ ...prev, [serverId]: 'refreshing' }))
|
||||
await refreshServerMutation.mutateAsync({ workspaceId, serverId })
|
||||
logger.info(`Refreshed MCP server: ${serverId}`)
|
||||
setRefreshStatus('refreshed')
|
||||
setTimeout(() => setRefreshStatus('idle'), 2000)
|
||||
setRefreshingServers((prev) => ({ ...prev, [serverId]: 'refreshed' }))
|
||||
setTimeout(() => {
|
||||
setRefreshingServers((prev) => {
|
||||
const newState = { ...prev }
|
||||
delete newState[serverId]
|
||||
return newState
|
||||
})
|
||||
}, 2000)
|
||||
} catch (error) {
|
||||
logger.error('Failed to refresh MCP server:', error)
|
||||
setRefreshStatus('idle')
|
||||
setRefreshingServers((prev) => {
|
||||
const newState = { ...prev }
|
||||
delete newState[serverId]
|
||||
return newState
|
||||
})
|
||||
}
|
||||
},
|
||||
[refreshServerMutation, workspaceId]
|
||||
@@ -432,6 +451,53 @@ export function MCP() {
|
||||
const isSubmitDisabled = serversLoading || isAddingServer || !isFormValid
|
||||
const testButtonLabel = getTestButtonLabel(testResult, isTestingConnection)
|
||||
|
||||
/**
|
||||
* Gets issues for stored tools that reference a specific server tool.
|
||||
* Returns issues from all workflows that have stored this tool.
|
||||
*/
|
||||
const getStoredToolIssues = useCallback(
|
||||
(serverId: string, toolName: string): { issue: McpToolIssue; workflowName: string }[] => {
|
||||
const relevantStoredTools = storedTools.filter(
|
||||
(st) => st.serverId === serverId && st.toolName === toolName
|
||||
)
|
||||
|
||||
const serverStates = servers.map((s) => ({
|
||||
id: s.id,
|
||||
url: s.url,
|
||||
connectionStatus: s.connectionStatus,
|
||||
lastError: s.lastError || undefined,
|
||||
}))
|
||||
|
||||
const discoveredTools = mcpToolsData.map((t) => ({
|
||||
serverId: t.serverId,
|
||||
name: t.name,
|
||||
inputSchema: t.inputSchema,
|
||||
}))
|
||||
|
||||
const issues: { issue: McpToolIssue; workflowName: string }[] = []
|
||||
|
||||
for (const storedTool of relevantStoredTools) {
|
||||
const issue = getMcpToolIssue(
|
||||
{
|
||||
serverId: storedTool.serverId,
|
||||
serverUrl: storedTool.serverUrl,
|
||||
toolName: storedTool.toolName,
|
||||
schema: storedTool.schema,
|
||||
},
|
||||
serverStates,
|
||||
discoveredTools
|
||||
)
|
||||
|
||||
if (issue) {
|
||||
issues.push({ issue, workflowName: storedTool.workflowName })
|
||||
}
|
||||
}
|
||||
|
||||
return issues
|
||||
},
|
||||
[storedTools, servers, mcpToolsData]
|
||||
)
|
||||
|
||||
if (selectedServer) {
|
||||
const { server, tools } = selectedServer
|
||||
const transportLabel = formatTransportLabel(server.transport || 'http')
|
||||
@@ -463,6 +529,15 @@ export function MCP() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{server.connectionStatus === 'error' && (
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<span className='font-medium text-[13px] text-[var(--text-primary)]'>Status</span>
|
||||
<p className='text-[14px] text-red-500 dark:text-red-400'>
|
||||
{server.lastError || 'Unable to connect'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Tools ({tools.length})
|
||||
@@ -471,21 +546,37 @@ export function MCP() {
|
||||
<p className='text-[13px] text-[var(--text-muted)]'>No tools available</p>
|
||||
) : (
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
{tools.map((tool) => (
|
||||
<div
|
||||
key={tool.name}
|
||||
className='rounded-[6px] border bg-[var(--surface-3)] px-[10px] py-[8px]'
|
||||
>
|
||||
<p className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
{tool.name}
|
||||
</p>
|
||||
{tool.description && (
|
||||
<p className='mt-[4px] text-[13px] text-[var(--text-tertiary)]'>
|
||||
{tool.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{tools.map((tool) => {
|
||||
const issues = getStoredToolIssues(server.id, tool.name)
|
||||
return (
|
||||
<div
|
||||
key={tool.name}
|
||||
className='rounded-[6px] border bg-[var(--surface-3)] px-[10px] py-[8px]'
|
||||
>
|
||||
<div className='flex items-center justify-between'>
|
||||
<p className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
{tool.name}
|
||||
</p>
|
||||
{issues.length > 0 && (
|
||||
<Badge
|
||||
variant='outline'
|
||||
style={{
|
||||
borderColor: 'var(--warning)',
|
||||
color: 'var(--warning)',
|
||||
}}
|
||||
>
|
||||
{getIssueBadgeLabel(issues[0].issue)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{tool.description && (
|
||||
<p className='mt-[4px] text-[13px] text-[var(--text-tertiary)]'>
|
||||
{tool.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -496,11 +587,11 @@ export function MCP() {
|
||||
<Button
|
||||
onClick={() => handleRefreshServer(server.id)}
|
||||
variant='default'
|
||||
disabled={refreshStatus !== 'idle'}
|
||||
disabled={!!refreshingServers[server.id]}
|
||||
>
|
||||
{refreshStatus === 'refreshing'
|
||||
{refreshingServers[server.id] === 'refreshing'
|
||||
? 'Refreshing...'
|
||||
: refreshStatus === 'refreshed'
|
||||
: refreshingServers[server.id] === 'refreshed'
|
||||
? 'Refreshed'
|
||||
: 'Refresh Tools'}
|
||||
</Button>
|
||||
@@ -672,6 +763,7 @@ export function MCP() {
|
||||
tools={tools}
|
||||
isDeleting={deletingServers.has(server.id)}
|
||||
isLoadingTools={isLoadingTools}
|
||||
isRefreshing={refreshingServers[server.id] === 'refreshing'}
|
||||
onRemove={() => handleRemoveServer(server.id, server.name || 'this server')}
|
||||
onViewDetails={() => handleViewDetails(server.id)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { Check, ChevronLeft, Clipboard, Globe, Plus, Search, Server, Trash2 } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Input as EmcnInput,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
} from '@/components/emcn'
|
||||
import { Input, Skeleton } from '@/components/ui'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
useCreateWorkflowMcpServer,
|
||||
useDeleteWorkflowMcpServer,
|
||||
useDeleteWorkflowMcpTool,
|
||||
usePublishWorkflowMcpServer,
|
||||
useUnpublishWorkflowMcpServer,
|
||||
useWorkflowMcpServer,
|
||||
useWorkflowMcpServers,
|
||||
type WorkflowMcpServer,
|
||||
type WorkflowMcpTool,
|
||||
} from '@/hooks/queries/workflow-mcp-servers'
|
||||
|
||||
const logger = createLogger('WorkflowMcpServers')
|
||||
|
||||
function ServerSkeleton() {
|
||||
return (
|
||||
<div className='flex items-center justify-between gap-[12px] rounded-[8px] border bg-[var(--surface-3)] p-[12px]'>
|
||||
<div className='flex min-w-0 flex-col justify-center gap-[4px]'>
|
||||
<Skeleton className='h-[14px] w-[120px]' />
|
||||
<Skeleton className='h-[12px] w-[80px]' />
|
||||
</div>
|
||||
<Skeleton className='h-[28px] w-[60px] rounded-[4px]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ServerListItemProps {
|
||||
server: WorkflowMcpServer
|
||||
onViewDetails: () => void
|
||||
onDelete: () => void
|
||||
isDeleting: boolean
|
||||
}
|
||||
|
||||
function ServerListItem({ server, onViewDetails, onDelete, isDeleting }: ServerListItemProps) {
|
||||
return (
|
||||
<div
|
||||
className='flex items-center justify-between gap-[12px] rounded-[8px] border bg-[var(--surface-3)] p-[12px] transition-colors hover:bg-[var(--surface-4)]'
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={onViewDetails}
|
||||
onKeyDown={(e) => e.key === 'Enter' && onViewDetails()}
|
||||
>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[10px]'>
|
||||
<Server className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-tertiary)]' />
|
||||
<div className='flex min-w-0 flex-col gap-[2px]'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<span className='truncate font-medium text-[14px] text-[var(--text-primary)]'>
|
||||
{server.name}
|
||||
</span>
|
||||
{server.isPublished && (
|
||||
<Badge variant='outline' className='flex-shrink-0 text-[10px]'>
|
||||
<Globe className='mr-[4px] h-[10px] w-[10px]' />
|
||||
Published
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
{server.toolCount || 0} tool{(server.toolCount || 0) !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
className='h-[28px] px-[8px]'
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ServerDetailViewProps {
|
||||
workspaceId: string
|
||||
serverId: string
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewProps) {
|
||||
const { data, isLoading, error } = useWorkflowMcpServer(workspaceId, serverId)
|
||||
const publishMutation = usePublishWorkflowMcpServer()
|
||||
const unpublishMutation = useUnpublishWorkflowMcpServer()
|
||||
const deleteToolMutation = useDeleteWorkflowMcpTool()
|
||||
const [copiedUrl, setCopiedUrl] = useState(false)
|
||||
const [toolToDelete, setToolToDelete] = useState<WorkflowMcpTool | null>(null)
|
||||
|
||||
const mcpServerUrl = useMemo(() => {
|
||||
if (!data?.server?.isPublished) return null
|
||||
return `${getBaseUrl()}/api/mcp/serve/${serverId}/sse`
|
||||
}, [data?.server?.isPublished, serverId])
|
||||
|
||||
const handlePublish = async () => {
|
||||
try {
|
||||
await publishMutation.mutateAsync({ workspaceId, serverId })
|
||||
} catch (error) {
|
||||
logger.error('Failed to publish server:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnpublish = async () => {
|
||||
try {
|
||||
await unpublishMutation.mutateAsync({ workspaceId, serverId })
|
||||
} catch (error) {
|
||||
logger.error('Failed to unpublish server:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyUrl = () => {
|
||||
if (mcpServerUrl) {
|
||||
navigator.clipboard.writeText(mcpServerUrl)
|
||||
setCopiedUrl(true)
|
||||
setTimeout(() => setCopiedUrl(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteTool = async () => {
|
||||
if (!toolToDelete) return
|
||||
try {
|
||||
await deleteToolMutation.mutateAsync({
|
||||
workspaceId,
|
||||
serverId,
|
||||
toolId: toolToDelete.id,
|
||||
})
|
||||
setToolToDelete(null)
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete tool:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='flex h-full flex-col gap-[16px]'>
|
||||
<Skeleton className='h-[24px] w-[200px]' />
|
||||
<Skeleton className='h-[100px] w-full' />
|
||||
<Skeleton className='h-[150px] w-full' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-[8px]'>
|
||||
<p className='text-[13px] text-[var(--text-error)]'>Failed to load server details</p>
|
||||
<Button variant='default' onClick={onBack}>
|
||||
Go Back
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { server, tools } = data
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex h-full flex-col gap-[16px]'>
|
||||
<div className='min-h-0 flex-1 overflow-y-auto'>
|
||||
<div className='flex flex-col gap-[16px]'>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Server Name
|
||||
</span>
|
||||
<p className='text-[14px] text-[var(--text-secondary)]'>{server.name}</p>
|
||||
</div>
|
||||
|
||||
{server.description && (
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Description
|
||||
</span>
|
||||
<p className='text-[14px] text-[var(--text-secondary)]'>{server.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<span className='font-medium text-[13px] text-[var(--text-primary)]'>Status</span>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
{server.isPublished ? (
|
||||
<>
|
||||
<Badge variant='outline' className='text-[12px]'>
|
||||
<Globe className='mr-[4px] h-[12px] w-[12px]' />
|
||||
Published
|
||||
</Badge>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={handleUnpublish}
|
||||
disabled={unpublishMutation.isPending}
|
||||
className='h-[28px] text-[12px]'
|
||||
>
|
||||
{unpublishMutation.isPending ? 'Unpublishing...' : 'Unpublish'}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className='text-[14px] text-[var(--text-tertiary)]'>Not Published</span>
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={handlePublish}
|
||||
disabled={publishMutation.isPending || tools.length === 0}
|
||||
className='h-[28px] text-[12px]'
|
||||
>
|
||||
{publishMutation.isPending ? 'Publishing...' : 'Publish'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{publishMutation.isError && (
|
||||
<p className='text-[12px] text-[var(--text-error)]'>
|
||||
{publishMutation.error?.message || 'Failed to publish'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{mcpServerUrl && (
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
MCP Server URL
|
||||
</span>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<code className='flex-1 truncate rounded-[4px] bg-[var(--surface-5)] px-[8px] py-[6px] font-mono text-[12px] text-[var(--text-secondary)]'>
|
||||
{mcpServerUrl}
|
||||
</code>
|
||||
<Button variant='ghost' onClick={handleCopyUrl} className='h-[32px] w-[32px] p-0'>
|
||||
{copiedUrl ? (
|
||||
<Check className='h-[14px] w-[14px]' />
|
||||
) : (
|
||||
<Clipboard className='h-[14px] w-[14px]' />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className='text-[11px] text-[var(--text-tertiary)]'>
|
||||
Use this URL to connect external MCP clients like Cursor or Claude Desktop.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Tools ({tools.length})
|
||||
</span>
|
||||
{tools.length === 0 ? (
|
||||
<p className='text-[13px] text-[var(--text-muted)]'>
|
||||
No tools added yet. Deploy a workflow and add it as a tool from the deploy modal.
|
||||
</p>
|
||||
) : (
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
{tools.map((tool) => (
|
||||
<div
|
||||
key={tool.id}
|
||||
className='flex items-center justify-between rounded-[6px] border bg-[var(--surface-3)] px-[10px] py-[8px]'
|
||||
>
|
||||
<div className='flex min-w-0 flex-col gap-[2px]'>
|
||||
<p className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
{tool.toolName}
|
||||
</p>
|
||||
{tool.toolDescription && (
|
||||
<p className='truncate text-[12px] text-[var(--text-tertiary)]'>
|
||||
{tool.toolDescription}
|
||||
</p>
|
||||
)}
|
||||
{tool.workflowName && (
|
||||
<p className='text-[11px] text-[var(--text-muted)]'>
|
||||
Workflow: {tool.workflowName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => setToolToDelete(tool)}
|
||||
className='h-[24px] w-[24px] p-0 text-[var(--text-tertiary)] hover:text-[var(--text-error)]'
|
||||
>
|
||||
<Trash2 className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-auto flex items-center justify-end'>
|
||||
<Button
|
||||
onClick={onBack}
|
||||
variant='primary'
|
||||
className='!bg-[var(--brand-tertiary-2)] !text-[var(--text-inverse)] hover:!bg-[var(--brand-tertiary-2)]/90'
|
||||
>
|
||||
<ChevronLeft className='mr-[4px] h-[14px] w-[14px]' />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal open={!!toolToDelete} onOpenChange={(open) => !open && setToolToDelete(null)}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalHeader>Remove Tool</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
Are you sure you want to remove{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>
|
||||
{toolToDelete?.toolName}
|
||||
</span>{' '}
|
||||
from this server?
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='default' onClick={() => setToolToDelete(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={handleDeleteTool}
|
||||
disabled={deleteToolMutation.isPending}
|
||||
className='!bg-[var(--text-error)] !text-white hover:!bg-[var(--text-error)]/90'
|
||||
>
|
||||
{deleteToolMutation.isPending ? 'Removing...' : 'Remove'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow MCP Servers settings component.
|
||||
* Allows users to create and manage MCP servers that expose workflows as tools.
|
||||
*/
|
||||
export function WorkflowMcpServers() {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
const { data: servers = [], isLoading, error } = useWorkflowMcpServers(workspaceId)
|
||||
const createServerMutation = useCreateWorkflowMcpServer()
|
||||
const deleteServerMutation = useDeleteWorkflowMcpServer()
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [formData, setFormData] = useState({ name: '', description: '' })
|
||||
const [selectedServerId, setSelectedServerId] = useState<string | null>(null)
|
||||
const [serverToDelete, setServerToDelete] = useState<WorkflowMcpServer | null>(null)
|
||||
const [deletingServers, setDeletingServers] = useState<Set<string>>(new Set())
|
||||
|
||||
const filteredServers = useMemo(() => {
|
||||
if (!searchTerm.trim()) return servers
|
||||
const search = searchTerm.toLowerCase()
|
||||
return servers.filter(
|
||||
(server) =>
|
||||
server.name.toLowerCase().includes(search) ||
|
||||
server.description?.toLowerCase().includes(search)
|
||||
)
|
||||
}, [servers, searchTerm])
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setFormData({ name: '', description: '' })
|
||||
setShowAddForm(false)
|
||||
}, [])
|
||||
|
||||
const handleCreateServer = async () => {
|
||||
if (!formData.name.trim()) return
|
||||
|
||||
try {
|
||||
await createServerMutation.mutateAsync({
|
||||
workspaceId,
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim() || undefined,
|
||||
})
|
||||
resetForm()
|
||||
} catch (error) {
|
||||
logger.error('Failed to create server:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteServer = async () => {
|
||||
if (!serverToDelete) return
|
||||
|
||||
setDeletingServers((prev) => new Set(prev).add(serverToDelete.id))
|
||||
setServerToDelete(null)
|
||||
|
||||
try {
|
||||
await deleteServerMutation.mutateAsync({
|
||||
workspaceId,
|
||||
serverId: serverToDelete.id,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete server:', error)
|
||||
} finally {
|
||||
setDeletingServers((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(serverToDelete.id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const hasServers = servers.length > 0
|
||||
const showEmptyState = !hasServers && !showAddForm
|
||||
const showNoResults = searchTerm.trim() && filteredServers.length === 0 && hasServers
|
||||
const isFormValid = formData.name.trim().length > 0
|
||||
|
||||
// Show detail view if a server is selected
|
||||
if (selectedServerId) {
|
||||
return (
|
||||
<ServerDetailView
|
||||
workspaceId={workspaceId}
|
||||
serverId={selectedServerId}
|
||||
onBack={() => setSelectedServerId(null)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex h-full flex-col gap-[16px]'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-1 items-center gap-[8px] rounded-[8px] border bg-[var(--surface-6)] px-[8px] py-[5px]',
|
||||
isLoading && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
<Search
|
||||
className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]'
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Input
|
||||
placeholder='Search servers...'
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
disabled={isLoading}
|
||||
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-100'
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
disabled={isLoading}
|
||||
variant='primary'
|
||||
className='!bg-[var(--brand-tertiary-2)] !text-[var(--text-inverse)] hover:!bg-[var(--brand-tertiary-2)]/90'
|
||||
>
|
||||
<Plus className='mr-[6px] h-[13px] w-[13px]' />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showAddForm && (
|
||||
<div className='rounded-[8px] border bg-[var(--surface-3)] p-[12px]'>
|
||||
<div className='flex flex-col gap-[12px]'>
|
||||
<div className='flex flex-col gap-[6px]'>
|
||||
<label
|
||||
htmlFor='mcp-server-name'
|
||||
className='font-medium text-[13px] text-[var(--text-secondary)]'
|
||||
>
|
||||
Server Name
|
||||
</label>
|
||||
<EmcnInput
|
||||
id='mcp-server-name'
|
||||
placeholder='e.g., My Workflow Tools'
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
|
||||
className='h-9'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-[6px]'>
|
||||
<label
|
||||
htmlFor='mcp-server-description'
|
||||
className='font-medium text-[13px] text-[var(--text-secondary)]'
|
||||
>
|
||||
Description (optional)
|
||||
</label>
|
||||
<EmcnInput
|
||||
id='mcp-server-description'
|
||||
placeholder='Describe what this server provides...'
|
||||
value={formData.description}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, description: e.target.value }))
|
||||
}
|
||||
className='h-9'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-end gap-[8px] pt-[4px]'>
|
||||
<Button variant='ghost' onClick={resetForm}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateServer}
|
||||
disabled={!isFormValid || createServerMutation.isPending}
|
||||
className='!bg-[var(--brand-tertiary-2)] !text-[var(--text-inverse)] hover:!bg-[var(--brand-tertiary-2)]/90'
|
||||
>
|
||||
{createServerMutation.isPending ? 'Creating...' : 'Create Server'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='min-h-0 flex-1 overflow-y-auto'>
|
||||
{error ? (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-[8px]'>
|
||||
<p className='text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
|
||||
{error instanceof Error ? error.message : 'Failed to load servers'}
|
||||
</p>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<ServerSkeleton />
|
||||
<ServerSkeleton />
|
||||
</div>
|
||||
) : showEmptyState ? (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-[8px] text-center'>
|
||||
<Server className='h-[32px] w-[32px] text-[var(--text-muted)]' />
|
||||
<p className='text-[13px] text-[var(--text-muted)]'>
|
||||
No workflow MCP servers yet.
|
||||
<br />
|
||||
Create one to expose your workflows as MCP tools.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
{filteredServers.map((server) => (
|
||||
<ServerListItem
|
||||
key={server.id}
|
||||
server={server}
|
||||
onViewDetails={() => setSelectedServerId(server.id)}
|
||||
onDelete={() => setServerToDelete(server)}
|
||||
isDeleting={deletingServers.has(server.id)}
|
||||
/>
|
||||
))}
|
||||
{showNoResults && (
|
||||
<div className='py-[16px] text-center text-[13px] text-[var(--text-muted)]'>
|
||||
No servers found matching "{searchTerm}"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal open={!!serverToDelete} onOpenChange={(open) => !open && setServerToDelete(null)}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalHeader>Delete MCP Server</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
Are you sure you want to delete{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>{serverToDelete?.name}</span>
|
||||
?{' '}
|
||||
<span className='text-[var(--text-error)]'>
|
||||
This will remove all tools and cannot be undone.
|
||||
</span>
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='default' onClick={() => setServerToDelete(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={handleDeleteServer}
|
||||
className='!bg-[var(--text-error)] !text-white hover:!bg-[var(--text-error)]/90'
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import * as VisuallyHidden from '@radix-ui/react-visually-hidden'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Files, LogIn, Settings, User, Users, Wrench } from 'lucide-react'
|
||||
import { Files, LogIn, Server, Settings, User, Users, Wrench } from 'lucide-react'
|
||||
import {
|
||||
Card,
|
||||
Connections,
|
||||
@@ -40,12 +40,14 @@ import {
|
||||
SSO,
|
||||
Subscription,
|
||||
TeamManagement,
|
||||
WorkflowMcpServers,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components'
|
||||
import { TemplateProfile } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/template-profile/template-profile'
|
||||
import { generalSettingsKeys, useGeneralSettings } from '@/hooks/queries/general-settings'
|
||||
import { organizationKeys, useOrganizations } from '@/hooks/queries/organization'
|
||||
import { ssoKeys, useSSOProviders } from '@/hooks/queries/sso'
|
||||
import { subscriptionKeys, useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
import { useSettingsModalStore } from '@/stores/settings-modal/store'
|
||||
|
||||
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
|
||||
const isSSOEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))
|
||||
@@ -68,6 +70,7 @@ type SettingsSection =
|
||||
| 'copilot'
|
||||
| 'mcp'
|
||||
| 'custom-tools'
|
||||
| 'workflow-mcp-servers'
|
||||
|
||||
type NavigationSection = 'account' | 'subscription' | 'tools' | 'system'
|
||||
|
||||
@@ -111,6 +114,7 @@ const allNavigationItems: NavigationItem[] = [
|
||||
{ id: 'integrations', label: 'Integrations', icon: Connections, section: 'tools' },
|
||||
{ id: 'custom-tools', label: 'Custom Tools', icon: Wrench, section: 'tools' },
|
||||
{ id: 'mcp', label: 'MCPs', icon: McpIcon, section: 'tools' },
|
||||
{ id: 'workflow-mcp-servers', label: 'Workflow MCP Servers', icon: Server, section: 'tools' },
|
||||
{ id: 'environment', label: 'Environment', icon: FolderCode, section: 'system' },
|
||||
{ id: 'apikeys', label: 'API Keys', icon: Key, section: 'system' },
|
||||
{
|
||||
@@ -134,6 +138,8 @@ const allNavigationItems: NavigationItem[] = [
|
||||
|
||||
export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
const [activeSection, setActiveSection] = useState<SettingsSection>('general')
|
||||
const { initialSection, mcpServerId, clearInitialState } = useSettingsModalStore()
|
||||
const [pendingMcpServerId, setPendingMcpServerId] = useState<string | null>(null)
|
||||
const { data: session } = useSession()
|
||||
const queryClient = useQueryClient()
|
||||
const { data: organizationsData } = useOrganizations()
|
||||
@@ -247,6 +253,24 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
// React Query hook automatically loads and syncs settings
|
||||
useGeneralSettings()
|
||||
|
||||
// Apply initial section from store when modal opens
|
||||
useEffect(() => {
|
||||
if (open && initialSection) {
|
||||
setActiveSection(initialSection)
|
||||
if (mcpServerId) {
|
||||
setPendingMcpServerId(mcpServerId)
|
||||
}
|
||||
clearInitialState()
|
||||
}
|
||||
}, [open, initialSection, mcpServerId, clearInitialState])
|
||||
|
||||
// Clear pending server ID when section changes away from MCP
|
||||
useEffect(() => {
|
||||
if (activeSection !== 'mcp') {
|
||||
setPendingMcpServerId(null)
|
||||
}
|
||||
}, [activeSection])
|
||||
|
||||
useEffect(() => {
|
||||
const handleOpenSettings = (event: CustomEvent<{ tab: SettingsSection }>) => {
|
||||
setActiveSection(event.detail.tab)
|
||||
@@ -436,8 +460,9 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
{isBillingEnabled && activeSection === 'team' && <TeamManagement />}
|
||||
{activeSection === 'sso' && <SSO />}
|
||||
{activeSection === 'copilot' && <Copilot />}
|
||||
{activeSection === 'mcp' && <MCP />}
|
||||
{activeSection === 'mcp' && <MCP initialServerId={pendingMcpServerId} />}
|
||||
{activeSection === 'custom-tools' && <CustomTools />}
|
||||
{activeSection === 'workflow-mcp-servers' && <WorkflowMcpServers />}
|
||||
</SModalMainBody>
|
||||
</SModalMain>
|
||||
</SModalContent>
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from '@/app/workspace/[workspaceId]/w/hooks'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
import { useSearchModalStore } from '@/stores/search-modal/store'
|
||||
import { useSettingsModalStore } from '@/stores/settings-modal/store'
|
||||
import { MIN_SIDEBAR_WIDTH, useSidebarStore } from '@/stores/sidebar/store'
|
||||
|
||||
const logger = createLogger('Sidebar')
|
||||
@@ -88,7 +89,11 @@ export function Sidebar() {
|
||||
|
||||
const [isWorkspaceMenuOpen, setIsWorkspaceMenuOpen] = useState(false)
|
||||
const [isHelpModalOpen, setIsHelpModalOpen] = useState(false)
|
||||
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false)
|
||||
const {
|
||||
isOpen: isSettingsModalOpen,
|
||||
openModal: openSettingsModal,
|
||||
closeModal: closeSettingsModal,
|
||||
} = useSettingsModalStore()
|
||||
|
||||
/** Listens for external events to open help modal */
|
||||
useEffect(() => {
|
||||
@@ -219,7 +224,7 @@ export function Sidebar() {
|
||||
id: 'settings',
|
||||
label: 'Settings',
|
||||
icon: Settings,
|
||||
onClick: () => setIsSettingsModalOpen(true),
|
||||
onClick: () => openSettingsModal(),
|
||||
},
|
||||
],
|
||||
[workspaceId]
|
||||
@@ -654,7 +659,10 @@ export function Sidebar() {
|
||||
|
||||
{/* Footer Navigation Modals */}
|
||||
<HelpModal open={isHelpModalOpen} onOpenChange={setIsHelpModalOpen} />
|
||||
<SettingsModal open={isSettingsModalOpen} onOpenChange={setIsSettingsModalOpen} />
|
||||
<SettingsModal
|
||||
open={isSettingsModalOpen}
|
||||
onOpenChange={(open) => (open ? openSettingsModal() : closeSettingsModal())}
|
||||
/>
|
||||
|
||||
{/* Hidden file input for workspace import */}
|
||||
<input
|
||||
|
||||
@@ -14,7 +14,7 @@ export type WorkflowExecutionPayload = {
|
||||
workflowId: string
|
||||
userId: string
|
||||
input?: any
|
||||
triggerType?: 'api' | 'webhook' | 'schedule' | 'manual' | 'chat'
|
||||
triggerType?: 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' | 'mcp'
|
||||
metadata?: Record<string, any>
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
getHostedModels,
|
||||
getMaxTemperature,
|
||||
getProviderIcon,
|
||||
getReasoningEffortValuesForModel,
|
||||
getVerbosityValuesForModel,
|
||||
MODELS_WITH_REASONING_EFFORT,
|
||||
MODELS_WITH_VERBOSITY,
|
||||
providers,
|
||||
@@ -114,12 +116,47 @@ export const AgentBlock: BlockConfig<AgentResponse> = {
|
||||
type: 'dropdown',
|
||||
placeholder: 'Select reasoning effort...',
|
||||
options: [
|
||||
{ label: 'none', id: 'none' },
|
||||
{ label: 'minimal', id: 'minimal' },
|
||||
{ label: 'low', id: 'low' },
|
||||
{ label: 'medium', id: 'medium' },
|
||||
{ label: 'high', id: 'high' },
|
||||
],
|
||||
dependsOn: ['model'],
|
||||
fetchOptions: async (blockId: string) => {
|
||||
const { useSubBlockStore } = await import('@/stores/workflows/subblock/store')
|
||||
const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store')
|
||||
|
||||
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
|
||||
if (!activeWorkflowId) {
|
||||
return [
|
||||
{ label: 'low', id: 'low' },
|
||||
{ label: 'medium', id: 'medium' },
|
||||
{ label: 'high', id: 'high' },
|
||||
]
|
||||
}
|
||||
|
||||
const workflowValues = useSubBlockStore.getState().workflowValues[activeWorkflowId]
|
||||
const blockValues = workflowValues?.[blockId]
|
||||
const modelValue = blockValues?.model as string
|
||||
|
||||
if (!modelValue) {
|
||||
return [
|
||||
{ label: 'low', id: 'low' },
|
||||
{ label: 'medium', id: 'medium' },
|
||||
{ label: 'high', id: 'high' },
|
||||
]
|
||||
}
|
||||
|
||||
const validOptions = getReasoningEffortValuesForModel(modelValue)
|
||||
if (!validOptions) {
|
||||
return [
|
||||
{ label: 'low', id: 'low' },
|
||||
{ label: 'medium', id: 'medium' },
|
||||
{ label: 'high', id: 'high' },
|
||||
]
|
||||
}
|
||||
|
||||
return validOptions.map((opt) => ({ label: opt, id: opt }))
|
||||
},
|
||||
value: () => 'medium',
|
||||
condition: {
|
||||
field: 'model',
|
||||
@@ -136,6 +173,43 @@ export const AgentBlock: BlockConfig<AgentResponse> = {
|
||||
{ label: 'medium', id: 'medium' },
|
||||
{ label: 'high', id: 'high' },
|
||||
],
|
||||
dependsOn: ['model'],
|
||||
fetchOptions: async (blockId: string) => {
|
||||
const { useSubBlockStore } = await import('@/stores/workflows/subblock/store')
|
||||
const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store')
|
||||
|
||||
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
|
||||
if (!activeWorkflowId) {
|
||||
return [
|
||||
{ label: 'low', id: 'low' },
|
||||
{ label: 'medium', id: 'medium' },
|
||||
{ label: 'high', id: 'high' },
|
||||
]
|
||||
}
|
||||
|
||||
const workflowValues = useSubBlockStore.getState().workflowValues[activeWorkflowId]
|
||||
const blockValues = workflowValues?.[blockId]
|
||||
const modelValue = blockValues?.model as string
|
||||
|
||||
if (!modelValue) {
|
||||
return [
|
||||
{ label: 'low', id: 'low' },
|
||||
{ label: 'medium', id: 'medium' },
|
||||
{ label: 'high', id: 'high' },
|
||||
]
|
||||
}
|
||||
|
||||
const validOptions = getVerbosityValuesForModel(modelValue)
|
||||
if (!validOptions) {
|
||||
return [
|
||||
{ label: 'low', id: 'low' },
|
||||
{ label: 'medium', id: 'medium' },
|
||||
{ label: 'high', id: 'high' },
|
||||
]
|
||||
}
|
||||
|
||||
return validOptions.map((opt) => ({ label: opt, id: opt }))
|
||||
},
|
||||
value: () => 'medium',
|
||||
condition: {
|
||||
field: 'model',
|
||||
@@ -166,6 +240,28 @@ export const AgentBlock: BlockConfig<AgentResponse> = {
|
||||
value: providers['azure-openai'].models,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'vertexProject',
|
||||
title: 'Vertex AI Project',
|
||||
type: 'short-input',
|
||||
placeholder: 'your-gcp-project-id',
|
||||
connectionDroppable: false,
|
||||
condition: {
|
||||
field: 'model',
|
||||
value: providers.vertex.models,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'vertexLocation',
|
||||
title: 'Vertex AI Location',
|
||||
type: 'short-input',
|
||||
placeholder: 'us-central1',
|
||||
connectionDroppable: false,
|
||||
condition: {
|
||||
field: 'model',
|
||||
value: providers.vertex.models,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'tools',
|
||||
title: 'Tools',
|
||||
@@ -465,6 +561,8 @@ Example 3 (Array Input):
|
||||
apiKey: { type: 'string', description: 'Provider API key' },
|
||||
azureEndpoint: { type: 'string', description: 'Azure OpenAI endpoint URL' },
|
||||
azureApiVersion: { type: 'string', description: 'Azure API version' },
|
||||
vertexProject: { type: 'string', description: 'Google Cloud project ID for Vertex AI' },
|
||||
vertexLocation: { type: 'string', description: 'Google Cloud location for Vertex AI' },
|
||||
responseFormat: {
|
||||
type: 'json',
|
||||
description: 'JSON response format schema',
|
||||
|
||||
@@ -239,6 +239,28 @@ export const EvaluatorBlock: BlockConfig<EvaluatorResponse> = {
|
||||
value: providers['azure-openai'].models,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'vertexProject',
|
||||
title: 'Vertex AI Project',
|
||||
type: 'short-input',
|
||||
placeholder: 'your-gcp-project-id',
|
||||
connectionDroppable: false,
|
||||
condition: {
|
||||
field: 'model',
|
||||
value: providers.vertex.models,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'vertexLocation',
|
||||
title: 'Vertex AI Location',
|
||||
type: 'short-input',
|
||||
placeholder: 'us-central1',
|
||||
connectionDroppable: false,
|
||||
condition: {
|
||||
field: 'model',
|
||||
value: providers.vertex.models,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'temperature',
|
||||
title: 'Temperature',
|
||||
@@ -356,6 +378,14 @@ export const EvaluatorBlock: BlockConfig<EvaluatorResponse> = {
|
||||
apiKey: { type: 'string' as ParamType, description: 'Provider API key' },
|
||||
azureEndpoint: { type: 'string' as ParamType, description: 'Azure OpenAI endpoint URL' },
|
||||
azureApiVersion: { type: 'string' as ParamType, description: 'Azure API version' },
|
||||
vertexProject: {
|
||||
type: 'string' as ParamType,
|
||||
description: 'Google Cloud project ID for Vertex AI',
|
||||
},
|
||||
vertexLocation: {
|
||||
type: 'string' as ParamType,
|
||||
description: 'Google Cloud location for Vertex AI',
|
||||
},
|
||||
temperature: {
|
||||
type: 'number' as ParamType,
|
||||
description: 'Response randomness level (low for consistent evaluation)',
|
||||
|
||||
@@ -188,6 +188,28 @@ export const RouterBlock: BlockConfig<RouterResponse> = {
|
||||
value: providers['azure-openai'].models,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'vertexProject',
|
||||
title: 'Vertex AI Project',
|
||||
type: 'short-input',
|
||||
placeholder: 'your-gcp-project-id',
|
||||
connectionDroppable: false,
|
||||
condition: {
|
||||
field: 'model',
|
||||
value: providers.vertex.models,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'vertexLocation',
|
||||
title: 'Vertex AI Location',
|
||||
type: 'short-input',
|
||||
placeholder: 'us-central1',
|
||||
connectionDroppable: false,
|
||||
condition: {
|
||||
field: 'model',
|
||||
value: providers.vertex.models,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'temperature',
|
||||
title: 'Temperature',
|
||||
@@ -235,6 +257,8 @@ export const RouterBlock: BlockConfig<RouterResponse> = {
|
||||
apiKey: { type: 'string', description: 'Provider API key' },
|
||||
azureEndpoint: { type: 'string', description: 'Azure OpenAI endpoint URL' },
|
||||
azureApiVersion: { type: 'string', description: 'Azure API version' },
|
||||
vertexProject: { type: 'string', description: 'Google Cloud project ID for Vertex AI' },
|
||||
vertexLocation: { type: 'string', description: 'Google Cloud location for Vertex AI' },
|
||||
temperature: {
|
||||
type: 'number',
|
||||
description: 'Response randomness level (low for consistent routing)',
|
||||
|
||||
257
apps/sim/blocks/blocks/servicenow.ts
Normal file
257
apps/sim/blocks/blocks/servicenow.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { ServiceNowIcon } from '@/components/icons'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode } from '@/blocks/types'
|
||||
import type { ServiceNowResponse } from '@/tools/servicenow/types'
|
||||
|
||||
export const ServiceNowBlock: BlockConfig<ServiceNowResponse> = {
|
||||
type: 'servicenow',
|
||||
name: 'ServiceNow',
|
||||
description: 'Create, read, update, delete, and bulk import ServiceNow records',
|
||||
authMode: AuthMode.OAuth,
|
||||
longDescription:
|
||||
'Integrate ServiceNow into your workflow. Can create, read, update, and delete records in any ServiceNow table (incidents, tasks, users, etc.). Supports bulk import operations for data migration and ETL.',
|
||||
docsLink: 'https://docs.sim.ai/tools/servicenow',
|
||||
category: 'tools',
|
||||
bgColor: '#032D42',
|
||||
icon: ServiceNowIcon,
|
||||
subBlocks: [
|
||||
// Operation selector
|
||||
{
|
||||
id: 'operation',
|
||||
title: 'Operation',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Create Record', id: 'create' },
|
||||
{ label: 'Read Records', id: 'read' },
|
||||
{ label: 'Update Record', id: 'update' },
|
||||
{ label: 'Delete Record', id: 'delete' },
|
||||
],
|
||||
value: () => 'read',
|
||||
},
|
||||
// Instance URL
|
||||
{
|
||||
id: 'instanceUrl',
|
||||
title: 'Instance URL',
|
||||
type: 'short-input',
|
||||
placeholder: 'https://instance.service-now.com',
|
||||
required: true,
|
||||
description: 'Your ServiceNow instance URL',
|
||||
},
|
||||
// OAuth Credential
|
||||
{
|
||||
id: 'credential',
|
||||
title: 'ServiceNow Account',
|
||||
type: 'oauth-input',
|
||||
serviceId: 'servicenow',
|
||||
requiredScopes: ['useraccount'],
|
||||
placeholder: 'Select ServiceNow account',
|
||||
required: true,
|
||||
},
|
||||
// Table Name
|
||||
{
|
||||
id: 'tableName',
|
||||
title: 'Table Name',
|
||||
type: 'short-input',
|
||||
placeholder: 'incident, task, sys_user, etc.',
|
||||
required: true,
|
||||
description: 'ServiceNow table name',
|
||||
},
|
||||
// Create-specific: Fields
|
||||
{
|
||||
id: 'fields',
|
||||
title: 'Fields (JSON)',
|
||||
type: 'code',
|
||||
language: 'json',
|
||||
placeholder: '{\n "short_description": "Issue description",\n "priority": "1"\n}',
|
||||
condition: { field: 'operation', value: 'create' },
|
||||
required: true,
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
maintainHistory: true,
|
||||
prompt: `You are an expert ServiceNow developer. Generate ServiceNow record field objects as JSON based on the user's request.
|
||||
|
||||
### CONTEXT
|
||||
ServiceNow records use specific field names depending on the table. Common tables and their key fields include:
|
||||
- incident: short_description, description, priority (1-5), urgency (1-3), impact (1-3), caller_id, assignment_group, assigned_to, category, subcategory, state
|
||||
- task: short_description, description, priority, assignment_group, assigned_to, state
|
||||
- sys_user: user_name, first_name, last_name, email, active, department, title
|
||||
- change_request: short_description, description, type, risk, impact, priority, assignment_group
|
||||
|
||||
### RULES
|
||||
- Output ONLY valid JSON object starting with { and ending with }
|
||||
- Use correct ServiceNow field names for the target table
|
||||
- Values should be strings unless the field specifically requires another type
|
||||
- For reference fields (like caller_id, assigned_to), use sys_id values or display values
|
||||
- Do not include sys_id in create operations (it's auto-generated)
|
||||
|
||||
### EXAMPLE
|
||||
User: "Create a high priority incident for network outage"
|
||||
Output: {"short_description": "Network outage", "description": "Network connectivity issue affecting users", "priority": "1", "urgency": "1", "impact": "1", "category": "Network"}`,
|
||||
generationType: 'json-object',
|
||||
},
|
||||
},
|
||||
// Read-specific: Query options
|
||||
{
|
||||
id: 'sysId',
|
||||
title: 'Record sys_id',
|
||||
type: 'short-input',
|
||||
placeholder: 'Specific record sys_id (optional)',
|
||||
condition: { field: 'operation', value: 'read' },
|
||||
},
|
||||
{
|
||||
id: 'number',
|
||||
title: 'Record Number',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g., INC0010001 (optional)',
|
||||
condition: { field: 'operation', value: 'read' },
|
||||
},
|
||||
{
|
||||
id: 'query',
|
||||
title: 'Query String',
|
||||
type: 'short-input',
|
||||
placeholder: 'active=true^priority=1',
|
||||
condition: { field: 'operation', value: 'read' },
|
||||
description: 'ServiceNow encoded query string',
|
||||
},
|
||||
{
|
||||
id: 'limit',
|
||||
title: 'Limit',
|
||||
type: 'short-input',
|
||||
placeholder: '10',
|
||||
condition: { field: 'operation', value: 'read' },
|
||||
},
|
||||
{
|
||||
id: 'fields',
|
||||
title: 'Fields to Return',
|
||||
type: 'short-input',
|
||||
placeholder: 'number,short_description,priority',
|
||||
condition: { field: 'operation', value: 'read' },
|
||||
description: 'Comma-separated list of fields',
|
||||
},
|
||||
// Update-specific: sysId and fields
|
||||
{
|
||||
id: 'sysId',
|
||||
title: 'Record sys_id',
|
||||
type: 'short-input',
|
||||
placeholder: 'Record sys_id to update',
|
||||
condition: { field: 'operation', value: 'update' },
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'fields',
|
||||
title: 'Fields to Update (JSON)',
|
||||
type: 'code',
|
||||
language: 'json',
|
||||
placeholder: '{\n "state": "2",\n "assigned_to": "user.sys_id"\n}',
|
||||
condition: { field: 'operation', value: 'update' },
|
||||
required: true,
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
maintainHistory: true,
|
||||
prompt: `You are an expert ServiceNow developer. Generate ServiceNow record update field objects as JSON based on the user's request.
|
||||
|
||||
### CONTEXT
|
||||
ServiceNow records use specific field names depending on the table. Common update scenarios include:
|
||||
- incident: state (1=New, 2=In Progress, 3=On Hold, 6=Resolved, 7=Closed), assigned_to, work_notes, close_notes, close_code
|
||||
- task: state, assigned_to, work_notes, percent_complete
|
||||
- change_request: state, risk, approval, work_notes
|
||||
|
||||
### RULES
|
||||
- Output ONLY valid JSON object starting with { and ending with }
|
||||
- Include only the fields that need to be updated
|
||||
- Use correct ServiceNow field names for the target table
|
||||
- For state transitions, use the correct numeric state values
|
||||
- work_notes and comments fields append to existing values
|
||||
|
||||
### EXAMPLE
|
||||
User: "Assign the incident to John and set to in progress"
|
||||
Output: {"state": "2", "assigned_to": "john.doe", "work_notes": "Assigned and starting investigation"}`,
|
||||
generationType: 'json-object',
|
||||
},
|
||||
},
|
||||
// Delete-specific: sysId
|
||||
{
|
||||
id: 'sysId',
|
||||
title: 'Record sys_id',
|
||||
type: 'short-input',
|
||||
placeholder: 'Record sys_id to delete',
|
||||
condition: { field: 'operation', value: 'delete' },
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
tools: {
|
||||
access: [
|
||||
'servicenow_create_record',
|
||||
'servicenow_read_record',
|
||||
'servicenow_update_record',
|
||||
'servicenow_delete_record',
|
||||
],
|
||||
config: {
|
||||
tool: (params) => {
|
||||
switch (params.operation) {
|
||||
case 'create':
|
||||
return 'servicenow_create_record'
|
||||
case 'read':
|
||||
return 'servicenow_read_record'
|
||||
case 'update':
|
||||
return 'servicenow_update_record'
|
||||
case 'delete':
|
||||
return 'servicenow_delete_record'
|
||||
default:
|
||||
throw new Error(`Invalid ServiceNow operation: ${params.operation}`)
|
||||
}
|
||||
},
|
||||
params: (params) => {
|
||||
const { operation, fields, records, credential, ...rest } = params
|
||||
|
||||
// Parse JSON fields if provided
|
||||
let parsedFields: Record<string, any> | undefined
|
||||
if (fields && (operation === 'create' || operation === 'update')) {
|
||||
try {
|
||||
parsedFields = typeof fields === 'string' ? JSON.parse(fields) : fields
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Invalid JSON in fields: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate OAuth credential
|
||||
if (!credential) {
|
||||
throw new Error('ServiceNow account credential is required')
|
||||
}
|
||||
|
||||
// Build params
|
||||
const baseParams: Record<string, any> = {
|
||||
...rest,
|
||||
credential,
|
||||
}
|
||||
|
||||
if (operation === 'create' || operation === 'update') {
|
||||
return {
|
||||
...baseParams,
|
||||
fields: parsedFields,
|
||||
}
|
||||
}
|
||||
return baseParams
|
||||
},
|
||||
},
|
||||
},
|
||||
inputs: {
|
||||
operation: { type: 'string', description: 'Operation to perform' },
|
||||
instanceUrl: { type: 'string', description: 'ServiceNow instance URL' },
|
||||
credential: { type: 'string', description: 'ServiceNow OAuth credential ID' },
|
||||
tableName: { type: 'string', description: 'Table name' },
|
||||
sysId: { type: 'string', description: 'Record sys_id' },
|
||||
number: { type: 'string', description: 'Record number' },
|
||||
query: { type: 'string', description: 'Query string' },
|
||||
limit: { type: 'number', description: 'Result limit' },
|
||||
fields: { type: 'json', description: 'Fields object or JSON string' },
|
||||
},
|
||||
outputs: {
|
||||
record: { type: 'json', description: 'Single ServiceNow record' },
|
||||
records: { type: 'json', description: 'Array of ServiceNow records' },
|
||||
success: { type: 'boolean', description: 'Operation success status' },
|
||||
metadata: { type: 'json', description: 'Operation metadata' },
|
||||
},
|
||||
}
|
||||
@@ -99,6 +99,28 @@ export const TranslateBlock: BlockConfig = {
|
||||
value: providers['azure-openai'].models,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'vertexProject',
|
||||
title: 'Vertex AI Project',
|
||||
type: 'short-input',
|
||||
placeholder: 'your-gcp-project-id',
|
||||
connectionDroppable: false,
|
||||
condition: {
|
||||
field: 'model',
|
||||
value: providers.vertex.models,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'vertexLocation',
|
||||
title: 'Vertex AI Location',
|
||||
type: 'short-input',
|
||||
placeholder: 'us-central1',
|
||||
connectionDroppable: false,
|
||||
condition: {
|
||||
field: 'model',
|
||||
value: providers.vertex.models,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'systemPrompt',
|
||||
title: 'System Prompt',
|
||||
@@ -120,6 +142,8 @@ export const TranslateBlock: BlockConfig = {
|
||||
apiKey: params.apiKey,
|
||||
azureEndpoint: params.azureEndpoint,
|
||||
azureApiVersion: params.azureApiVersion,
|
||||
vertexProject: params.vertexProject,
|
||||
vertexLocation: params.vertexLocation,
|
||||
}),
|
||||
},
|
||||
},
|
||||
@@ -129,6 +153,8 @@ export const TranslateBlock: BlockConfig = {
|
||||
apiKey: { type: 'string', description: 'Provider API key' },
|
||||
azureEndpoint: { type: 'string', description: 'Azure OpenAI endpoint URL' },
|
||||
azureApiVersion: { type: 'string', description: 'Azure API version' },
|
||||
vertexProject: { type: 'string', description: 'Google Cloud project ID for Vertex AI' },
|
||||
vertexLocation: { type: 'string', description: 'Google Cloud location for Vertex AI' },
|
||||
systemPrompt: { type: 'string', description: 'Translation instructions' },
|
||||
},
|
||||
outputs: {
|
||||
|
||||
@@ -96,6 +96,7 @@ import { SearchBlock } from '@/blocks/blocks/search'
|
||||
import { SendGridBlock } from '@/blocks/blocks/sendgrid'
|
||||
import { SentryBlock } from '@/blocks/blocks/sentry'
|
||||
import { SerperBlock } from '@/blocks/blocks/serper'
|
||||
import { ServiceNowBlock } from '@/blocks/blocks/servicenow'
|
||||
import { SftpBlock } from '@/blocks/blocks/sftp'
|
||||
import { SharepointBlock } from '@/blocks/blocks/sharepoint'
|
||||
import { ShopifyBlock } from '@/blocks/blocks/shopify'
|
||||
@@ -238,6 +239,7 @@ export const registry: Record<string, BlockConfig> = {
|
||||
search: SearchBlock,
|
||||
sendgrid: SendGridBlock,
|
||||
sentry: SentryBlock,
|
||||
servicenow: ServiceNowBlock,
|
||||
serper: SerperBlock,
|
||||
sharepoint: SharepointBlock,
|
||||
shopify: ShopifyBlock,
|
||||
|
||||
@@ -291,7 +291,7 @@ function CodeRow({ index, style, ...props }: RowComponentProps<CodeRowProps>) {
|
||||
const line = lines[index]
|
||||
|
||||
return (
|
||||
<div style={style} className='flex' data-row-index={index}>
|
||||
<div style={style} className={cn('flex', wrapText && 'overflow-hidden')} data-row-index={index}>
|
||||
{showGutter && (
|
||||
<div
|
||||
className='flex-shrink-0 select-none pr-0.5 text-right text-[var(--text-muted)] text-xs tabular-nums leading-[21px] dark:text-[#a8a8a8]'
|
||||
@@ -303,7 +303,7 @@ function CodeRow({ index, style, ...props }: RowComponentProps<CodeRowProps>) {
|
||||
<pre
|
||||
className={cn(
|
||||
'm-0 flex-1 pr-2 pl-2 font-mono text-[13px] text-[var(--text-primary)] leading-[21px] dark:text-[#eeeeee]',
|
||||
wrapText ? 'whitespace-pre-wrap break-words' : 'whitespace-pre'
|
||||
wrapText ? 'min-w-0 whitespace-pre-wrap break-words' : 'whitespace-pre'
|
||||
)}
|
||||
dangerouslySetInnerHTML={{ __html: line.html || ' ' }}
|
||||
/>
|
||||
@@ -625,7 +625,7 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({
|
||||
rowComponent={CodeRow}
|
||||
rowProps={rowProps}
|
||||
overscanCount={5}
|
||||
className='overflow-x-auto'
|
||||
className={wrapText ? 'overflow-x-hidden' : 'overflow-x-auto'}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2452,6 +2452,56 @@ export const GeminiIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const VertexIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
{...props}
|
||||
id='standard_product_icon'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
version='1.1'
|
||||
viewBox='0 0 512 512'
|
||||
>
|
||||
<g id='bounding_box'>
|
||||
<rect width='512' height='512' fill='none' />
|
||||
</g>
|
||||
<g id='art'>
|
||||
<path
|
||||
d='M128,244.99c-8.84,0-16-7.16-16-16v-95.97c0-8.84,7.16-16,16-16s16,7.16,16,16v95.97c0,8.84-7.16,16-16,16Z'
|
||||
fill='#ea4335'
|
||||
/>
|
||||
<path
|
||||
d='M256,458c-2.98,0-5.97-.83-8.59-2.5l-186-122c-7.46-4.74-9.65-14.63-4.91-22.09,4.75-7.46,14.64-9.65,22.09-4.91l177.41,116.53,177.41-116.53c7.45-4.74,17.34-2.55,22.09,4.91,4.74,7.46,2.55,17.34-4.91,22.09l-186,122c-2.62,1.67-5.61,2.5-8.59,2.5Z'
|
||||
fill='#fbbc04'
|
||||
/>
|
||||
<path
|
||||
d='M256,388.03c-8.84,0-16-7.16-16-16v-73.06c0-8.84,7.16-16,16-16s16,7.16,16,16v73.06c0,8.84-7.16,16-16,16Z'
|
||||
fill='#34a853'
|
||||
/>
|
||||
<circle cx='128' cy='70' r='16' fill='#ea4335' />
|
||||
<circle cx='128' cy='292' r='16' fill='#ea4335' />
|
||||
<path
|
||||
d='M384.23,308.01c-8.82,0-15.98-7.14-16-15.97l-.23-94.01c-.02-8.84,7.13-16.02,15.97-16.03h.04c8.82,0,15.98,7.14,16,15.97l.23,94.01c.02,8.84-7.13,16.02-15.97,16.03h-.04Z'
|
||||
fill='#4285f4'
|
||||
/>
|
||||
<circle cx='384' cy='70' r='16' fill='#4285f4' />
|
||||
<circle cx='384' cy='134' r='16' fill='#4285f4' />
|
||||
<path
|
||||
d='M320,220.36c-8.84,0-16-7.16-16-16v-103.02c0-8.84,7.16-16,16-16s16,7.16,16,16v103.02c0,8.84-7.16,16-16,16Z'
|
||||
fill='#fbbc04'
|
||||
/>
|
||||
<circle cx='256' cy='171' r='16' fill='#34a853' />
|
||||
<circle cx='256' cy='235' r='16' fill='#34a853' />
|
||||
<circle cx='320' cy='265' r='16' fill='#fbbc04' />
|
||||
<circle cx='320' cy='329' r='16' fill='#fbbc04' />
|
||||
<path
|
||||
d='M192,217.36c-8.84,0-16-7.16-16-16v-100.02c0-8.84,7.16-16,16-16s16,7.16,16,16v100.02c0,8.84-7.16,16-16,16Z'
|
||||
fill='#fbbc04'
|
||||
/>
|
||||
<circle cx='192' cy='265' r='16' fill='#fbbc04' />
|
||||
<circle cx='192' cy='329' r='16' fill='#fbbc04' />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const CerebrasIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
{...props}
|
||||
@@ -3335,6 +3385,24 @@ export function SalesforceIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function ServiceNowIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 1570 1403'
|
||||
width='48'
|
||||
height='48'
|
||||
>
|
||||
<path
|
||||
fill='#62d84e'
|
||||
fillRule='evenodd'
|
||||
d='M1228.4 138.9c129.2 88.9 228.9 214.3 286.3 360.2 57.5 145.8 70 305.5 36 458.5S1437.8 1250 1324 1357.9c-13.3 12.9-28.8 23.4-45.8 30.8-17 7.5-35.2 11.9-53.7 12.9-18.5 1.1-37.1-1.1-54.8-6.6-17.7-5.4-34.3-13.9-49.1-25.2-48.2-35.9-101.8-63.8-158.8-82.6-57.1-18.9-116.7-28.5-176.8-28.5s-119.8 9.6-176.8 28.5c-57 18.8-110.7 46.7-158.9 82.6-14.6 11.2-31 19.8-48.6 25.3s-36 7.8-54.4 6.8c-18.4-.9-36.5-5.1-53.4-12.4s-32.4-17.5-45.8-30.2C132.5 1251 53 1110.8 19 956.8s-20.9-314.6 37.6-461c58.5-146.5 159.6-272 290.3-360.3S631.8.1 789.6.5c156.8 1.3 309.6 49.6 438.8 138.4m-291.8 1014c48.2-19.2 92-48 128.7-84.6 36.7-36.7 65.5-80.4 84.7-128.6 19.2-48.1 28.4-99.7 27-151.5 0-103.9-41.3-203.5-114.8-277S889 396.4 785 396.4s-203.7 41.3-277.2 114.8S393 684.3 393 788.2c-1.4 51.8 7.8 103.4 27 151.5 19.2 48.2 48 91.9 84.7 128.6 36.7 36.6 80.5 65.4 128.6 84.6 48.2 19.2 99.8 28.4 151.7 27 51.8 1.4 103.4-7.8 151.6-27'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ApolloIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { db } from '@sim/db'
|
||||
import { mcpServers } from '@sim/db/schema'
|
||||
import { and, eq, inArray, isNull } from 'drizzle-orm'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
@@ -72,6 +75,11 @@ export class BlockExecutor {
|
||||
|
||||
try {
|
||||
resolvedInputs = this.resolver.resolveInputs(ctx, node.id, block.config.params, block)
|
||||
|
||||
if (block.metadata?.id === BlockType.AGENT && resolvedInputs.tools) {
|
||||
resolvedInputs = await this.filterUnavailableMcpToolsForLog(ctx, resolvedInputs)
|
||||
}
|
||||
|
||||
if (blockLog) {
|
||||
blockLog.input = resolvedInputs
|
||||
}
|
||||
@@ -395,6 +403,60 @@ export class BlockExecutor {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters out unavailable MCP tools from agent inputs for logging.
|
||||
* Only includes tools from servers with 'connected' status.
|
||||
*/
|
||||
private async filterUnavailableMcpToolsForLog(
|
||||
ctx: ExecutionContext,
|
||||
inputs: Record<string, any>
|
||||
): Promise<Record<string, any>> {
|
||||
const tools = inputs.tools
|
||||
if (!Array.isArray(tools) || tools.length === 0) return inputs
|
||||
|
||||
const mcpTools = tools.filter((t: any) => t.type === 'mcp')
|
||||
if (mcpTools.length === 0) return inputs
|
||||
|
||||
const serverIds = [
|
||||
...new Set(mcpTools.map((t: any) => t.params?.serverId).filter(Boolean)),
|
||||
] as string[]
|
||||
if (serverIds.length === 0) return inputs
|
||||
|
||||
const availableServerIds = new Set<string>()
|
||||
if (ctx.workspaceId && serverIds.length > 0) {
|
||||
try {
|
||||
const servers = await db
|
||||
.select({ id: mcpServers.id, connectionStatus: mcpServers.connectionStatus })
|
||||
.from(mcpServers)
|
||||
.where(
|
||||
and(
|
||||
eq(mcpServers.workspaceId, ctx.workspaceId),
|
||||
inArray(mcpServers.id, serverIds),
|
||||
isNull(mcpServers.deletedAt)
|
||||
)
|
||||
)
|
||||
|
||||
for (const server of servers) {
|
||||
if (server.connectionStatus === 'connected') {
|
||||
availableServerIds.add(server.id)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to check MCP server availability for logging:', error)
|
||||
return inputs
|
||||
}
|
||||
}
|
||||
|
||||
const filteredTools = tools.filter((tool: any) => {
|
||||
if (tool.type !== 'mcp') return true
|
||||
const serverId = tool.params?.serverId
|
||||
if (!serverId) return false
|
||||
return availableServerIds.has(serverId)
|
||||
})
|
||||
|
||||
return { ...inputs, tools: filteredTools }
|
||||
}
|
||||
|
||||
private preparePauseResumeSelfReference(
|
||||
ctx: ExecutionContext,
|
||||
node: DAGNode,
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { db } from '@sim/db'
|
||||
import { mcpServers } from '@sim/db/schema'
|
||||
import { and, eq, inArray, isNull } from 'drizzle-orm'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { createMcpToolId } from '@/lib/mcp/utils'
|
||||
import { getAllBlocks } from '@/blocks'
|
||||
@@ -35,19 +38,23 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
block: SerializedBlock,
|
||||
inputs: AgentInputs
|
||||
): Promise<BlockOutput | StreamingExecution> {
|
||||
const responseFormat = this.parseResponseFormat(inputs.responseFormat)
|
||||
const model = inputs.model || AGENT.DEFAULT_MODEL
|
||||
// Filter out unavailable MCP tools early so they don't appear in logs/inputs
|
||||
const filteredTools = await this.filterUnavailableMcpTools(ctx, inputs.tools || [])
|
||||
const filteredInputs = { ...inputs, tools: filteredTools }
|
||||
|
||||
const responseFormat = this.parseResponseFormat(filteredInputs.responseFormat)
|
||||
const model = filteredInputs.model || AGENT.DEFAULT_MODEL
|
||||
const providerId = getProviderFromModel(model)
|
||||
const formattedTools = await this.formatTools(ctx, inputs.tools || [])
|
||||
const formattedTools = await this.formatTools(ctx, filteredInputs.tools || [])
|
||||
const streamingConfig = this.getStreamingConfig(ctx, block)
|
||||
const messages = await this.buildMessages(ctx, inputs, block.id)
|
||||
const messages = await this.buildMessages(ctx, filteredInputs, block.id)
|
||||
|
||||
const providerRequest = this.buildProviderRequest({
|
||||
ctx,
|
||||
providerId,
|
||||
model,
|
||||
messages,
|
||||
inputs,
|
||||
inputs: filteredInputs,
|
||||
formattedTools,
|
||||
responseFormat,
|
||||
streaming: streamingConfig.shouldUseStreaming ?? false,
|
||||
@@ -58,10 +65,10 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
providerRequest,
|
||||
block,
|
||||
responseFormat,
|
||||
inputs
|
||||
filteredInputs
|
||||
)
|
||||
|
||||
await this.persistResponseToMemory(ctx, inputs, result, block.id)
|
||||
await this.persistResponseToMemory(ctx, filteredInputs, result, block.id)
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -115,6 +122,53 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
return undefined
|
||||
}
|
||||
|
||||
private async filterUnavailableMcpTools(
|
||||
ctx: ExecutionContext,
|
||||
tools: ToolInput[]
|
||||
): Promise<ToolInput[]> {
|
||||
if (!Array.isArray(tools) || tools.length === 0) return tools
|
||||
|
||||
const mcpTools = tools.filter((t) => t.type === 'mcp')
|
||||
if (mcpTools.length === 0) return tools
|
||||
|
||||
const serverIds = [...new Set(mcpTools.map((t) => t.params?.serverId).filter(Boolean))]
|
||||
if (serverIds.length === 0) return tools
|
||||
|
||||
const availableServerIds = new Set<string>()
|
||||
if (ctx.workspaceId && serverIds.length > 0) {
|
||||
try {
|
||||
const servers = await db
|
||||
.select({ id: mcpServers.id, connectionStatus: mcpServers.connectionStatus })
|
||||
.from(mcpServers)
|
||||
.where(
|
||||
and(
|
||||
eq(mcpServers.workspaceId, ctx.workspaceId),
|
||||
inArray(mcpServers.id, serverIds),
|
||||
isNull(mcpServers.deletedAt)
|
||||
)
|
||||
)
|
||||
|
||||
for (const server of servers) {
|
||||
if (server.connectionStatus === 'connected') {
|
||||
availableServerIds.add(server.id)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to check MCP server availability, including all tools:', error)
|
||||
for (const serverId of serverIds) {
|
||||
availableServerIds.add(serverId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tools.filter((tool) => {
|
||||
if (tool.type !== 'mcp') return true
|
||||
const serverId = tool.params?.serverId
|
||||
if (!serverId) return false
|
||||
return availableServerIds.has(serverId)
|
||||
})
|
||||
}
|
||||
|
||||
private async formatTools(ctx: ExecutionContext, inputTools: ToolInput[]): Promise<any[]> {
|
||||
if (!Array.isArray(inputTools)) return []
|
||||
|
||||
@@ -304,6 +358,7 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
|
||||
/**
|
||||
* Process MCP tools using cached schemas from build time.
|
||||
* Note: Unavailable tools are already filtered by filterUnavailableMcpTools.
|
||||
*/
|
||||
private async processMcpToolsBatched(
|
||||
ctx: ExecutionContext,
|
||||
@@ -312,7 +367,6 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
if (mcpTools.length === 0) return []
|
||||
|
||||
const results: any[] = []
|
||||
|
||||
const toolsWithSchema: ToolInput[] = []
|
||||
const toolsNeedingDiscovery: ToolInput[] = []
|
||||
|
||||
@@ -439,7 +493,7 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
const discoveredTools = await this.discoverMcpToolsForServer(ctx, serverId)
|
||||
return { serverId, tools, discoveredTools, error: null as Error | null }
|
||||
} catch (error) {
|
||||
logger.error(`Failed to discover tools from server ${serverId}:`, error)
|
||||
logger.error(`Failed to discover tools from server ${serverId}:`)
|
||||
return { serverId, tools, discoveredTools: [] as any[], error: error as Error }
|
||||
}
|
||||
})
|
||||
@@ -829,6 +883,8 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
apiKey: inputs.apiKey,
|
||||
azureEndpoint: inputs.azureEndpoint,
|
||||
azureApiVersion: inputs.azureApiVersion,
|
||||
vertexProject: inputs.vertexProject,
|
||||
vertexLocation: inputs.vertexLocation,
|
||||
responseFormat,
|
||||
workflowId: ctx.workflowId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
@@ -921,6 +977,8 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
apiKey: finalApiKey,
|
||||
azureEndpoint: providerRequest.azureEndpoint,
|
||||
azureApiVersion: providerRequest.azureApiVersion,
|
||||
vertexProject: providerRequest.vertexProject,
|
||||
vertexLocation: providerRequest.vertexLocation,
|
||||
responseFormat: providerRequest.responseFormat,
|
||||
workflowId: providerRequest.workflowId,
|
||||
workspaceId: providerRequest.workspaceId,
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface AgentInputs {
|
||||
apiKey?: string
|
||||
azureEndpoint?: string
|
||||
azureApiVersion?: string
|
||||
vertexProject?: string
|
||||
vertexLocation?: string
|
||||
reasoningEffort?: string
|
||||
verbosity?: string
|
||||
}
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { McpServerStatusConfig } from '@/lib/mcp/types'
|
||||
|
||||
const logger = createLogger('McpQueries')
|
||||
|
||||
/**
|
||||
* Query key factories for MCP-related queries
|
||||
*/
|
||||
export type { McpServerStatusConfig }
|
||||
|
||||
export const mcpKeys = {
|
||||
all: ['mcp'] as const,
|
||||
servers: (workspaceId: string) => [...mcpKeys.all, 'servers', workspaceId] as const,
|
||||
tools: (workspaceId: string) => [...mcpKeys.all, 'tools', workspaceId] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP Server Types
|
||||
*/
|
||||
export interface McpServer {
|
||||
id: string
|
||||
workspaceId: string
|
||||
@@ -25,9 +22,11 @@ export interface McpServer {
|
||||
headers?: Record<string, string>
|
||||
enabled: boolean
|
||||
connectionStatus?: 'connected' | 'disconnected' | 'error'
|
||||
lastError?: string
|
||||
lastError?: string | null
|
||||
statusConfig?: McpServerStatusConfig
|
||||
toolCount?: number
|
||||
lastToolsRefresh?: string
|
||||
lastConnected?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
deletedAt?: string
|
||||
@@ -86,8 +85,13 @@ export function useMcpServers(workspaceId: string) {
|
||||
/**
|
||||
* Fetch MCP tools for a workspace
|
||||
*/
|
||||
async function fetchMcpTools(workspaceId: string): Promise<McpTool[]> {
|
||||
const response = await fetch(`/api/mcp/tools/discover?workspaceId=${workspaceId}`)
|
||||
async function fetchMcpTools(workspaceId: string, forceRefresh = false): Promise<McpTool[]> {
|
||||
const params = new URLSearchParams({ workspaceId })
|
||||
if (forceRefresh) {
|
||||
params.set('refresh', 'true')
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/mcp/tools/discover?${params.toString()}`)
|
||||
|
||||
// Treat 404 as "no tools available" - return empty array
|
||||
if (response.status === 404) {
|
||||
@@ -159,14 +163,43 @@ export function useCreateMcpServer() {
|
||||
return {
|
||||
...serverData,
|
||||
id: serverId,
|
||||
connectionStatus: 'disconnected' as const,
|
||||
connectionStatus: 'connected' as const,
|
||||
serverId,
|
||||
updated: wasUpdated,
|
||||
}
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
onSuccess: async (data, variables) => {
|
||||
const freshTools = await fetchMcpTools(variables.workspaceId, true)
|
||||
|
||||
const previousServers = queryClient.getQueryData<McpServer[]>(
|
||||
mcpKeys.servers(variables.workspaceId)
|
||||
)
|
||||
if (previousServers) {
|
||||
const newServer: McpServer = {
|
||||
id: data.id,
|
||||
workspaceId: variables.workspaceId,
|
||||
name: variables.config.name,
|
||||
transport: variables.config.transport,
|
||||
url: variables.config.url,
|
||||
timeout: variables.config.timeout || 30000,
|
||||
headers: variables.config.headers,
|
||||
enabled: variables.config.enabled,
|
||||
connectionStatus: 'connected',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
const serverExists = previousServers.some((s) => s.id === data.id)
|
||||
queryClient.setQueryData<McpServer[]>(
|
||||
mcpKeys.servers(variables.workspaceId),
|
||||
serverExists
|
||||
? previousServers.map((s) => (s.id === data.id ? { ...s, ...newServer } : s))
|
||||
: [...previousServers, newServer]
|
||||
)
|
||||
}
|
||||
|
||||
queryClient.setQueryData(mcpKeys.tools(variables.workspaceId), freshTools)
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.servers(variables.workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.tools(variables.workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -213,7 +246,7 @@ export function useDeleteMcpServer() {
|
||||
interface UpdateMcpServerParams {
|
||||
workspaceId: string
|
||||
serverId: string
|
||||
updates: Partial<McpServerConfig>
|
||||
updates: Partial<McpServerConfig & { enabled?: boolean }>
|
||||
}
|
||||
|
||||
export function useUpdateMcpServer() {
|
||||
@@ -221,8 +254,20 @@ export function useUpdateMcpServer() {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, serverId, updates }: UpdateMcpServerParams) => {
|
||||
const response = await fetch(`/api/mcp/servers/${serverId}?workspaceId=${workspaceId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to update MCP server')
|
||||
}
|
||||
|
||||
logger.info(`Updated MCP server: ${serverId} in workspace: ${workspaceId}`)
|
||||
return { serverId, updates }
|
||||
return data.data?.server
|
||||
},
|
||||
onMutate: async ({ workspaceId, serverId, updates }) => {
|
||||
await queryClient.cancelQueries({ queryKey: mcpKeys.servers(workspaceId) })
|
||||
@@ -249,6 +294,7 @@ export function useUpdateMcpServer() {
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.servers(variables.workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.tools(variables.workspaceId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -292,9 +338,10 @@ export function useRefreshMcpServer() {
|
||||
logger.info(`Refreshed MCP server: ${serverId}`)
|
||||
return data.data
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
onSuccess: async (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.servers(variables.workspaceId) })
|
||||
queryClient.invalidateQueries({ queryKey: mcpKeys.tools(variables.workspaceId) })
|
||||
const freshTools = await fetchMcpTools(variables.workspaceId, true)
|
||||
queryClient.setQueryData(mcpKeys.tools(variables.workspaceId), freshTools)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -349,3 +396,42 @@ export function useTestMcpServer() {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Stored MCP tool from workflow state
|
||||
*/
|
||||
export interface StoredMcpTool {
|
||||
workflowId: string
|
||||
workflowName: string
|
||||
serverId: string
|
||||
serverUrl?: string
|
||||
toolName: string
|
||||
schema?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch stored MCP tools from all workflows in the workspace
|
||||
*/
|
||||
async function fetchStoredMcpTools(workspaceId: string): Promise<StoredMcpTool[]> {
|
||||
const response = await fetch(`/api/mcp/tools/stored?workspaceId=${workspaceId}`)
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
throw new Error(data.error || 'Failed to fetch stored MCP tools')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.data?.tools || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch stored MCP tools from all workflows
|
||||
*/
|
||||
export function useStoredMcpTools(workspaceId: string) {
|
||||
return useQuery({
|
||||
queryKey: [...mcpKeys.all, workspaceId, 'stored'],
|
||||
queryFn: () => fetchStoredMcpTools(workspaceId),
|
||||
enabled: !!workspaceId,
|
||||
staleTime: 60 * 1000, // 1 minute - workflows don't change frequently
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export const notificationKeys = {
|
||||
|
||||
type NotificationType = 'webhook' | 'email' | 'slack'
|
||||
type LogLevel = 'info' | 'error'
|
||||
type TriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat'
|
||||
type TriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' | 'mcp'
|
||||
|
||||
type AlertRuleType =
|
||||
| 'consecutive_failures'
|
||||
|
||||
@@ -142,6 +142,13 @@ export function useConnectOAuthService() {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// ServiceNow requires a custom OAuth flow with instance URL input
|
||||
if (providerId === 'servicenow') {
|
||||
const returnUrl = encodeURIComponent(callbackURL)
|
||||
window.location.href = `/api/auth/servicenow/authorize?returnUrl=${returnUrl}`
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
await client.oauth2.link({
|
||||
providerId,
|
||||
callbackURL,
|
||||
|
||||
508
apps/sim/hooks/queries/workflow-mcp-servers.ts
Normal file
508
apps/sim/hooks/queries/workflow-mcp-servers.ts
Normal file
@@ -0,0 +1,508 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('WorkflowMcpServerQueries')
|
||||
|
||||
/**
|
||||
* Query key factories for Workflow MCP Server queries
|
||||
*/
|
||||
export const workflowMcpServerKeys = {
|
||||
all: ['workflow-mcp-servers'] as const,
|
||||
servers: (workspaceId: string) => [...workflowMcpServerKeys.all, 'servers', workspaceId] as const,
|
||||
server: (workspaceId: string, serverId: string) =>
|
||||
[...workflowMcpServerKeys.servers(workspaceId), serverId] as const,
|
||||
tools: (workspaceId: string, serverId: string) =>
|
||||
[...workflowMcpServerKeys.server(workspaceId, serverId), 'tools'] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow MCP Server Types
|
||||
*/
|
||||
export interface WorkflowMcpServer {
|
||||
id: string
|
||||
workspaceId: string
|
||||
createdBy: string
|
||||
name: string
|
||||
description: string | null
|
||||
isPublished: boolean
|
||||
publishedAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
toolCount?: number
|
||||
}
|
||||
|
||||
export interface WorkflowMcpTool {
|
||||
id: string
|
||||
serverId: string
|
||||
workflowId: string
|
||||
toolName: string
|
||||
toolDescription: string | null
|
||||
parameterSchema: Record<string, unknown>
|
||||
isEnabled: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
workflowName?: string
|
||||
workflowDescription?: string | null
|
||||
isDeployed?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch workflow MCP servers for a workspace
|
||||
*/
|
||||
async function fetchWorkflowMcpServers(workspaceId: string): Promise<WorkflowMcpServer[]> {
|
||||
const response = await fetch(`/api/mcp/workflow-servers?workspaceId=${workspaceId}`)
|
||||
|
||||
if (response.status === 404) {
|
||||
return []
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to fetch workflow MCP servers')
|
||||
}
|
||||
|
||||
return data.data?.servers || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch workflow MCP servers
|
||||
*/
|
||||
export function useWorkflowMcpServers(workspaceId: string) {
|
||||
return useQuery({
|
||||
queryKey: workflowMcpServerKeys.servers(workspaceId),
|
||||
queryFn: () => fetchWorkflowMcpServers(workspaceId),
|
||||
enabled: !!workspaceId,
|
||||
retry: false,
|
||||
staleTime: 60 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single workflow MCP server with its tools
|
||||
*/
|
||||
async function fetchWorkflowMcpServer(
|
||||
workspaceId: string,
|
||||
serverId: string
|
||||
): Promise<{ server: WorkflowMcpServer; tools: WorkflowMcpTool[] }> {
|
||||
const response = await fetch(`/api/mcp/workflow-servers/${serverId}?workspaceId=${workspaceId}`)
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to fetch workflow MCP server')
|
||||
}
|
||||
|
||||
return {
|
||||
server: data.data?.server,
|
||||
tools: data.data?.tools || [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch a single workflow MCP server
|
||||
*/
|
||||
export function useWorkflowMcpServer(workspaceId: string, serverId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: workflowMcpServerKeys.server(workspaceId, serverId || ''),
|
||||
queryFn: () => fetchWorkflowMcpServer(workspaceId, serverId!),
|
||||
enabled: !!workspaceId && !!serverId,
|
||||
retry: false,
|
||||
staleTime: 30 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch tools for a workflow MCP server
|
||||
*/
|
||||
async function fetchWorkflowMcpTools(
|
||||
workspaceId: string,
|
||||
serverId: string
|
||||
): Promise<WorkflowMcpTool[]> {
|
||||
const response = await fetch(
|
||||
`/api/mcp/workflow-servers/${serverId}/tools?workspaceId=${workspaceId}`
|
||||
)
|
||||
|
||||
if (response.status === 404) {
|
||||
return []
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to fetch workflow MCP tools')
|
||||
}
|
||||
|
||||
return data.data?.tools || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch tools for a workflow MCP server
|
||||
*/
|
||||
export function useWorkflowMcpTools(workspaceId: string, serverId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: workflowMcpServerKeys.tools(workspaceId, serverId || ''),
|
||||
queryFn: () => fetchWorkflowMcpTools(workspaceId, serverId!),
|
||||
enabled: !!workspaceId && !!serverId,
|
||||
retry: false,
|
||||
staleTime: 30 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create workflow MCP server mutation
|
||||
*/
|
||||
interface CreateWorkflowMcpServerParams {
|
||||
workspaceId: string
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function useCreateWorkflowMcpServer() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, name, description }: CreateWorkflowMcpServerParams) => {
|
||||
const response = await fetch('/api/mcp/workflow-servers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workspaceId, name, description }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to create workflow MCP server')
|
||||
}
|
||||
|
||||
logger.info(`Created workflow MCP server: ${name}`)
|
||||
return data.data?.server as WorkflowMcpServer
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workflowMcpServerKeys.servers(variables.workspaceId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update workflow MCP server mutation
|
||||
*/
|
||||
interface UpdateWorkflowMcpServerParams {
|
||||
workspaceId: string
|
||||
serverId: string
|
||||
name?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function useUpdateWorkflowMcpServer() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
workspaceId,
|
||||
serverId,
|
||||
name,
|
||||
description,
|
||||
}: UpdateWorkflowMcpServerParams) => {
|
||||
const response = await fetch(
|
||||
`/api/mcp/workflow-servers/${serverId}?workspaceId=${workspaceId}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, description }),
|
||||
}
|
||||
)
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to update workflow MCP server')
|
||||
}
|
||||
|
||||
logger.info(`Updated workflow MCP server: ${serverId}`)
|
||||
return data.data?.server as WorkflowMcpServer
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workflowMcpServerKeys.servers(variables.workspaceId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workflowMcpServerKeys.server(variables.workspaceId, variables.serverId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete workflow MCP server mutation
|
||||
*/
|
||||
interface DeleteWorkflowMcpServerParams {
|
||||
workspaceId: string
|
||||
serverId: string
|
||||
}
|
||||
|
||||
export function useDeleteWorkflowMcpServer() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, serverId }: DeleteWorkflowMcpServerParams) => {
|
||||
const response = await fetch(
|
||||
`/api/mcp/workflow-servers/${serverId}?workspaceId=${workspaceId}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
)
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to delete workflow MCP server')
|
||||
}
|
||||
|
||||
logger.info(`Deleted workflow MCP server: ${serverId}`)
|
||||
return data
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workflowMcpServerKeys.servers(variables.workspaceId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish workflow MCP server mutation
|
||||
*/
|
||||
interface PublishWorkflowMcpServerParams {
|
||||
workspaceId: string
|
||||
serverId: string
|
||||
}
|
||||
|
||||
export interface PublishWorkflowMcpServerResult {
|
||||
server: WorkflowMcpServer
|
||||
mcpServerUrl: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export function usePublishWorkflowMcpServer() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
workspaceId,
|
||||
serverId,
|
||||
}: PublishWorkflowMcpServerParams): Promise<PublishWorkflowMcpServerResult> => {
|
||||
const response = await fetch(
|
||||
`/api/mcp/workflow-servers/${serverId}/publish?workspaceId=${workspaceId}`,
|
||||
{
|
||||
method: 'POST',
|
||||
}
|
||||
)
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to publish workflow MCP server')
|
||||
}
|
||||
|
||||
logger.info(`Published workflow MCP server: ${serverId}`)
|
||||
return data.data
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workflowMcpServerKeys.servers(variables.workspaceId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workflowMcpServerKeys.server(variables.workspaceId, variables.serverId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpublish workflow MCP server mutation
|
||||
*/
|
||||
export function useUnpublishWorkflowMcpServer() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, serverId }: PublishWorkflowMcpServerParams) => {
|
||||
const response = await fetch(
|
||||
`/api/mcp/workflow-servers/${serverId}/publish?workspaceId=${workspaceId}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
)
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to unpublish workflow MCP server')
|
||||
}
|
||||
|
||||
logger.info(`Unpublished workflow MCP server: ${serverId}`)
|
||||
return data.data
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workflowMcpServerKeys.servers(variables.workspaceId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workflowMcpServerKeys.server(variables.workspaceId, variables.serverId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Add tool to workflow MCP server mutation
|
||||
*/
|
||||
interface AddWorkflowMcpToolParams {
|
||||
workspaceId: string
|
||||
serverId: string
|
||||
workflowId: string
|
||||
toolName?: string
|
||||
toolDescription?: string
|
||||
parameterSchema?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function useAddWorkflowMcpTool() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
workspaceId,
|
||||
serverId,
|
||||
workflowId,
|
||||
toolName,
|
||||
toolDescription,
|
||||
parameterSchema,
|
||||
}: AddWorkflowMcpToolParams) => {
|
||||
const response = await fetch(
|
||||
`/api/mcp/workflow-servers/${serverId}/tools?workspaceId=${workspaceId}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workflowId, toolName, toolDescription, parameterSchema }),
|
||||
}
|
||||
)
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to add tool to workflow MCP server')
|
||||
}
|
||||
|
||||
logger.info(`Added tool to workflow MCP server: ${serverId}`)
|
||||
return data.data?.tool as WorkflowMcpTool
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workflowMcpServerKeys.servers(variables.workspaceId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workflowMcpServerKeys.server(variables.workspaceId, variables.serverId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workflowMcpServerKeys.tools(variables.workspaceId, variables.serverId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update tool mutation
|
||||
*/
|
||||
interface UpdateWorkflowMcpToolParams {
|
||||
workspaceId: string
|
||||
serverId: string
|
||||
toolId: string
|
||||
toolName?: string
|
||||
toolDescription?: string
|
||||
parameterSchema?: Record<string, unknown>
|
||||
isEnabled?: boolean
|
||||
}
|
||||
|
||||
export function useUpdateWorkflowMcpTool() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
workspaceId,
|
||||
serverId,
|
||||
toolId,
|
||||
...updates
|
||||
}: UpdateWorkflowMcpToolParams) => {
|
||||
const response = await fetch(
|
||||
`/api/mcp/workflow-servers/${serverId}/tools/${toolId}?workspaceId=${workspaceId}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
}
|
||||
)
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to update tool')
|
||||
}
|
||||
|
||||
logger.info(`Updated tool ${toolId} in workflow MCP server: ${serverId}`)
|
||||
return data.data?.tool as WorkflowMcpTool
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workflowMcpServerKeys.tools(variables.workspaceId, variables.serverId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete tool mutation
|
||||
*/
|
||||
interface DeleteWorkflowMcpToolParams {
|
||||
workspaceId: string
|
||||
serverId: string
|
||||
toolId: string
|
||||
}
|
||||
|
||||
export function useDeleteWorkflowMcpTool() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workspaceId, serverId, toolId }: DeleteWorkflowMcpToolParams) => {
|
||||
const response = await fetch(
|
||||
`/api/mcp/workflow-servers/${serverId}/tools/${toolId}?workspaceId=${workspaceId}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
)
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to delete tool')
|
||||
}
|
||||
|
||||
logger.info(`Deleted tool ${toolId} from workflow MCP server: ${serverId}`)
|
||||
return data
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workflowMcpServerKeys.servers(variables.workspaceId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workflowMcpServerKeys.server(variables.workspaceId, variables.serverId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: workflowMcpServerKeys.tools(variables.workspaceId, variables.serverId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1540,7 +1540,7 @@ export function useCollaborativeWorkflow() {
|
||||
const config = {
|
||||
id: nodeId,
|
||||
nodes: childNodes,
|
||||
iterations: Math.max(1, Math.min(100, count)), // Clamp between 1-100 for loops
|
||||
iterations: Math.max(1, Math.min(1000, count)), // Clamp between 1-1000 for loops
|
||||
loopType: currentLoopType,
|
||||
forEachItems: currentCollection,
|
||||
}
|
||||
|
||||
@@ -34,14 +34,19 @@ export function useMcpServerTest() {
|
||||
const [isTestingConnection, setIsTestingConnection] = useState(false)
|
||||
|
||||
const testConnection = useCallback(
|
||||
async (config: McpServerTestConfig): Promise<McpServerTestResult> => {
|
||||
async (
|
||||
config: McpServerTestConfig,
|
||||
options?: { silent?: boolean }
|
||||
): Promise<McpServerTestResult> => {
|
||||
const { silent = false } = options || {}
|
||||
|
||||
if (!config.name || !config.transport || !config.workspaceId) {
|
||||
const result: McpServerTestResult = {
|
||||
success: false,
|
||||
message: 'Missing required configuration',
|
||||
error: 'Please provide server name, transport method, and workspace ID',
|
||||
}
|
||||
setTestResult(result)
|
||||
if (!silent) setTestResult(result)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -51,12 +56,14 @@ export function useMcpServerTest() {
|
||||
message: 'Missing server URL',
|
||||
error: 'Please provide a server URL for HTTP/SSE transport',
|
||||
}
|
||||
setTestResult(result)
|
||||
if (!silent) setTestResult(result)
|
||||
return result
|
||||
}
|
||||
|
||||
setIsTestingConnection(true)
|
||||
setTestResult(null)
|
||||
if (!silent) {
|
||||
setIsTestingConnection(true)
|
||||
setTestResult(null)
|
||||
}
|
||||
|
||||
try {
|
||||
const cleanConfig = {
|
||||
@@ -88,14 +95,14 @@ export function useMcpServerTest() {
|
||||
error: result.data.error,
|
||||
warnings: result.data.warnings,
|
||||
}
|
||||
setTestResult(testResult)
|
||||
if (!silent) setTestResult(testResult)
|
||||
logger.error('MCP server test failed:', result.data.error)
|
||||
return testResult
|
||||
}
|
||||
throw new Error(result.error || 'Connection test failed')
|
||||
}
|
||||
|
||||
setTestResult(result.data || result)
|
||||
if (!silent) setTestResult(result.data || result)
|
||||
logger.info(`MCP server test ${result.data?.success ? 'passed' : 'failed'}:`, config.name)
|
||||
return result.data || result
|
||||
} catch (error) {
|
||||
@@ -105,11 +112,11 @@ export function useMcpServerTest() {
|
||||
message: 'Connection failed',
|
||||
error: errorMessage,
|
||||
}
|
||||
setTestResult(result)
|
||||
if (!silent) setTestResult(result)
|
||||
logger.error('MCP server test failed:', errorMessage)
|
||||
return result
|
||||
} finally {
|
||||
setIsTestingConnection(false)
|
||||
if (!silent) setIsTestingConnection(false)
|
||||
}
|
||||
},
|
||||
[]
|
||||
|
||||
@@ -14,6 +14,7 @@ interface UseWebhookManagementProps {
|
||||
blockId: string
|
||||
triggerId?: string
|
||||
isPreview?: boolean
|
||||
useWebhookUrl?: boolean
|
||||
}
|
||||
|
||||
interface WebhookManagementState {
|
||||
@@ -90,6 +91,7 @@ export function useWebhookManagement({
|
||||
blockId,
|
||||
triggerId,
|
||||
isPreview = false,
|
||||
useWebhookUrl = false,
|
||||
}: UseWebhookManagementProps): WebhookManagementState {
|
||||
const params = useParams()
|
||||
const workflowId = params.workflowId as string
|
||||
@@ -134,7 +136,6 @@ export function useWebhookManagement({
|
||||
const currentlyLoading = store.loadingWebhooks.has(blockId)
|
||||
const alreadyChecked = store.checkedWebhooks.has(blockId)
|
||||
const currentWebhookId = store.getValue(blockId, 'webhookId')
|
||||
|
||||
if (currentlyLoading || (alreadyChecked && currentWebhookId)) {
|
||||
return
|
||||
}
|
||||
@@ -205,7 +206,9 @@ export function useWebhookManagement({
|
||||
}
|
||||
}
|
||||
|
||||
loadWebhookOrGenerateUrl()
|
||||
if (useWebhookUrl) {
|
||||
loadWebhookOrGenerateUrl()
|
||||
}
|
||||
}, [isPreview, triggerId, workflowId, blockId])
|
||||
|
||||
const createWebhook = async (
|
||||
|
||||
@@ -148,7 +148,14 @@ export type CopilotProviderConfig =
|
||||
endpoint?: string
|
||||
}
|
||||
| {
|
||||
provider: Exclude<ProviderId, 'azure-openai'>
|
||||
provider: 'vertex'
|
||||
model: string
|
||||
apiKey?: string
|
||||
vertexProject?: string
|
||||
vertexLocation?: string
|
||||
}
|
||||
| {
|
||||
provider: Exclude<ProviderId, 'azure-openai' | 'vertex'>
|
||||
model?: string
|
||||
apiKey?: string
|
||||
}
|
||||
|
||||
@@ -98,6 +98,10 @@ export const env = createEnv({
|
||||
OCR_AZURE_MODEL_NAME: z.string().optional(), // Azure Mistral OCR model name for document processing
|
||||
OCR_AZURE_API_KEY: z.string().min(1).optional(), // Azure Mistral OCR API key
|
||||
|
||||
// Vertex AI Configuration
|
||||
VERTEX_PROJECT: z.string().optional(), // Google Cloud project ID for Vertex AI
|
||||
VERTEX_LOCATION: z.string().optional(), // Google Cloud location/region for Vertex AI (defaults to us-central1)
|
||||
|
||||
// Monitoring & Analytics
|
||||
TELEMETRY_ENDPOINT: z.string().url().optional(), // Custom telemetry/analytics endpoint
|
||||
COST_MULTIPLIER: z.number().optional(), // Multiplier for cost calculations
|
||||
@@ -233,6 +237,8 @@ export const env = createEnv({
|
||||
WORDPRESS_CLIENT_SECRET: z.string().optional(), // WordPress.com OAuth client secret
|
||||
SPOTIFY_CLIENT_ID: z.string().optional(), // Spotify OAuth client ID
|
||||
SPOTIFY_CLIENT_SECRET: z.string().optional(), // Spotify OAuth client secret
|
||||
SERVICENOW_CLIENT_ID: z.string().optional(), // ServiceNow OAuth client ID
|
||||
SERVICENOW_CLIENT_SECRET: z.string().optional(), // ServiceNow OAuth client secret
|
||||
|
||||
// E2B Remote Code Execution
|
||||
E2B_ENABLED: z.string().optional(), // Enable E2B remote code execution
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import type { TokenBucketConfig } from './storage'
|
||||
|
||||
export type TriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' | 'api-endpoint'
|
||||
export type TriggerType =
|
||||
| 'api'
|
||||
| 'webhook'
|
||||
| 'schedule'
|
||||
| 'manual'
|
||||
| 'chat'
|
||||
| 'mcp'
|
||||
| 'api-endpoint'
|
||||
|
||||
export type RateLimitCounterType = 'sync' | 'async' | 'api-endpoint'
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ export interface PreprocessExecutionOptions {
|
||||
// Required fields
|
||||
workflowId: string
|
||||
userId: string // The authenticated user ID
|
||||
triggerType: 'manual' | 'api' | 'webhook' | 'schedule' | 'chat'
|
||||
triggerType: 'manual' | 'api' | 'webhook' | 'schedule' | 'chat' | 'mcp'
|
||||
executionId: string
|
||||
requestId: string
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ export function getTriggerOptions(): TriggerOption[] {
|
||||
{ value: 'schedule', label: 'Schedule', color: '#059669' },
|
||||
{ value: 'chat', label: 'Chat', color: '#7c3aed' },
|
||||
{ value: 'webhook', label: 'Webhook', color: '#ea580c' },
|
||||
{ value: 'mcp', label: 'MCP', color: '#dc2626' },
|
||||
]
|
||||
|
||||
for (const trigger of triggers) {
|
||||
|
||||
@@ -23,6 +23,8 @@ const FILTER_FIELDS = {
|
||||
workflow: 'string',
|
||||
trigger: 'string',
|
||||
execution: 'string',
|
||||
executionId: 'string',
|
||||
workflowId: 'string',
|
||||
id: 'string',
|
||||
cost: 'number',
|
||||
duration: 'number',
|
||||
@@ -215,11 +217,13 @@ export function queryToApiParams(parsedQuery: ParsedQuery): Record<string, strin
|
||||
break
|
||||
|
||||
case 'cost':
|
||||
params[`cost_${filter.operator}_${filter.value}`] = 'true'
|
||||
params.costOperator = filter.operator
|
||||
params.costValue = String(filter.value)
|
||||
break
|
||||
|
||||
case 'duration':
|
||||
params[`duration_${filter.operator}_${filter.value}`] = 'true'
|
||||
params.durationOperator = filter.operator
|
||||
params.durationValue = String(filter.value)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,8 +38,6 @@ export const FILTER_DEFINITIONS: FilterDefinition[] = [
|
||||
{ value: 'info', label: 'Info', description: 'Info logs only' },
|
||||
],
|
||||
},
|
||||
// Note: Trigger options are now dynamically populated from active logs
|
||||
// Core types are included by default, integration triggers are added from actual log data
|
||||
{
|
||||
key: 'cost',
|
||||
label: 'Cost',
|
||||
@@ -82,14 +80,6 @@ export const FILTER_DEFINITIONS: FilterDefinition[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const CORE_TRIGGERS: TriggerData[] = [
|
||||
{ value: 'api', label: 'API', color: '#3b82f6' },
|
||||
{ value: 'manual', label: 'Manual', color: '#6b7280' },
|
||||
{ value: 'webhook', label: 'Webhook', color: '#f97316' },
|
||||
{ value: 'chat', label: 'Chat', color: '#8b5cf6' },
|
||||
{ value: 'schedule', label: 'Schedule', color: '#10b981' },
|
||||
]
|
||||
|
||||
export class SearchSuggestions {
|
||||
private workflowsData: WorkflowData[]
|
||||
private foldersData: FolderData[]
|
||||
@@ -116,10 +106,10 @@ export class SearchSuggestions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all triggers (core + integrations)
|
||||
* Get all triggers from registry data
|
||||
*/
|
||||
private getAllTriggers(): TriggerData[] {
|
||||
return [...CORE_TRIGGERS, ...this.triggersData]
|
||||
return this.triggersData
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -128,24 +118,20 @@ export class SearchSuggestions {
|
||||
getSuggestions(input: string): SuggestionGroup | null {
|
||||
const trimmed = input.trim()
|
||||
|
||||
// Empty input → show all filter keys
|
||||
if (!trimmed) {
|
||||
return this.getFilterKeysList()
|
||||
}
|
||||
|
||||
// Input ends with ':' → show values for that key
|
||||
if (trimmed.endsWith(':')) {
|
||||
const key = trimmed.slice(0, -1)
|
||||
return this.getFilterValues(key)
|
||||
}
|
||||
|
||||
// Input contains ':' → filter value context
|
||||
if (trimmed.includes(':')) {
|
||||
const [key, partial] = trimmed.split(':')
|
||||
return this.getFilterValues(key, partial)
|
||||
}
|
||||
|
||||
// Plain text → multi-section results
|
||||
return this.getMultiSectionResults(trimmed)
|
||||
}
|
||||
|
||||
@@ -155,7 +141,6 @@ export class SearchSuggestions {
|
||||
private getFilterKeysList(): SuggestionGroup {
|
||||
const suggestions: Suggestion[] = []
|
||||
|
||||
// Add all filter keys
|
||||
for (const filter of FILTER_DEFINITIONS) {
|
||||
suggestions.push({
|
||||
id: `filter-key-${filter.key}`,
|
||||
@@ -166,7 +151,6 @@ export class SearchSuggestions {
|
||||
})
|
||||
}
|
||||
|
||||
// Add trigger key (always available - core types + integrations)
|
||||
suggestions.push({
|
||||
id: 'filter-key-trigger',
|
||||
value: 'trigger:',
|
||||
@@ -175,7 +159,6 @@ export class SearchSuggestions {
|
||||
category: 'filters',
|
||||
})
|
||||
|
||||
// Add workflow and folder keys
|
||||
if (this.workflowsData.length > 0) {
|
||||
suggestions.push({
|
||||
id: 'filter-key-workflow',
|
||||
@@ -249,12 +232,10 @@ export class SearchSuggestions {
|
||||
: null
|
||||
}
|
||||
|
||||
// Trigger filter values (core + integrations)
|
||||
if (key === 'trigger') {
|
||||
const allTriggers = this.getAllTriggers()
|
||||
const suggestions = allTriggers
|
||||
.filter((t) => !partial || t.label.toLowerCase().includes(partial.toLowerCase()))
|
||||
.slice(0, 15) // Show more since we have core + integrations
|
||||
.map((t) => ({
|
||||
id: `filter-value-trigger-${t.value}`,
|
||||
value: `trigger:${t.value}`,
|
||||
@@ -273,11 +254,9 @@ export class SearchSuggestions {
|
||||
: null
|
||||
}
|
||||
|
||||
// Workflow filter values
|
||||
if (key === 'workflow') {
|
||||
const suggestions = this.workflowsData
|
||||
.filter((w) => !partial || w.name.toLowerCase().includes(partial.toLowerCase()))
|
||||
.slice(0, 8)
|
||||
.map((w) => ({
|
||||
id: `filter-value-workflow-${w.id}`,
|
||||
value: `workflow:"${w.name}"`,
|
||||
@@ -295,11 +274,9 @@ export class SearchSuggestions {
|
||||
: null
|
||||
}
|
||||
|
||||
// Folder filter values
|
||||
if (key === 'folder') {
|
||||
const suggestions = this.foldersData
|
||||
.filter((f) => !partial || f.name.toLowerCase().includes(partial.toLowerCase()))
|
||||
.slice(0, 8)
|
||||
.map((f) => ({
|
||||
id: `filter-value-folder-${f.id}`,
|
||||
value: `folder:"${f.name}"`,
|
||||
@@ -326,7 +303,6 @@ export class SearchSuggestions {
|
||||
const sections: Array<{ title: string; suggestions: Suggestion[] }> = []
|
||||
const allSuggestions: Suggestion[] = []
|
||||
|
||||
// Show all results option
|
||||
const showAllSuggestion: Suggestion = {
|
||||
id: 'show-all',
|
||||
value: query,
|
||||
@@ -335,7 +311,6 @@ export class SearchSuggestions {
|
||||
}
|
||||
allSuggestions.push(showAllSuggestion)
|
||||
|
||||
// Match filter values (e.g., "info" → "Status: Info")
|
||||
const matchingFilterValues = this.getMatchingFilterValues(query)
|
||||
if (matchingFilterValues.length > 0) {
|
||||
sections.push({
|
||||
@@ -345,7 +320,6 @@ export class SearchSuggestions {
|
||||
allSuggestions.push(...matchingFilterValues)
|
||||
}
|
||||
|
||||
// Match triggers
|
||||
const matchingTriggers = this.getMatchingTriggers(query)
|
||||
if (matchingTriggers.length > 0) {
|
||||
sections.push({
|
||||
@@ -355,7 +329,6 @@ export class SearchSuggestions {
|
||||
allSuggestions.push(...matchingTriggers)
|
||||
}
|
||||
|
||||
// Match workflows
|
||||
const matchingWorkflows = this.getMatchingWorkflows(query)
|
||||
if (matchingWorkflows.length > 0) {
|
||||
sections.push({
|
||||
@@ -365,7 +338,6 @@ export class SearchSuggestions {
|
||||
allSuggestions.push(...matchingWorkflows)
|
||||
}
|
||||
|
||||
// Match folders
|
||||
const matchingFolders = this.getMatchingFolders(query)
|
||||
if (matchingFolders.length > 0) {
|
||||
sections.push({
|
||||
@@ -375,7 +347,6 @@ export class SearchSuggestions {
|
||||
allSuggestions.push(...matchingFolders)
|
||||
}
|
||||
|
||||
// Add filter keys if no specific matches
|
||||
if (
|
||||
matchingFilterValues.length === 0 &&
|
||||
matchingTriggers.length === 0 &&
|
||||
|
||||
@@ -108,7 +108,7 @@ export class McpClient {
|
||||
this.connectionStatus.lastError = errorMessage
|
||||
this.isConnected = false
|
||||
logger.error(`Failed to connect to MCP server ${this.config.name}:`, error)
|
||||
throw new McpConnectionError(errorMessage, this.config.id)
|
||||
throw new McpConnectionError(errorMessage, this.config.name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ export class McpClient {
|
||||
*/
|
||||
async listTools(): Promise<McpTool[]> {
|
||||
if (!this.isConnected) {
|
||||
throw new McpConnectionError('Not connected to server', this.config.id)
|
||||
throw new McpConnectionError('Not connected to server', this.config.name)
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -170,7 +170,7 @@ export class McpClient {
|
||||
*/
|
||||
async callTool(toolCall: McpToolCall): Promise<McpToolResult> {
|
||||
if (!this.isConnected) {
|
||||
throw new McpConnectionError('Not connected to server', this.config.id)
|
||||
throw new McpConnectionError('Not connected to server', this.config.name)
|
||||
}
|
||||
|
||||
const consentRequest: McpConsentRequest = {
|
||||
@@ -217,7 +217,7 @@ export class McpClient {
|
||||
*/
|
||||
async ping(): Promise<{ _meta?: Record<string, any> }> {
|
||||
if (!this.isConnected) {
|
||||
throw new McpConnectionError('Not connected to server', this.config.id)
|
||||
throw new McpConnectionError('Not connected to server', this.config.name)
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
115
apps/sim/lib/mcp/serve-auth.ts
Normal file
115
apps/sim/lib/mcp/serve-auth.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflowMcpServer } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('McpServeAuth')
|
||||
|
||||
export interface McpServeAuthResult {
|
||||
success: boolean
|
||||
userId?: string
|
||||
workspaceId?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates authentication for accessing a workflow MCP server.
|
||||
*
|
||||
* Authentication can be done via:
|
||||
* 1. API Key (X-API-Key header) - for programmatic access
|
||||
* 2. Session cookie - for logged-in users
|
||||
*
|
||||
* The user must have at least read access to the workspace that owns the server.
|
||||
*/
|
||||
export async function validateMcpServeAuth(
|
||||
request: NextRequest,
|
||||
serverId: string
|
||||
): Promise<McpServeAuthResult> {
|
||||
try {
|
||||
// First, get the server to find its workspace
|
||||
const [server] = await db
|
||||
.select({
|
||||
id: workflowMcpServer.id,
|
||||
workspaceId: workflowMcpServer.workspaceId,
|
||||
isPublished: workflowMcpServer.isPublished,
|
||||
})
|
||||
.from(workflowMcpServer)
|
||||
.where(eq(workflowMcpServer.id, serverId))
|
||||
.limit(1)
|
||||
|
||||
if (!server) {
|
||||
return { success: false, error: 'Server not found' }
|
||||
}
|
||||
|
||||
if (!server.isPublished) {
|
||||
return { success: false, error: 'Server is not published' }
|
||||
}
|
||||
|
||||
// Check authentication using hybrid auth (supports both session and API key)
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!auth.success || !auth.userId) {
|
||||
return { success: false, error: auth.error || 'Authentication required' }
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userId: auth.userId,
|
||||
workspaceId: server.workspaceId,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error validating MCP serve auth:', error)
|
||||
return {
|
||||
success: false,
|
||||
error: 'Authentication validation failed',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection instructions for an MCP server.
|
||||
* This provides the information users need to connect their MCP clients.
|
||||
*/
|
||||
export function getMcpServerConnectionInfo(
|
||||
serverId: string,
|
||||
serverName: string,
|
||||
baseUrl: string
|
||||
): {
|
||||
sseUrl: string
|
||||
httpUrl: string
|
||||
authHeader: string
|
||||
instructions: string
|
||||
} {
|
||||
const sseUrl = `${baseUrl}/api/mcp/serve/${serverId}/sse`
|
||||
const httpUrl = `${baseUrl}/api/mcp/serve/${serverId}`
|
||||
|
||||
return {
|
||||
sseUrl,
|
||||
httpUrl,
|
||||
authHeader: 'X-API-Key: YOUR_SIM_API_KEY',
|
||||
instructions: `
|
||||
To connect to this MCP server from Cursor or Claude Desktop:
|
||||
|
||||
1. Get your Sim API key from Settings -> API Keys
|
||||
2. Configure your MCP client with:
|
||||
- Server URL: ${sseUrl}
|
||||
- Authentication: Add header "X-API-Key" with your API key
|
||||
|
||||
For Cursor, add to your MCP configuration:
|
||||
{
|
||||
"mcpServers": {
|
||||
"${serverName.toLowerCase().replace(/\s+/g, '-')}": {
|
||||
"url": "${sseUrl}",
|
||||
"headers": {
|
||||
"X-API-Key": "YOUR_SIM_API_KEY"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
For Claude Desktop, configure similarly in your settings.
|
||||
`.trim(),
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,14 @@ import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { McpClient } from '@/lib/mcp/client'
|
||||
import {
|
||||
createMcpCacheAdapter,
|
||||
getMcpCacheType,
|
||||
type McpCacheStorageAdapter,
|
||||
} from '@/lib/mcp/storage'
|
||||
import type {
|
||||
McpServerConfig,
|
||||
McpServerStatusConfig,
|
||||
McpServerSummary,
|
||||
McpTool,
|
||||
McpToolCall,
|
||||
@@ -22,154 +28,21 @@ import { MCP_CONSTANTS } from '@/lib/mcp/utils'
|
||||
|
||||
const logger = createLogger('McpService')
|
||||
|
||||
interface ToolCache {
|
||||
tools: McpTool[]
|
||||
expiry: Date
|
||||
lastAccessed: Date
|
||||
}
|
||||
|
||||
interface CacheStats {
|
||||
totalEntries: number
|
||||
activeEntries: number
|
||||
expiredEntries: number
|
||||
maxCacheSize: number
|
||||
cacheHitRate: number
|
||||
memoryUsage: {
|
||||
approximateBytes: number
|
||||
entriesEvicted: number
|
||||
}
|
||||
}
|
||||
|
||||
class McpService {
|
||||
private toolCache = new Map<string, ToolCache>()
|
||||
private readonly cacheTimeout = MCP_CONSTANTS.CACHE_TIMEOUT // 30 seconds
|
||||
private readonly maxCacheSize = MCP_CONSTANTS.MAX_CACHE_SIZE // 1000
|
||||
private cleanupInterval: NodeJS.Timeout | null = null
|
||||
private cacheHits = 0
|
||||
private cacheMisses = 0
|
||||
private entriesEvicted = 0
|
||||
private cacheAdapter: McpCacheStorageAdapter
|
||||
private readonly cacheTimeout = MCP_CONSTANTS.CACHE_TIMEOUT // 5 minutes
|
||||
|
||||
constructor() {
|
||||
this.startPeriodicCleanup()
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic cleanup of expired cache entries
|
||||
*/
|
||||
private startPeriodicCleanup(): void {
|
||||
this.cleanupInterval = setInterval(
|
||||
() => {
|
||||
this.cleanupExpiredEntries()
|
||||
},
|
||||
5 * 60 * 1000
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop periodic cleanup
|
||||
*/
|
||||
private stopPeriodicCleanup(): void {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval)
|
||||
this.cleanupInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup expired cache entries
|
||||
*/
|
||||
private cleanupExpiredEntries(): void {
|
||||
const now = new Date()
|
||||
const expiredKeys: string[] = []
|
||||
|
||||
this.toolCache.forEach((cache, key) => {
|
||||
if (cache.expiry <= now) {
|
||||
expiredKeys.push(key)
|
||||
}
|
||||
})
|
||||
|
||||
expiredKeys.forEach((key) => this.toolCache.delete(key))
|
||||
|
||||
if (expiredKeys.length > 0) {
|
||||
logger.debug(`Cleaned up ${expiredKeys.length} expired cache entries`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evict least recently used entries when cache exceeds max size
|
||||
*/
|
||||
private evictLRUEntries(): void {
|
||||
if (this.toolCache.size <= this.maxCacheSize) {
|
||||
return
|
||||
}
|
||||
|
||||
const entries: { key: string; cache: ToolCache }[] = []
|
||||
this.toolCache.forEach((cache, key) => {
|
||||
entries.push({ key, cache })
|
||||
})
|
||||
entries.sort((a, b) => a.cache.lastAccessed.getTime() - b.cache.lastAccessed.getTime())
|
||||
|
||||
const entriesToRemove = this.toolCache.size - this.maxCacheSize + 1
|
||||
for (let i = 0; i < entriesToRemove && i < entries.length; i++) {
|
||||
this.toolCache.delete(entries[i].key)
|
||||
this.entriesEvicted++
|
||||
}
|
||||
|
||||
logger.debug(`Evicted ${entriesToRemove} LRU cache entries to maintain size limit`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache entry and update last accessed time
|
||||
*/
|
||||
private getCacheEntry(key: string): ToolCache | undefined {
|
||||
const entry = this.toolCache.get(key)
|
||||
if (entry) {
|
||||
entry.lastAccessed = new Date()
|
||||
this.cacheHits++
|
||||
return entry
|
||||
}
|
||||
this.cacheMisses++
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cache entry with LRU eviction
|
||||
*/
|
||||
private setCacheEntry(key: string, tools: McpTool[]): void {
|
||||
const now = new Date()
|
||||
const cache: ToolCache = {
|
||||
tools,
|
||||
expiry: new Date(now.getTime() + this.cacheTimeout),
|
||||
lastAccessed: now,
|
||||
}
|
||||
|
||||
this.toolCache.set(key, cache)
|
||||
|
||||
this.evictLRUEntries()
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate approximate memory usage of cache
|
||||
*/
|
||||
private calculateMemoryUsage(): number {
|
||||
let totalBytes = 0
|
||||
|
||||
this.toolCache.forEach((cache, key) => {
|
||||
totalBytes += key.length * 2 // UTF-16 encoding
|
||||
totalBytes += JSON.stringify(cache.tools).length * 2
|
||||
totalBytes += 64
|
||||
})
|
||||
|
||||
return totalBytes
|
||||
this.cacheAdapter = createMcpCacheAdapter()
|
||||
logger.info(`MCP Service initialized with ${getMcpCacheType()} cache`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose of the service and cleanup resources
|
||||
*/
|
||||
dispose(): void {
|
||||
this.stopPeriodicCleanup()
|
||||
this.toolCache.clear()
|
||||
logger.info('MCP Service disposed and cleanup stopped')
|
||||
this.cacheAdapter.dispose()
|
||||
logger.info('MCP Service disposed')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -385,6 +258,81 @@ class McpService {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update server connection status after discovery attempt
|
||||
*/
|
||||
private async updateServerStatus(
|
||||
serverId: string,
|
||||
workspaceId: string,
|
||||
success: boolean,
|
||||
error?: string,
|
||||
toolCount?: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
const [currentServer] = await db
|
||||
.select({ statusConfig: mcpServers.statusConfig })
|
||||
.from(mcpServers)
|
||||
.where(
|
||||
and(
|
||||
eq(mcpServers.id, serverId),
|
||||
eq(mcpServers.workspaceId, workspaceId),
|
||||
isNull(mcpServers.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
const currentConfig: McpServerStatusConfig =
|
||||
(currentServer?.statusConfig as McpServerStatusConfig | null) ?? {
|
||||
consecutiveFailures: 0,
|
||||
lastSuccessfulDiscovery: null,
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
|
||||
if (success) {
|
||||
await db
|
||||
.update(mcpServers)
|
||||
.set({
|
||||
connectionStatus: 'connected',
|
||||
lastConnected: now,
|
||||
lastError: null,
|
||||
toolCount: toolCount ?? 0,
|
||||
lastToolsRefresh: now,
|
||||
statusConfig: {
|
||||
consecutiveFailures: 0,
|
||||
lastSuccessfulDiscovery: now.toISOString(),
|
||||
},
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(mcpServers.id, serverId))
|
||||
} else {
|
||||
const newFailures = currentConfig.consecutiveFailures + 1
|
||||
const isErrorState = newFailures >= MCP_CONSTANTS.MAX_CONSECUTIVE_FAILURES
|
||||
|
||||
await db
|
||||
.update(mcpServers)
|
||||
.set({
|
||||
connectionStatus: isErrorState ? 'error' : 'disconnected',
|
||||
lastError: error || 'Unknown error',
|
||||
statusConfig: {
|
||||
consecutiveFailures: newFailures,
|
||||
lastSuccessfulDiscovery: currentConfig.lastSuccessfulDiscovery,
|
||||
},
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(mcpServers.id, serverId))
|
||||
|
||||
if (isErrorState) {
|
||||
logger.warn(
|
||||
`Server ${serverId} marked as error after ${newFailures} consecutive failures`
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Failed to update server status for ${serverId}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover tools from all workspace servers
|
||||
*/
|
||||
@@ -399,10 +347,14 @@ class McpService {
|
||||
|
||||
try {
|
||||
if (!forceRefresh) {
|
||||
const cached = this.getCacheEntry(cacheKey)
|
||||
if (cached && cached.expiry > new Date()) {
|
||||
logger.debug(`[${requestId}] Using cached tools for user ${userId}`)
|
||||
return cached.tools
|
||||
try {
|
||||
const cached = await this.cacheAdapter.get(cacheKey)
|
||||
if (cached) {
|
||||
logger.debug(`[${requestId}] Using cached tools for user ${userId}`)
|
||||
return cached.tools
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`[${requestId}] Cache read failed, proceeding with discovery:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,7 +377,7 @@ class McpService {
|
||||
logger.debug(
|
||||
`[${requestId}] Discovered ${tools.length} tools from server ${config.name}`
|
||||
)
|
||||
return tools
|
||||
return { serverId: config.id, tools }
|
||||
} finally {
|
||||
await client.disconnect()
|
||||
}
|
||||
@@ -433,20 +385,40 @@ class McpService {
|
||||
)
|
||||
|
||||
let failedCount = 0
|
||||
const statusUpdates: Promise<void>[] = []
|
||||
|
||||
results.forEach((result, index) => {
|
||||
const server = servers[index]
|
||||
if (result.status === 'fulfilled') {
|
||||
allTools.push(...result.value)
|
||||
allTools.push(...result.value.tools)
|
||||
statusUpdates.push(
|
||||
this.updateServerStatus(
|
||||
server.id!,
|
||||
workspaceId,
|
||||
true,
|
||||
undefined,
|
||||
result.value.tools.length
|
||||
)
|
||||
)
|
||||
} else {
|
||||
failedCount++
|
||||
logger.warn(
|
||||
`[${requestId}] Failed to discover tools from server ${servers[index].name}:`,
|
||||
result.reason
|
||||
)
|
||||
const errorMessage =
|
||||
result.reason instanceof Error ? result.reason.message : 'Unknown error'
|
||||
logger.warn(`[${requestId}] Failed to discover tools from server ${server.name}:`)
|
||||
statusUpdates.push(this.updateServerStatus(server.id!, workspaceId, false, errorMessage))
|
||||
}
|
||||
})
|
||||
|
||||
Promise.allSettled(statusUpdates).catch((err) => {
|
||||
logger.error(`[${requestId}] Error updating server statuses:`, err)
|
||||
})
|
||||
|
||||
if (failedCount === 0) {
|
||||
this.setCacheEntry(cacheKey, allTools)
|
||||
try {
|
||||
await this.cacheAdapter.set(cacheKey, allTools, this.cacheTimeout)
|
||||
} catch (error) {
|
||||
logger.warn(`[${requestId}] Cache write failed:`, error)
|
||||
}
|
||||
} else {
|
||||
logger.warn(
|
||||
`[${requestId}] Skipping cache due to ${failedCount} failed server(s) - will retry on next request`
|
||||
@@ -565,44 +537,18 @@ class McpService {
|
||||
/**
|
||||
* Clear tool cache for a workspace or all workspaces
|
||||
*/
|
||||
clearCache(workspaceId?: string): void {
|
||||
if (workspaceId) {
|
||||
const workspaceCacheKey = `workspace:${workspaceId}`
|
||||
this.toolCache.delete(workspaceCacheKey)
|
||||
logger.debug(`Cleared MCP tool cache for workspace ${workspaceId}`)
|
||||
} else {
|
||||
this.toolCache.clear()
|
||||
this.cacheHits = 0
|
||||
this.cacheMisses = 0
|
||||
this.entriesEvicted = 0
|
||||
logger.debug('Cleared all MCP tool cache and reset statistics')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive cache statistics
|
||||
*/
|
||||
getCacheStats(): CacheStats {
|
||||
const entries: { key: string; cache: ToolCache }[] = []
|
||||
this.toolCache.forEach((cache, key) => {
|
||||
entries.push({ key, cache })
|
||||
})
|
||||
|
||||
const now = new Date()
|
||||
const activeEntries = entries.filter(({ cache }) => cache.expiry > now)
|
||||
const totalRequests = this.cacheHits + this.cacheMisses
|
||||
const hitRate = totalRequests > 0 ? this.cacheHits / totalRequests : 0
|
||||
|
||||
return {
|
||||
totalEntries: entries.length,
|
||||
activeEntries: activeEntries.length,
|
||||
expiredEntries: entries.length - activeEntries.length,
|
||||
maxCacheSize: this.maxCacheSize,
|
||||
cacheHitRate: Math.round(hitRate * 100) / 100,
|
||||
memoryUsage: {
|
||||
approximateBytes: this.calculateMemoryUsage(),
|
||||
entriesEvicted: this.entriesEvicted,
|
||||
},
|
||||
async clearCache(workspaceId?: string): Promise<void> {
|
||||
try {
|
||||
if (workspaceId) {
|
||||
const workspaceCacheKey = `workspace:${workspaceId}`
|
||||
await this.cacheAdapter.delete(workspaceCacheKey)
|
||||
logger.debug(`Cleared MCP tool cache for workspace ${workspaceId}`)
|
||||
} else {
|
||||
await this.cacheAdapter.clear()
|
||||
logger.debug('Cleared all MCP tool cache')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to clear cache:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
14
apps/sim/lib/mcp/storage/adapter.ts
Normal file
14
apps/sim/lib/mcp/storage/adapter.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { McpTool } from '@/lib/mcp/types'
|
||||
|
||||
export interface McpCacheEntry {
|
||||
tools: McpTool[]
|
||||
expiry: number // Unix timestamp ms
|
||||
}
|
||||
|
||||
export interface McpCacheStorageAdapter {
|
||||
get(key: string): Promise<McpCacheEntry | null>
|
||||
set(key: string, tools: McpTool[], ttlMs: number): Promise<void>
|
||||
delete(key: string): Promise<void>
|
||||
clear(): Promise<void>
|
||||
dispose(): void
|
||||
}
|
||||
53
apps/sim/lib/mcp/storage/factory.ts
Normal file
53
apps/sim/lib/mcp/storage/factory.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { getRedisClient } from '@/lib/core/config/redis'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { McpCacheStorageAdapter } from './adapter'
|
||||
import { MemoryMcpCache } from './memory-cache'
|
||||
import { RedisMcpCache } from './redis-cache'
|
||||
|
||||
const logger = createLogger('McpCacheFactory')
|
||||
|
||||
let cachedAdapter: McpCacheStorageAdapter | null = null
|
||||
|
||||
/**
|
||||
* Create MCP cache storage adapter.
|
||||
* Uses Redis if available, falls back to in-memory cache.
|
||||
*
|
||||
* Unlike rate-limiting (which fails if Redis is configured but unavailable),
|
||||
* MCP caching gracefully falls back to memory since it's an optimization.
|
||||
*/
|
||||
export function createMcpCacheAdapter(): McpCacheStorageAdapter {
|
||||
if (cachedAdapter) {
|
||||
return cachedAdapter
|
||||
}
|
||||
|
||||
const redis = getRedisClient()
|
||||
|
||||
if (redis) {
|
||||
logger.info('MCP cache: Using Redis')
|
||||
cachedAdapter = new RedisMcpCache(redis)
|
||||
} else {
|
||||
logger.info('MCP cache: Using in-memory (Redis not configured)')
|
||||
cachedAdapter = new MemoryMcpCache()
|
||||
}
|
||||
|
||||
return cachedAdapter
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current adapter type for logging/debugging
|
||||
*/
|
||||
export function getMcpCacheType(): 'redis' | 'memory' {
|
||||
const redis = getRedisClient()
|
||||
return redis ? 'redis' : 'memory'
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the cached adapter.
|
||||
* Only use for testing purposes.
|
||||
*/
|
||||
export function resetMcpCacheAdapter(): void {
|
||||
if (cachedAdapter) {
|
||||
cachedAdapter.dispose()
|
||||
cachedAdapter = null
|
||||
}
|
||||
}
|
||||
4
apps/sim/lib/mcp/storage/index.ts
Normal file
4
apps/sim/lib/mcp/storage/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export type { McpCacheEntry, McpCacheStorageAdapter } from './adapter'
|
||||
export { createMcpCacheAdapter, getMcpCacheType, resetMcpCacheAdapter } from './factory'
|
||||
export { MemoryMcpCache } from './memory-cache'
|
||||
export { RedisMcpCache } from './redis-cache'
|
||||
103
apps/sim/lib/mcp/storage/memory-cache.ts
Normal file
103
apps/sim/lib/mcp/storage/memory-cache.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { McpTool } from '@/lib/mcp/types'
|
||||
import { MCP_CONSTANTS } from '@/lib/mcp/utils'
|
||||
import type { McpCacheEntry, McpCacheStorageAdapter } from './adapter'
|
||||
|
||||
const logger = createLogger('McpMemoryCache')
|
||||
|
||||
export class MemoryMcpCache implements McpCacheStorageAdapter {
|
||||
private cache = new Map<string, McpCacheEntry>()
|
||||
private readonly maxCacheSize = MCP_CONSTANTS.MAX_CACHE_SIZE
|
||||
private cleanupInterval: NodeJS.Timeout | null = null
|
||||
|
||||
constructor() {
|
||||
this.startPeriodicCleanup()
|
||||
}
|
||||
|
||||
private startPeriodicCleanup(): void {
|
||||
this.cleanupInterval = setInterval(
|
||||
() => {
|
||||
this.cleanupExpiredEntries()
|
||||
},
|
||||
5 * 60 * 1000 // 5 minutes
|
||||
)
|
||||
// Don't keep Node process alive just for cache cleanup
|
||||
this.cleanupInterval.unref()
|
||||
}
|
||||
|
||||
private cleanupExpiredEntries(): void {
|
||||
const now = Date.now()
|
||||
const expiredKeys: string[] = []
|
||||
|
||||
this.cache.forEach((entry, key) => {
|
||||
if (entry.expiry <= now) {
|
||||
expiredKeys.push(key)
|
||||
}
|
||||
})
|
||||
|
||||
expiredKeys.forEach((key) => this.cache.delete(key))
|
||||
|
||||
if (expiredKeys.length > 0) {
|
||||
logger.debug(`Cleaned up ${expiredKeys.length} expired cache entries`)
|
||||
}
|
||||
}
|
||||
|
||||
private evictIfNeeded(): void {
|
||||
if (this.cache.size <= this.maxCacheSize) {
|
||||
return
|
||||
}
|
||||
|
||||
// Evict oldest entries (by insertion order - Map maintains order)
|
||||
const entriesToRemove = this.cache.size - this.maxCacheSize
|
||||
const keys = Array.from(this.cache.keys()).slice(0, entriesToRemove)
|
||||
keys.forEach((key) => this.cache.delete(key))
|
||||
|
||||
logger.debug(`Evicted ${entriesToRemove} cache entries`)
|
||||
}
|
||||
|
||||
async get(key: string): Promise<McpCacheEntry | null> {
|
||||
const entry = this.cache.get(key)
|
||||
const now = Date.now()
|
||||
|
||||
if (!entry || entry.expiry <= now) {
|
||||
if (entry) {
|
||||
this.cache.delete(key)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Return copy to prevent caller from mutating cache
|
||||
return {
|
||||
tools: entry.tools,
|
||||
expiry: entry.expiry,
|
||||
}
|
||||
}
|
||||
|
||||
async set(key: string, tools: McpTool[], ttlMs: number): Promise<void> {
|
||||
const now = Date.now()
|
||||
const entry: McpCacheEntry = {
|
||||
tools,
|
||||
expiry: now + ttlMs,
|
||||
}
|
||||
|
||||
this.cache.set(key, entry)
|
||||
this.evictIfNeeded()
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
this.cache.delete(key)
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.cache.clear()
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval)
|
||||
this.cleanupInterval = null
|
||||
}
|
||||
this.cache.clear()
|
||||
logger.info('Memory cache disposed')
|
||||
}
|
||||
}
|
||||
96
apps/sim/lib/mcp/storage/redis-cache.ts
Normal file
96
apps/sim/lib/mcp/storage/redis-cache.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type Redis from 'ioredis'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { McpTool } from '@/lib/mcp/types'
|
||||
import type { McpCacheEntry, McpCacheStorageAdapter } from './adapter'
|
||||
|
||||
const logger = createLogger('McpRedisCache')
|
||||
|
||||
const REDIS_KEY_PREFIX = 'mcp:tools:'
|
||||
|
||||
export class RedisMcpCache implements McpCacheStorageAdapter {
|
||||
constructor(private redis: Redis) {}
|
||||
|
||||
private getKey(key: string): string {
|
||||
return `${REDIS_KEY_PREFIX}${key}`
|
||||
}
|
||||
|
||||
async get(key: string): Promise<McpCacheEntry | null> {
|
||||
try {
|
||||
const redisKey = this.getKey(key)
|
||||
const data = await this.redis.get(redisKey)
|
||||
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(data) as McpCacheEntry
|
||||
} catch {
|
||||
// Corrupted data - delete and treat as miss
|
||||
logger.warn('Corrupted cache entry, deleting:', redisKey)
|
||||
await this.redis.del(redisKey)
|
||||
return null
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Redis cache get error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async set(key: string, tools: McpTool[], ttlMs: number): Promise<void> {
|
||||
try {
|
||||
const now = Date.now()
|
||||
const entry: McpCacheEntry = {
|
||||
tools,
|
||||
expiry: now + ttlMs,
|
||||
}
|
||||
|
||||
await this.redis.set(this.getKey(key), JSON.stringify(entry), 'PX', ttlMs)
|
||||
} catch (error) {
|
||||
logger.error('Redis cache set error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
try {
|
||||
await this.redis.del(this.getKey(key))
|
||||
} catch (error) {
|
||||
logger.error('Redis cache delete error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
try {
|
||||
let cursor = '0'
|
||||
let deletedCount = 0
|
||||
|
||||
do {
|
||||
const [nextCursor, keys] = await this.redis.scan(
|
||||
cursor,
|
||||
'MATCH',
|
||||
`${REDIS_KEY_PREFIX}*`,
|
||||
'COUNT',
|
||||
100
|
||||
)
|
||||
cursor = nextCursor
|
||||
|
||||
if (keys.length > 0) {
|
||||
await this.redis.del(...keys)
|
||||
deletedCount += keys.length
|
||||
}
|
||||
} while (cursor !== '0')
|
||||
|
||||
logger.debug(`Cleared ${deletedCount} MCP cache entries from Redis`)
|
||||
} catch (error) {
|
||||
logger.error('Redis cache clear error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
// Redis client is managed externally, nothing to dispose
|
||||
logger.info('Redis cache adapter disposed')
|
||||
}
|
||||
}
|
||||
129
apps/sim/lib/mcp/tool-validation.ts
Normal file
129
apps/sim/lib/mcp/tool-validation.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* MCP Tool Validation
|
||||
*
|
||||
* Shared logic for detecting issues with MCP tools across the platform.
|
||||
* Used by both tool-input.tsx (workflow context) and MCP modal (workspace context).
|
||||
*/
|
||||
|
||||
import isEqual from 'lodash/isEqual'
|
||||
import omit from 'lodash/omit'
|
||||
|
||||
export type McpToolIssueType =
|
||||
| 'server_not_found'
|
||||
| 'server_error'
|
||||
| 'tool_not_found'
|
||||
| 'schema_changed'
|
||||
| 'url_changed'
|
||||
|
||||
export interface McpToolIssue {
|
||||
type: McpToolIssueType
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface StoredMcpTool {
|
||||
serverId: string
|
||||
serverUrl?: string
|
||||
toolName: string
|
||||
schema?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ServerState {
|
||||
id: string
|
||||
url?: string
|
||||
connectionStatus?: 'connected' | 'disconnected' | 'error'
|
||||
lastError?: string
|
||||
}
|
||||
|
||||
export interface DiscoveredTool {
|
||||
serverId: string
|
||||
name: string
|
||||
inputSchema?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares two schemas to detect changes.
|
||||
* Uses lodash isEqual for deep, key-order-independent comparison.
|
||||
* Ignores description field which may be backfilled.
|
||||
*/
|
||||
export function hasSchemaChanged(
|
||||
storedSchema: Record<string, unknown> | undefined,
|
||||
serverSchema: Record<string, unknown> | undefined
|
||||
): boolean {
|
||||
if (!storedSchema || !serverSchema) return false
|
||||
|
||||
const storedWithoutDesc = omit(storedSchema, 'description')
|
||||
const serverWithoutDesc = omit(serverSchema, 'description')
|
||||
|
||||
return !isEqual(storedWithoutDesc, serverWithoutDesc)
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects issues with a stored MCP tool by comparing against current server/tool state.
|
||||
*/
|
||||
export function getMcpToolIssue(
|
||||
storedTool: StoredMcpTool,
|
||||
servers: ServerState[],
|
||||
discoveredTools: DiscoveredTool[]
|
||||
): McpToolIssue | null {
|
||||
const { serverId, serverUrl, toolName, schema } = storedTool
|
||||
|
||||
// Check server exists
|
||||
const server = servers.find((s) => s.id === serverId)
|
||||
if (!server) {
|
||||
return { type: 'server_not_found', message: 'Server not found' }
|
||||
}
|
||||
|
||||
// Check server connection status
|
||||
if (server.connectionStatus === 'error') {
|
||||
return { type: 'server_error', message: server.lastError || 'Server connection error' }
|
||||
}
|
||||
if (server.connectionStatus !== 'connected') {
|
||||
return { type: 'server_error', message: 'Server not connected' }
|
||||
}
|
||||
|
||||
// Check server URL changed (if we have stored URL)
|
||||
if (serverUrl && server.url && serverUrl !== server.url) {
|
||||
return { type: 'url_changed', message: 'Server URL changed - tools may be different' }
|
||||
}
|
||||
|
||||
// Check tool exists on server
|
||||
const serverTool = discoveredTools.find((t) => t.serverId === serverId && t.name === toolName)
|
||||
if (!serverTool) {
|
||||
return { type: 'tool_not_found', message: 'Tool not found on server' }
|
||||
}
|
||||
|
||||
// Check schema changed
|
||||
if (schema && serverTool.inputSchema) {
|
||||
if (hasSchemaChanged(schema, serverTool.inputSchema)) {
|
||||
return { type: 'schema_changed', message: 'Tool schema changed' }
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a user-friendly label for the issue badge
|
||||
*/
|
||||
export function getIssueBadgeLabel(issue: McpToolIssue): string {
|
||||
switch (issue.type) {
|
||||
case 'schema_changed':
|
||||
return 'stale'
|
||||
case 'url_changed':
|
||||
return 'stale'
|
||||
default:
|
||||
return 'unavailable'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an issue means the tool cannot be used (vs just being stale)
|
||||
*/
|
||||
export function isToolUnavailable(issue: McpToolIssue | null): boolean {
|
||||
if (!issue) return false
|
||||
return (
|
||||
issue.type === 'server_not_found' ||
|
||||
issue.type === 'server_error' ||
|
||||
issue.type === 'tool_not_found'
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,11 @@
|
||||
// Modern MCP uses Streamable HTTP which handles both HTTP POST and SSE responses
|
||||
export type McpTransport = 'streamable-http'
|
||||
|
||||
export interface McpServerStatusConfig {
|
||||
consecutiveFailures: number
|
||||
lastSuccessfulDiscovery: string | null
|
||||
}
|
||||
|
||||
export interface McpServerConfig {
|
||||
id: string
|
||||
name: string
|
||||
@@ -20,6 +25,7 @@ export interface McpServerConfig {
|
||||
timeout?: number
|
||||
retries?: number
|
||||
enabled?: boolean
|
||||
statusConfig?: McpServerStatusConfig
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
@@ -113,8 +119,8 @@ export class McpError extends Error {
|
||||
}
|
||||
|
||||
export class McpConnectionError extends McpError {
|
||||
constructor(message: string, serverId: string) {
|
||||
super(`MCP Connection Error for server ${serverId}: ${message}`)
|
||||
constructor(message: string, serverName: string) {
|
||||
super(`Failed to connect to "${serverName}": ${message}`)
|
||||
this.name = 'McpConnectionError'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,11 @@ import type { McpApiResponse } from '@/lib/mcp/types'
|
||||
*/
|
||||
export const MCP_CONSTANTS = {
|
||||
EXECUTION_TIMEOUT: 60000,
|
||||
CACHE_TIMEOUT: 30 * 1000,
|
||||
CACHE_TIMEOUT: 5 * 60 * 1000, // 5 minutes
|
||||
DEFAULT_RETRIES: 3,
|
||||
DEFAULT_CONNECTION_TIMEOUT: 30000,
|
||||
MAX_CACHE_SIZE: 1000,
|
||||
MAX_CONSECUTIVE_FAILURES: 3,
|
||||
} as const
|
||||
|
||||
/**
|
||||
|
||||
399
apps/sim/lib/mcp/workflow-mcp-server.ts
Normal file
399
apps/sim/lib/mcp/workflow-mcp-server.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
/**
|
||||
* Workflow MCP Server
|
||||
*
|
||||
* Creates an MCP server using the official @modelcontextprotocol/sdk
|
||||
* that exposes workflows as tools via a Next.js-compatible transport.
|
||||
*/
|
||||
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
|
||||
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { db } from '@sim/db'
|
||||
import { workflow, workflowMcpTool } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { z } from 'zod'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { fileItemZodSchema } from '@/lib/mcp/workflow-tool-schema'
|
||||
|
||||
const logger = createLogger('WorkflowMcpServer')
|
||||
|
||||
/**
|
||||
* Convert stored JSON schema to Zod schema.
|
||||
* Uses fileItemZodSchema from workflow-tool-schema for file arrays.
|
||||
*/
|
||||
function jsonSchemaToZodShape(schema: Record<string, unknown> | null): z.ZodRawShape | undefined {
|
||||
if (!schema || schema.type !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const properties = schema.properties as
|
||||
| Record<string, { type: string; description?: string; items?: unknown }>
|
||||
| undefined
|
||||
if (!properties || Object.keys(properties).length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const shape: z.ZodRawShape = {}
|
||||
const required = (schema.required as string[] | undefined) || []
|
||||
|
||||
for (const [key, prop] of Object.entries(properties)) {
|
||||
let zodType: z.ZodTypeAny
|
||||
|
||||
// Check if this array has items (file arrays have items.type === 'object')
|
||||
const hasObjectItems =
|
||||
prop.type === 'array' &&
|
||||
prop.items &&
|
||||
typeof prop.items === 'object' &&
|
||||
(prop.items as Record<string, unknown>).type === 'object'
|
||||
|
||||
switch (prop.type) {
|
||||
case 'string':
|
||||
zodType = z.string()
|
||||
break
|
||||
case 'number':
|
||||
zodType = z.number()
|
||||
break
|
||||
case 'boolean':
|
||||
zodType = z.boolean()
|
||||
break
|
||||
case 'array':
|
||||
if (hasObjectItems) {
|
||||
// File arrays - use the shared file item schema
|
||||
zodType = z.array(fileItemZodSchema)
|
||||
} else {
|
||||
zodType = z.array(z.any())
|
||||
}
|
||||
break
|
||||
case 'object':
|
||||
zodType = z.record(z.any())
|
||||
break
|
||||
default:
|
||||
zodType = z.any()
|
||||
}
|
||||
|
||||
if (prop.description) {
|
||||
zodType = zodType.describe(prop.description)
|
||||
}
|
||||
|
||||
if (!required.includes(key)) {
|
||||
zodType = zodType.optional()
|
||||
}
|
||||
|
||||
shape[key] = zodType
|
||||
}
|
||||
|
||||
return Object.keys(shape).length > 0 ? shape : undefined
|
||||
}
|
||||
|
||||
interface WorkflowTool {
|
||||
id: string
|
||||
toolName: string
|
||||
toolDescription: string | null
|
||||
parameterSchema: Record<string, unknown> | null
|
||||
workflowId: string
|
||||
isEnabled: boolean
|
||||
}
|
||||
|
||||
interface ServerContext {
|
||||
serverId: string
|
||||
serverName: string
|
||||
userId: string
|
||||
workspaceId: string
|
||||
apiKey?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* A simple transport for handling single request/response cycles in Next.js
|
||||
* This transport is designed for stateless request handling where each
|
||||
* request creates a new server instance.
|
||||
*/
|
||||
class NextJsTransport implements Transport {
|
||||
private responseMessage: JSONRPCMessage | null = null
|
||||
private resolveResponse: ((message: JSONRPCMessage) => void) | null = null
|
||||
|
||||
onclose?: () => void
|
||||
onerror?: (error: Error) => void
|
||||
onmessage?: (message: JSONRPCMessage) => void
|
||||
|
||||
async start(): Promise<void> {
|
||||
// No-op for stateless transport
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.onclose?.()
|
||||
}
|
||||
|
||||
async send(message: JSONRPCMessage): Promise<void> {
|
||||
this.responseMessage = message
|
||||
this.resolveResponse?.(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects a message into the transport as if it was received from the client
|
||||
*/
|
||||
receiveMessage(message: JSONRPCMessage): void {
|
||||
this.onmessage?.(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the server to send a response
|
||||
*/
|
||||
waitForResponse(): Promise<JSONRPCMessage> {
|
||||
if (this.responseMessage) {
|
||||
return Promise.resolve(this.responseMessage)
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
this.resolveResponse = resolve
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and configures an MCP server with workflow tools
|
||||
*/
|
||||
async function createConfiguredMcpServer(context: ServerContext): Promise<McpServer> {
|
||||
const { serverId, serverName, apiKey } = context
|
||||
|
||||
// Create the MCP server using the SDK
|
||||
const server = new McpServer({
|
||||
name: serverName,
|
||||
version: '1.0.0',
|
||||
})
|
||||
|
||||
// Load tools from the database
|
||||
const tools = await db
|
||||
.select({
|
||||
id: workflowMcpTool.id,
|
||||
toolName: workflowMcpTool.toolName,
|
||||
toolDescription: workflowMcpTool.toolDescription,
|
||||
parameterSchema: workflowMcpTool.parameterSchema,
|
||||
workflowId: workflowMcpTool.workflowId,
|
||||
isEnabled: workflowMcpTool.isEnabled,
|
||||
})
|
||||
.from(workflowMcpTool)
|
||||
.where(eq(workflowMcpTool.serverId, serverId))
|
||||
|
||||
// Register each enabled tool
|
||||
for (const tool of tools.filter((t) => t.isEnabled)) {
|
||||
const zodSchema = jsonSchemaToZodShape(tool.parameterSchema as Record<string, unknown> | null)
|
||||
|
||||
if (zodSchema) {
|
||||
// Tool with parameters - callback receives (args, extra)
|
||||
server.tool(
|
||||
tool.toolName,
|
||||
tool.toolDescription || `Execute workflow: ${tool.toolName}`,
|
||||
zodSchema,
|
||||
async (args) => {
|
||||
return executeWorkflowTool(tool as WorkflowTool, args, apiKey)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
// Tool without parameters - callback only receives (extra)
|
||||
server.tool(
|
||||
tool.toolName,
|
||||
tool.toolDescription || `Execute workflow: ${tool.toolName}`,
|
||||
async () => {
|
||||
return executeWorkflowTool(tool as WorkflowTool, {}, apiKey)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Created MCP server "${serverName}" with ${tools.filter((t) => t.isEnabled).length} tools`
|
||||
)
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a workflow tool and returns the result
|
||||
*/
|
||||
async function executeWorkflowTool(
|
||||
tool: WorkflowTool,
|
||||
args: Record<string, unknown>,
|
||||
apiKey?: string | null
|
||||
): Promise<{
|
||||
content: Array<{ type: 'text'; text: string }>
|
||||
isError?: boolean
|
||||
}> {
|
||||
logger.info(`Executing workflow ${tool.workflowId} via MCP tool ${tool.toolName}`)
|
||||
|
||||
try {
|
||||
// Verify workflow is deployed
|
||||
const [workflowRecord] = await db
|
||||
.select({ id: workflow.id, isDeployed: workflow.isDeployed })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, tool.workflowId))
|
||||
.limit(1)
|
||||
|
||||
if (!workflowRecord || !workflowRecord.isDeployed) {
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ error: 'Workflow is not deployed' }) }],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the workflow
|
||||
const baseUrl = getBaseUrl()
|
||||
const executeUrl = `${baseUrl}/api/workflows/${tool.workflowId}/execute`
|
||||
|
||||
const executeHeaders: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
if (apiKey) {
|
||||
executeHeaders['X-API-Key'] = apiKey
|
||||
}
|
||||
|
||||
const executeResponse = await fetch(executeUrl, {
|
||||
method: 'POST',
|
||||
headers: executeHeaders,
|
||||
body: JSON.stringify({
|
||||
input: args,
|
||||
triggerType: 'mcp',
|
||||
}),
|
||||
})
|
||||
|
||||
const executeResult = await executeResponse.json()
|
||||
|
||||
if (!executeResponse.ok) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ error: executeResult.error || 'Workflow execution failed' }),
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(executeResult.output || executeResult, null, 2),
|
||||
},
|
||||
],
|
||||
isError: !executeResult.success,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error executing workflow ${tool.workflowId}:`, error)
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ error: 'Tool execution failed' }) }],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles an MCP JSON-RPC request using the SDK
|
||||
*/
|
||||
export async function handleMcpRequest(
|
||||
context: ServerContext,
|
||||
request: Request
|
||||
): Promise<Response> {
|
||||
try {
|
||||
// Parse the incoming JSON-RPC message
|
||||
const body = await request.json()
|
||||
const message = body as JSONRPCMessage
|
||||
|
||||
// Create transport and server
|
||||
const transport = new NextJsTransport()
|
||||
const server = await createConfiguredMcpServer(context)
|
||||
|
||||
// Connect server to transport
|
||||
await server.connect(transport)
|
||||
|
||||
// Inject the received message
|
||||
transport.receiveMessage(message)
|
||||
|
||||
// Wait for the response
|
||||
const response = await transport.waitForResponse()
|
||||
|
||||
// Clean up
|
||||
await server.close()
|
||||
|
||||
return new Response(JSON.stringify(response), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-MCP-Server-Name': context.serverName,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error handling MCP request:', error)
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: null,
|
||||
error: {
|
||||
code: -32603,
|
||||
message: 'Internal error',
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an SSE stream for MCP notifications (used for GET requests)
|
||||
*/
|
||||
export function createMcpSseStream(context: ServerContext): ReadableStream<Uint8Array> {
|
||||
const encoder = new TextEncoder()
|
||||
let isStreamClosed = false
|
||||
|
||||
return new ReadableStream({
|
||||
async start(controller) {
|
||||
const sendEvent = (event: string, data: unknown) => {
|
||||
if (isStreamClosed) return
|
||||
try {
|
||||
const message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
|
||||
controller.enqueue(encoder.encode(message))
|
||||
} catch {
|
||||
isStreamClosed = true
|
||||
}
|
||||
}
|
||||
|
||||
// Send initial connection event
|
||||
sendEvent('open', { type: 'connection', status: 'connected' })
|
||||
|
||||
// Send server capabilities
|
||||
sendEvent('message', {
|
||||
jsonrpc: '2.0',
|
||||
method: 'notifications/initialized',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
serverInfo: {
|
||||
name: context.serverName,
|
||||
version: '1.0.0',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Keep connection alive with periodic pings
|
||||
const pingInterval = setInterval(() => {
|
||||
if (isStreamClosed) {
|
||||
clearInterval(pingInterval)
|
||||
return
|
||||
}
|
||||
sendEvent('ping', { timestamp: Date.now() })
|
||||
}, 30000)
|
||||
},
|
||||
|
||||
cancel() {
|
||||
isStreamClosed = true
|
||||
logger.info(`SSE connection closed for server ${context.serverId}`)
|
||||
},
|
||||
})
|
||||
}
|
||||
247
apps/sim/lib/mcp/workflow-tool-schema.ts
Normal file
247
apps/sim/lib/mcp/workflow-tool-schema.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { z } from 'zod'
|
||||
import type { InputFormatField } from '@/lib/workflows/types'
|
||||
|
||||
/**
|
||||
* MCP Tool Schema following the JSON Schema specification
|
||||
*/
|
||||
export interface McpToolInputSchema {
|
||||
type: 'object'
|
||||
properties: Record<string, McpToolProperty>
|
||||
required?: string[]
|
||||
}
|
||||
|
||||
export interface McpToolProperty {
|
||||
type: string
|
||||
description?: string
|
||||
items?: McpToolProperty
|
||||
properties?: Record<string, McpToolProperty>
|
||||
}
|
||||
|
||||
export interface McpToolDefinition {
|
||||
name: string
|
||||
description: string
|
||||
inputSchema: McpToolInputSchema
|
||||
}
|
||||
|
||||
/**
|
||||
* File item Zod schema for MCP file inputs.
|
||||
* This is the single source of truth for file structure.
|
||||
*/
|
||||
export const fileItemZodSchema = z.object({
|
||||
name: z.string().describe('File name'),
|
||||
data: z.string().describe('Base64 encoded file content'),
|
||||
mimeType: z.string().describe('MIME type of the file'),
|
||||
})
|
||||
|
||||
/**
|
||||
* Convert InputFormatField type to Zod schema
|
||||
*/
|
||||
function fieldTypeToZod(fieldType: string | undefined, isRequired: boolean): z.ZodTypeAny {
|
||||
let zodType: z.ZodTypeAny
|
||||
|
||||
switch (fieldType) {
|
||||
case 'string':
|
||||
zodType = z.string()
|
||||
break
|
||||
case 'number':
|
||||
zodType = z.number()
|
||||
break
|
||||
case 'boolean':
|
||||
zodType = z.boolean()
|
||||
break
|
||||
case 'object':
|
||||
zodType = z.record(z.any())
|
||||
break
|
||||
case 'array':
|
||||
zodType = z.array(z.any())
|
||||
break
|
||||
case 'files':
|
||||
zodType = z.array(fileItemZodSchema)
|
||||
break
|
||||
default:
|
||||
zodType = z.string()
|
||||
}
|
||||
|
||||
return isRequired ? zodType : zodType.optional()
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Zod schema shape from InputFormatField array.
|
||||
* This is used directly by the MCP server for tool registration.
|
||||
*/
|
||||
export function generateToolZodSchema(inputFormat: InputFormatField[]): z.ZodRawShape | undefined {
|
||||
if (!inputFormat || inputFormat.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const shape: z.ZodRawShape = {}
|
||||
|
||||
for (const field of inputFormat) {
|
||||
if (!field.name) continue
|
||||
|
||||
const zodType = fieldTypeToZod(field.type, true)
|
||||
shape[field.name] = field.name ? zodType.describe(field.name) : zodType
|
||||
}
|
||||
|
||||
return Object.keys(shape).length > 0 ? shape : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Map InputFormatField type to JSON Schema type (for database storage)
|
||||
*/
|
||||
function mapFieldTypeToJsonSchemaType(fieldType: string | undefined): string {
|
||||
switch (fieldType) {
|
||||
case 'string':
|
||||
return 'string'
|
||||
case 'number':
|
||||
return 'number'
|
||||
case 'boolean':
|
||||
return 'boolean'
|
||||
case 'object':
|
||||
return 'object'
|
||||
case 'array':
|
||||
return 'array'
|
||||
case 'files':
|
||||
return 'array'
|
||||
default:
|
||||
return 'string'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a workflow name to be a valid MCP tool name.
|
||||
* Tool names should be lowercase, alphanumeric with underscores.
|
||||
*/
|
||||
export function sanitizeToolName(name: string): string {
|
||||
return (
|
||||
name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s_-]/g, '')
|
||||
.replace(/[\s-]+/g, '_')
|
||||
.replace(/_+/g, '_')
|
||||
.replace(/^_|_$/g, '')
|
||||
.substring(0, 64) || 'workflow_tool'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate MCP tool input schema from InputFormatField array.
|
||||
* This converts the workflow's input format definition to JSON Schema format
|
||||
* that MCP clients can use to understand tool parameters.
|
||||
*/
|
||||
export function generateToolInputSchema(inputFormat: InputFormatField[]): McpToolInputSchema {
|
||||
const properties: Record<string, McpToolProperty> = {}
|
||||
const required: string[] = []
|
||||
|
||||
for (const field of inputFormat) {
|
||||
if (!field.name) continue
|
||||
|
||||
const fieldName = field.name
|
||||
const fieldType = mapFieldTypeToJsonSchemaType(field.type)
|
||||
|
||||
const property: McpToolProperty = {
|
||||
type: fieldType,
|
||||
// Use custom description if provided, otherwise use field name
|
||||
description: field.description?.trim() || fieldName,
|
||||
}
|
||||
|
||||
// Handle array types
|
||||
if (fieldType === 'array') {
|
||||
if (field.type === 'files') {
|
||||
property.items = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'File name' },
|
||||
url: { type: 'string', description: 'File URL' },
|
||||
type: { type: 'string', description: 'MIME type' },
|
||||
size: { type: 'number', description: 'File size in bytes' },
|
||||
},
|
||||
}
|
||||
// Use custom description if provided, otherwise use default
|
||||
if (!field.description?.trim()) {
|
||||
property.description = 'Array of file objects'
|
||||
}
|
||||
} else {
|
||||
property.items = { type: 'string' }
|
||||
}
|
||||
}
|
||||
|
||||
properties[fieldName] = property
|
||||
|
||||
// All fields are considered required by default
|
||||
// (in the future, we could add an optional flag to InputFormatField)
|
||||
required.push(fieldName)
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'object',
|
||||
properties,
|
||||
required: required.length > 0 ? required : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a complete MCP tool definition from workflow metadata and input format.
|
||||
*/
|
||||
export function generateToolDefinition(
|
||||
workflowName: string,
|
||||
workflowDescription: string | undefined | null,
|
||||
inputFormat: InputFormatField[],
|
||||
customToolName?: string,
|
||||
customDescription?: string
|
||||
): McpToolDefinition {
|
||||
return {
|
||||
name: customToolName || sanitizeToolName(workflowName),
|
||||
description: customDescription || workflowDescription || `Execute ${workflowName} workflow`,
|
||||
inputSchema: generateToolInputSchema(inputFormat),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Valid start block types that can have input format
|
||||
*/
|
||||
const VALID_START_BLOCK_TYPES = [
|
||||
'starter',
|
||||
'start',
|
||||
'start_trigger',
|
||||
'api',
|
||||
'api_trigger',
|
||||
'input_trigger',
|
||||
]
|
||||
|
||||
/**
|
||||
* Extract input format from a workflow's blocks.
|
||||
* Looks for any valid start block and extracts its inputFormat configuration.
|
||||
*/
|
||||
export function extractInputFormatFromBlocks(
|
||||
blocks: Record<string, unknown>
|
||||
): InputFormatField[] | null {
|
||||
// Look for any valid start block
|
||||
for (const [, block] of Object.entries(blocks)) {
|
||||
if (!block || typeof block !== 'object') continue
|
||||
|
||||
const blockObj = block as Record<string, unknown>
|
||||
const blockType = blockObj.type as string
|
||||
|
||||
if (VALID_START_BLOCK_TYPES.includes(blockType)) {
|
||||
// Try to get inputFormat from subBlocks
|
||||
const subBlocks = blockObj.subBlocks as Record<string, unknown> | undefined
|
||||
if (subBlocks?.inputFormat) {
|
||||
const inputFormatSubBlock = subBlocks.inputFormat as Record<string, unknown>
|
||||
const value = inputFormatSubBlock.value
|
||||
if (Array.isArray(value)) {
|
||||
return value as InputFormatField[]
|
||||
}
|
||||
}
|
||||
|
||||
// Try legacy config.params.inputFormat
|
||||
const config = blockObj.config as Record<string, unknown> | undefined
|
||||
const params = config?.params as Record<string, unknown> | undefined
|
||||
if (params?.inputFormat && Array.isArray(params.inputFormat)) {
|
||||
return params.inputFormat as InputFormatField[]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user