Compare commits

..

25 Commits

Author SHA1 Message Date
priyanshu.solanki
f81c0ba9bf fix- adding the useWebhookUrl check becfore calling loadWebhookOrGenerateUrl function: 2025-12-18 12:20:13 -07:00
priyanshu.solanki
6c10f31a40 using official mcp sdk and added description fields 2025-12-17 21:20:30 -07:00
priyanshu.solanki
896e9674c2 removing unecessary auth 2025-12-17 18:58:51 -07:00
priyanshu.solanki
f2450d3c26 refactored code to use hasstartblock from the tirgger utils 2025-12-17 18:03:01 -07:00
priyanshu.solanki
cfbe4a4790 fix lint errors 2025-12-17 17:40:07 -07:00
priyanshu.solanki
1f22d7a9ec fix 2025-12-17 17:37:31 -07:00
priyanshu.solanki
2259bfcb8f fixing merge conflicts 2025-12-17 17:25:28 -07:00
priyanshu.solanki
85af046754 using mcn components 2025-12-17 17:25:27 -07:00
priyanshu.solanki
57f3697dd5 fixing lint issues 2025-12-17 17:25:27 -07:00
priyanshu.solanki
a15ac7360d fixed the issue of UI rendering for deleted mcp servers 2025-12-17 17:25:27 -07:00
priyanshu.solanki
93217438ef added a workflow as mcp 2025-12-17 17:24:16 -07:00
Waleed
7b5405e968 feat(vertex): added vertex to list of supported providers (#2430)
* feat(vertex): added vertex to list of supported providers

* added utils files for each provider, consolidated gemini utils, added dynamic verbosity and reasoning fetcher
2025-12-17 14:57:58 -08:00
Vikhyath Mondreti
1ae3b47f5c fix(inactivity-poll): need to respect level and trigger filters (#2431) 2025-12-17 14:50:33 -08:00
Waleed
3120a785df fix(terminal): fix text wrap for errors and messages with long strings (#2429) 2025-12-17 13:42:43 -08:00
Vikhyath Mondreti
8775e76c32 improvement(subflow): resize vertical height estimate (#2428)
* improvement(node-dims): share constants for node padding

* fix vertical height estimation
2025-12-17 12:07:57 -08:00
Vikhyath Mondreti
9a6c68789d fix(subflow): resizing live update 2025-12-17 11:49:24 -08:00
Waleed
08bc1125bd fix(cmd-k): when navigating to current workspace/workflow, close modal instead of navigating (#2420)
* fix(cmd-k): when navigating to current workspace, close modal instead of navigating

* ack PR comment
2025-12-17 10:21:35 -08:00
Waleed
f4f74da1dc feat(i18n): update translations (#2421)
Co-authored-by: icecrasher321 <icecrasher321@users.noreply.github.com>
2025-12-17 10:21:15 -08:00
Vikhyath Mondreti
de330d80f5 improvement(mcp): restructure mcp tools caching/fetching info to improve UX (#2416)
* feat(mcp): improve cache practice

* restructure mcps fetching, caching, UX indicators

* fix schema

* styling improvements

* fix tooltips and render issue

* fix loading sequence + add redis

---------

Co-authored-by: waleed <walif6@gmail.com>
2025-12-16 21:23:18 -08:00
Emir Karabeg
b7228d57f7 feat(service-now): added service now block (#2404)
* feat(service-now): added service now block

* fix: bun lock

* improvement: fixed @trigger.dev/sdk imports and removal of sentry blocks

* improvement: fixed @trigger.dev/sdk import

* improvement: fixed @trigger.dev/sdk import

* fix(servicenow): save accessTokenExpiresAt on initial OAuth account creation

* docs(servicenow): add ServiceNow tool documentation and icon mapping

* fixing bun lint issues

* fixing username/password fields

* fixing test file for refreshaccesstoken to support instance uri

* removing basic auth and fixing undo-redo/store.ts

* removed import set api code, changed CRUD operations to CRUD_record and added wand configuration to help users to generate JSON Arrays

---------

Co-authored-by: priyanshu.solanki <priyanshu.solanki@saviynt.com>
2025-12-16 21:16:09 -08:00
Waleed
dcbeca1abe fix(subflow): fix json stringification in subflow collections (#2419)
* fix(subflow): fix json stringification in subflow collections

* cleanup
2025-12-16 20:47:58 -08:00
Waleed
27ea333974 fix(chat): fix stale closure in workflow runner for chat (#2418) 2025-12-16 19:59:02 -08:00
Waleed
9861d3a0ac improvement(helm): added more to helm charts, remove instance selector for various cloud providers (#2412)
* improvement(helm): added more to helm charts, remove instance selector for various cloud providers

* ack PR comment
2025-12-16 18:24:00 -08:00
Waleed
fdbf8be79b fix(logs-search): restored support for log search queries (#2417) 2025-12-16 18:18:46 -08:00
Adam Gough
6f4f4e22f0 fix(loop): increased max loop iterations to 1000 (#2413) 2025-12-16 16:08:56 -08:00
167 changed files with 27285 additions and 1844 deletions

View File

@@ -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
```

View File

@@ -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

View File

@@ -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,

View 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`

View File

@@ -80,6 +80,7 @@
"sendgrid",
"sentry",
"serper",
"servicenow",
"sftp",
"sharepoint",
"shopify",

View 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`

View 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`

View 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`

View 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インスタンスURLhttps://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`

View 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`

View File

@@ -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

View File

@@ -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",

View File

@@ -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'

View File

@@ -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')

View File

@@ -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`)

View 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`)
}
}

View 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`)
}
}

View 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 })
}
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View 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 }
)
}
}

View 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,
})
}
}

View 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 })
}
}

View File

@@ -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,

View File

@@ -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 })

View File

@@ -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` })

View 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
)
}
}
)

View 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
)
}
}
)

View 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
)
}
}
)

View File

@@ -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
)
}
}
)

View 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
)
}
}
)

View 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
)
}
}
)

View File

@@ -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,

View File

@@ -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

View File

@@ -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)

View File

@@ -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`, {

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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>
)}

View File

@@ -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' },

View File

@@ -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,
}
}

View File

@@ -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

View File

@@ -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>
)
}

View File

@@ -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>

View File

@@ -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,

View File

@@ -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({

View File

@@ -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>
)
})}
</>
)
}

View File

@@ -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}

View File

@@ -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,

View File

@@ -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}

View File

@@ -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 }
},

View File

@@ -655,6 +655,7 @@ export function useWorkflowExecution() {
setExecutor,
setPendingBlocks,
setActiveBlocks,
workflows,
]
)

View File

@@ -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. */

View File

@@ -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

View File

@@ -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'

View File

@@ -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]'>

View File

@@ -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)}
/>

View File

@@ -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>
</>
)
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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>
}

View File

@@ -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',

View File

@@ -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)',

View File

@@ -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)',

View 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' },
},
}

View File

@@ -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: {

View File

@@ -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,

View File

@@ -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 || '&nbsp;' }}
/>
@@ -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>
)

View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View File

@@ -19,6 +19,8 @@ export interface AgentInputs {
apiKey?: string
azureEndpoint?: string
azureApiVersion?: string
vertexProject?: string
vertexLocation?: string
reasoningEffort?: string
verbosity?: string
}

View File

@@ -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
})
}

View File

@@ -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'

View File

@@ -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,

View 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),
})
},
})
}

View File

@@ -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,
}

View File

@@ -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)
}
},
[]

View File

@@ -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 (

View File

@@ -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
}

View File

@@ -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

View File

@@ -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'

View File

@@ -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

View File

@@ -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) {

View File

@@ -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
}
}

View File

@@ -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 &&

View File

@@ -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 {

View 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(),
}
}

View File

@@ -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)
}
}
}

View 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
}

View 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
}
}

View 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'

View 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')
}
}

View 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')
}
}

View 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'
)
}

View File

@@ -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'
}
}

View File

@@ -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
/**

View 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}`)
},
})
}

View 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