feat(integrations): add Azure Data Explorer (#6701)

* feat(integrations): add Azure Data Explorer

Add a 14-operation Azure Data Explorer (Kusto) integration covering KQL
queries, schema and metadata discovery, table management, inline and
query-sourced ingestion, ingestion-failure triage, and arbitrary
management commands.

Authentication uses a Microsoft Entra service principal through an
internal proxy route, since the Kusto token audience is per-cluster and
cannot be expressed as a static-scope OAuth provider.

* fix(azure-data-explorer): only read partial-failure status from the QueryStatus table

Scanning every returned table for Severity and StatusDescription columns
misread an ordinary query as a failed request whenever the user's own
result selected columns of those names — a common shape for a log table.

Failure detection now consults only the table the response's table of
contents names as QueryStatus, and primary-result selection reuses the
same index instead of re-reading it.

* fix(azure-data-explorer): keep the Show Operations and Show Table Details cards from painting empty

check:canvas-sentences flagged the Show Operations sentence: it anchored
`core` on operationId, which is an advanced-mode optional field, so an
untouched card resolved to nothing and painted empty. Show Table Details
had the same shape in milder form — table is optional there, since
omitting it describes every table, leaving a dangling preposition.

Both now lead with literal copy and treat their field as an optional
refinement. Also simplifies the primary-table condition to a single
`!= null` check.

* fix(azure-data-explorer): authenticate sovereign clusters against their own Entra authority

The cluster allowlist accepted Azure China and US Government hosts, but
every token request went to login.microsoftonline.com. Those clouds are
isolated instances with their own Entra endpoints, so a sovereign cluster
passed URI validation and then could never obtain a token.

Each Kusto service domain is now declared alongside the authority that
issues tokens for it, so the two cannot drift apart, and the authority is
part of the token cache key.

* improvement(azure-data-explorer): warn that ingest-from-query matches columns by position

Kusto aligns an ingested query result to the target table on column type
and order, never on column name, so a query projecting the right columns
in the wrong order lands data in the wrong columns without erroring.

Surfaces that in the tool description and param the model reads, in the
wand prompt that generates the query, in the rollup skill's steps, and in
the docs. Also verifies the target schema first rather than after.

* chore(azure-data-explorer): drop the unsourced kustomfa host from the cluster allowlist

Every other entry traces to a Microsoft reference — the Kusto
connection-string doc, the national-cloud endpoint tables, and the Fabric
KQL-database REST reference. kustomfa.windows.net does not, and the
connection-string doc states the trust boundary as hostnames ending in
kusto.windows.net.

An allowlist should only hold hosts we can justify, so this drops it and
records the sourcing standard for anything added later.

* fix(azure-data-explorer): handle commas inside quoted properties and empty extent IDs

Two defects in the shared command helpers:

buildWithClause split the property list on every comma before validating,
so a value that legally contains one — a docstring sentence, or a tags
array with more than one entry — was torn in half and rejected. Splitting
is now quote-aware, and an unterminated quote is rejected outright rather
than swallowing the rest of the clause.

transformColumnListResponse dropped empty strings, but `.ingest inline`
reports "no data shards were generated" as a single record carrying an
empty extent ID. A no-op load therefore looked like a missing column
instead of an empty result. Only non-strings are skipped now.
This commit is contained in:
Waleed
2026-08-14 12:27:13 -07:00
committed by GitHub
parent 1d342722ad
commit a7115e87ee
36 changed files with 4790 additions and 5 deletions
+132
View File
@@ -3898,6 +3898,138 @@ export const ClaudeIcon = (props: SVGProps<SVGSVGElement>) => (
</svg>
)
export function AzureDataExplorerIcon(props: SVGProps<SVGSVGElement>) {
const id = useId()
const wedgeGradient = `adx_wedge_${id}`
const dashLongGradient = `adx_dash_long_${id}`
const dashTopGradient = `adx_dash_top_${id}`
const dashBottomGradient = `adx_dash_bottom_${id}`
return (
<svg
{...props}
width='18'
height='18'
viewBox='0 0 18 18'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M1.47,1.47,16.53,16.53a.57.57,0,0,0,1-.4V1.07A.57.57,0,0,0,16.93.5H1.87A.57.57,0,0,0,1.47,1.47Z'
fill={`url(#${wedgeGradient})`}
/>
<path d='M5.53,5.53l6.94,6.94,5-5V1.07A.57.57,0,0,0,16.93.5H10.56Z' fill='#50e6ff' />
<rect
x='-0.38'
y='12.85'
width='9.37'
height='1.7'
rx='0.27'
transform='translate(-8.43 7.06) rotate(-45)'
fill={`url(#${dashLongGradient})`}
/>
<rect
x='0.12'
y='9.23'
width='5.99'
height='1.7'
rx='0.27'
transform='translate(-6.22 5.15) rotate(-45)'
fill={`url(#${dashTopGradient})`}
/>
<rect
x='4.89'
y='14.04'
width='5.99'
height='1.7'
rx='0.27'
transform='translate(-8.22 9.93) rotate(-45)'
fill={`url(#${dashBottomGradient})`}
/>
<rect
x='9.19'
y='4.75'
width='1.7'
height='1.7'
rx='0.38'
transform='translate(-1.02 8.74) rotate(-45)'
fill='#fff'
/>
<rect
x='11.59'
y='2.35'
width='1.7'
height='1.7'
rx='0.38'
transform='translate(1.38 9.73) rotate(-45)'
fill='#fff'
/>
<rect
x='11.59'
y='7.15'
width='1.7'
height='1.7'
rx='0.38'
transform='translate(-2.01 11.14) rotate(-45)'
fill='#fff'
/>
<path
d='M13.91,5.33l.66-.66a.38.38,0,0,1,.54,0l.66.66a.38.38,0,0,1,0,.54l-.66.66a.38.38,0,0,1-.54,0l-.66-.66a.38.38,0,0,1,0-.54'
fill='#fff'
/>
<defs>
<linearGradient
id={wedgeGradient}
x1='1.3'
y1='8.6'
x2='17.5'
y2='8.6'
gradientUnits='userSpaceOnUse'
>
<stop offset='0.1' stopColor='#54aef0' />
<stop offset='1' stopColor='#1988d9' />
</linearGradient>
<linearGradient
id={dashLongGradient}
x1='0.5'
y1='13.7'
x2='8.11'
y2='13.7'
gradientTransform='translate(10.95 0.97) rotate(45)'
gradientUnits='userSpaceOnUse'
>
<stop offset='0' stopColor='#b3b2b3' />
<stop offset='1' stopColor='#979797' />
</linearGradient>
<linearGradient
id={dashTopGradient}
x1='0.5'
y1='10.08'
x2='5.72'
y2='10.08'
gradientTransform='translate(8.04 0.75) rotate(45)'
gradientUnits='userSpaceOnUse'
>
<stop offset='0' stopColor='#b3b2b3' />
<stop offset='1' stopColor='#979797' />
</linearGradient>
<linearGradient
id={dashBottomGradient}
x1='5.28'
y1='14.89'
x2='10.49'
y2='14.89'
gradientTransform='translate(12.84 -1.21) rotate(45)'
gradientUnits='userSpaceOnUse'
>
<stop offset='0' stopColor='#b3b2b3' />
<stop offset='1' stopColor='#979797' />
</linearGradient>
</defs>
</svg>
)
}
export function AzureIcon(props: SVGProps<SVGSVGElement>) {
const id = useId()
const gradient0 = `azure_paint0_${id}`
+2
View File
@@ -22,6 +22,7 @@ import {
AshbyIcon,
AthenaIcon,
AttioIcon,
AzureDataExplorerIcon,
AzureIcon,
BoxCompanyIcon,
BrainIcon,
@@ -281,6 +282,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
ashby: AshbyIcon,
athena: AthenaIcon,
attio: AttioIcon,
azure_data_explorer: AzureDataExplorerIcon,
azure_devops: AzureIcon,
box: BoxCompanyIcon,
brandfetch: BrandfetchIcon,
@@ -0,0 +1,497 @@
---
title: Azure Data Explorer
description: Query and manage Azure Data Explorer (Kusto) clusters with KQL
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="azure_data_explorer"
color="#FFFFFF"
/>
{/* MANUAL-CONTENT-START:intro */}
[Azure Data Explorer](https://azure.microsoft.com/products/data-explorer) is Microsoft's analytics service for very large volumes of machine-generated data — logs, metrics, traces, telemetry, and IoT events. It is built for questions asked over billions of rows: you write a query, and it comes back in seconds. The same engine powers Fabric Eventhouse, Azure Monitor, and Application Insights.
You query it with **KQL** (Kusto Query Language), a pipeline language that reads left to right. Start with a table, then pipe the rows through operators:
```kusto
StormEvents
| where StartTime > ago(7d) and State == "FLORIDA"
| summarize Events = count() by EventType
| top 10 by Events
```
Azure Data Explorer also has a second command family: **management commands**, which all start with a dot (`.show tables`, `.create table`, `.ingest inline`). Queries read data; management commands inspect and change the cluster itself.
In Sim, this integration gives your agents both halves:
- **Ask questions of your telemetry** — turn a plain-English question into KQL, run it, and answer with real numbers instead of a guess
- **Discover the data model first** — list databases, tables, and stored functions, read a table's schema, and check its size and row count, so a generated query references columns that actually exist and you know what it will scan
- **Push rows in** — send small batches straight into a table, or materialize a query result into a rollup table with `.set-or-append`
- **Manage tables** — create a table from a column schema, or drop one you no longer need
- **Debug the pipeline** — list ingestion failures with their error codes and root causes, and check the state of a long-running operation
- **Run any management command** — the escape hatch for policies, mappings, and anything else on the control plane
Authentication uses a **Microsoft Entra service principal** (an app registration with a tenant ID, client ID, and client secret) rather than an interactive sign-in, so scheduled and unattended workflows keep working without anyone logging in. Grant that principal access to the database with `.add database <DATABASE> viewers ('aadapp=<clientId>;<tenantId>')` — use `viewers` for read-only agents, and `ingestors` or `users` only when a workflow needs to write.
A few things worth knowing before you build:
- **Enable Read-only on the Run Query operation** whenever an agent writes its own KQL. It sends the `x-ms-readonly` header, and the cluster then refuses anything that would change data — a cheap guardrail against a generated query doing more than you intended.
- **Results are capped at 10,000 rows.** Every result reports `rowCount`, `totalRowCount`, and `truncated`, so a query that returned more than the cap says so rather than quietly looking complete. Aggregate with `summarize` or bound the query with `take` instead of pulling raw rows.
- **Ingest Rows Inline is for small batches.** It is ideal for tens or hundreds of rows from a workflow run. For continuous or high-volume loading, use Azure Data Explorer's queued or streaming ingestion instead.
- **Ingest From Query defaults to `set-or-append`**, which adds to an existing table. `set-or-replace` discards everything already in the target table — pick it only when you mean to rebuild the rollup from scratch. For a large backfill, turn on the background option and poll Show Operations with the operation ID it returns.
- **Ingest From Query matches columns by position, not by name.** Kusto aligns the query result to the target table on column type and order, so a query that projects the right columns in the wrong order ingests data into the wrong columns without erroring. End the query with an explicit `project` in the table's column order, and confirm with Show Table Schema first.
- **Drop Table is permanent.** It deletes the table and its data. Give an agent the `viewers` role rather than `admins` unless a workflow genuinely needs to change schema.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Run Kusto Query Language queries against Azure Data Explorer and Fabric Eventhouse clusters, discover databases, tables, and schemas, push small batches of rows inline, and run management commands. Authenticates with a Microsoft Entra service principal using client credentials, so no interactive sign-in is needed.
## Actions
### Azure Data Explorer Query
Run a Kusto Query Language (KQL) query against an Azure Data Explorer database and return the primary result table.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net\) |
| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal |
| `clientId` | string | Yes | Microsoft Entra application \(client\) ID |
| `clientSecret` | string | Yes | Microsoft Entra application client secret |
| `resource` | string | No | Token audience override. Defaults to the cluster URI itself |
| `database` | string | Yes | Database to run the query against |
| `query` | string | Yes | KQL query text \(e.g., StormEvents \| where State == "FLORIDA" \| summarize count\(\) by EventType\) |
| `properties` | json | No | Kusto request properties object, e.g. \{"Options":\{"servertimeout":"00:04:00","queryconsistency":"strongconsistency"\}\} |
| `readOnly` | boolean | No | Send x-ms-readonly so the cluster rejects any request that would change data |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `tableName` | string | Name Kusto assigned to the returned result table |
| `columns` | array | Column metadata for the result table |
| ↳ `name` | string | Column name |
| ↳ `type` | string | Kusto scalar type |
| ↳ `dataType` | string | Approximate .NET type |
| `rows` | array | Result rows as positional arrays matching the columns order |
| `records` | array | Result rows keyed by column name |
| `rowCount` | number | Rows carried in this result, after the row cap |
| `totalRowCount` | number | Rows Kusto returned, before the row cap was applied |
| `truncated` | boolean | Whether rows were dropped to stay within the row cap — narrow the query if true |
### Azure Data Explorer Management Command
Run an Azure Data Explorer management command (a control command starting with ".") such as .show, .create, .alter, or .drop. Write commands change cluster state permanently; use the Query operation for reads.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net\) |
| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal |
| `clientId` | string | Yes | Microsoft Entra application \(client\) ID |
| `clientSecret` | string | Yes | Microsoft Entra application client secret |
| `resource` | string | No | Token audience override. Defaults to the cluster URI itself |
| `command` | string | Yes | Management command text, starting with "." \(e.g., .show table Events details\) |
| `database` | string | No | Database context for the command. Required for all commands except cluster-level ones such as .show databases |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `tableName` | string | Name Kusto assigned to the returned result table |
| `columns` | array | Column metadata for the result table |
| ↳ `name` | string | Column name |
| ↳ `type` | string | Kusto scalar type |
| ↳ `dataType` | string | Approximate .NET type |
| `rows` | array | Result rows as positional arrays matching the columns order |
| `records` | array | Result rows keyed by column name |
| `rowCount` | number | Rows carried in this result, after the row cap |
| `totalRowCount` | number | Rows Kusto returned, before the row cap was applied |
| `truncated` | boolean | Whether rows were dropped to stay within the row cap — narrow the query if true |
### Azure Data Explorer List Databases
List the databases on an Azure Data Explorer cluster that the service principal can access.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net\) |
| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal |
| `clientId` | string | Yes | Microsoft Entra application \(client\) ID |
| `clientSecret` | string | Yes | Microsoft Entra application client secret |
| `resource` | string | No | Token audience override. Defaults to the cluster URI itself |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `tableName` | string | Name Kusto assigned to the returned result table |
| `columns` | array | Column metadata for the result table |
| ↳ `name` | string | Column name |
| ↳ `type` | string | Kusto scalar type |
| ↳ `dataType` | string | Approximate .NET type |
| `rows` | array | Result rows as positional arrays matching the columns order |
| `records` | array | Result rows keyed by column name |
| `rowCount` | number | Rows carried in this result, after the row cap |
| `totalRowCount` | number | Rows Kusto returned, before the row cap was applied |
| `truncated` | boolean | Whether rows were dropped to stay within the row cap — narrow the query if true |
| `databases` | array | Database names, read from the DatabaseName column |
### Azure Data Explorer List Tables
List the tables in an Azure Data Explorer database, with their folder and docstring.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net\) |
| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal |
| `clientId` | string | Yes | Microsoft Entra application \(client\) ID |
| `clientSecret` | string | Yes | Microsoft Entra application client secret |
| `resource` | string | No | Token audience override. Defaults to the cluster URI itself |
| `database` | string | Yes | Database whose tables should be listed |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `tableName` | string | Name Kusto assigned to the returned result table |
| `columns` | array | Column metadata for the result table |
| ↳ `name` | string | Column name |
| ↳ `type` | string | Kusto scalar type |
| ↳ `dataType` | string | Approximate .NET type |
| `rows` | array | Result rows as positional arrays matching the columns order |
| `records` | array | Result rows keyed by column name |
| `rowCount` | number | Rows carried in this result, after the row cap |
| `totalRowCount` | number | Rows Kusto returned, before the row cap was applied |
| `truncated` | boolean | Whether rows were dropped to stay within the row cap — narrow the query if true |
| `tables` | array | Table names, read from the TableName column |
### Azure Data Explorer Show Table Schema
Read the column schema of an Azure Data Explorer table in CSL form (e.g., "Timestamp:datetime,Level:string"). Use this before writing a KQL query against an unfamiliar table.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net\) |
| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal |
| `clientId` | string | Yes | Microsoft Entra application \(client\) ID |
| `clientSecret` | string | Yes | Microsoft Entra application client secret |
| `resource` | string | No | Token audience override. Defaults to the cluster URI itself |
| `database` | string | Yes | Database containing the table |
| `table` | string | Yes | Table whose schema should be read |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `tableName` | string | Name of the table |
| `schema` | string | Comma-separated CSL column schema \(name:type\) |
| `databaseName` | string | The table's database |
| `folder` | string | The table's folder |
| `docString` | string | The table's docstring |
### Azure Data Explorer Show Database Schema
Read the full schema of an Azure Data Explorer database as a flat list of every table and column, so an agent can discover the data model in one call.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net\) |
| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal |
| `clientId` | string | Yes | Microsoft Entra application \(client\) ID |
| `clientSecret` | string | Yes | Microsoft Entra application client secret |
| `resource` | string | No | Token audience override. Defaults to the cluster URI itself |
| `database` | string | Yes | Database whose schema should be read |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `tableName` | string | Name Kusto assigned to the returned result table |
| `columns` | array | Column metadata for the result table |
| ↳ `name` | string | Column name |
| ↳ `type` | string | Kusto scalar type |
| ↳ `dataType` | string | Approximate .NET type |
| `rows` | array | Result rows as positional arrays matching the columns order |
| `records` | array | Result rows keyed by column name |
| `rowCount` | number | Rows carried in this result, after the row cap |
| `totalRowCount` | number | Rows Kusto returned, before the row cap was applied |
| `truncated` | boolean | Whether rows were dropped to stay within the row cap — narrow the query if true |
### Azure Data Explorer Show Table Details
Read size, row count, hot-cache footprint, and effective policies for a table — or for every table in the database when no table is given. Use it to see how much data a table actually holds before querying it.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net\) |
| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal |
| `clientId` | string | Yes | Microsoft Entra application \(client\) ID |
| `clientSecret` | string | Yes | Microsoft Entra application client secret |
| `resource` | string | No | Token audience override. Defaults to the cluster URI itself |
| `database` | string | Yes | Database to read table details from |
| `table` | string | No | Table to describe. Omit to describe every table in the database |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `tableName` | string | Name Kusto assigned to the returned result table |
| `columns` | array | Column metadata for the result table |
| ↳ `name` | string | Column name |
| ↳ `type` | string | Kusto scalar type |
| ↳ `dataType` | string | Approximate .NET type |
| `rows` | array | Result rows as positional arrays matching the columns order |
| `records` | array | Result rows keyed by column name |
| `rowCount` | number | Rows carried in this result, after the row cap |
| `totalRowCount` | number | Rows Kusto returned, before the row cap was applied |
| `truncated` | boolean | Whether rows were dropped to stay within the row cap — narrow the query if true |
### Azure Data Explorer List Functions
List the stored functions in an Azure Data Explorer database, with their parameters and bodies, so an agent can reuse existing logic instead of rewriting it.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net\) |
| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal |
| `clientId` | string | Yes | Microsoft Entra application \(client\) ID |
| `clientSecret` | string | Yes | Microsoft Entra application client secret |
| `resource` | string | No | Token audience override. Defaults to the cluster URI itself |
| `database` | string | Yes | Database whose stored functions should be listed |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `tableName` | string | Name Kusto assigned to the returned result table |
| `columns` | array | Column metadata for the result table |
| ↳ `name` | string | Column name |
| ↳ `type` | string | Kusto scalar type |
| ↳ `dataType` | string | Approximate .NET type |
| `rows` | array | Result rows as positional arrays matching the columns order |
| `records` | array | Result rows keyed by column name |
| `rowCount` | number | Rows carried in this result, after the row cap |
| `totalRowCount` | number | Rows Kusto returned, before the row cap was applied |
| `truncated` | boolean | Whether rows were dropped to stay within the row cap — narrow the query if true |
| `functions` | array | Stored function names, read from the Name column |
### Azure Data Explorer Ingest Inline
Push rows directly into an Azure Data Explorer table with .ingest inline. Data is parsed as CSV against the table schema unless an ingestion property says otherwise. Intended for small batches — use queued or streaming ingestion for production volumes.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net\) |
| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal |
| `clientId` | string | Yes | Microsoft Entra application \(client\) ID |
| `clientSecret` | string | Yes | Microsoft Entra application client secret |
| `resource` | string | No | Token audience override. Defaults to the cluster URI itself |
| `database` | string | Yes | Database containing the target table |
| `table` | string | Yes | Table to ingest into. Its schema is the assumed schema for the data |
| `data` | string | Yes | Rows to ingest, one record per line, parsed as CSV by default \(e.g., "Shoes,1000\\nWide Shoes,50"\) |
| `ingestionProperties` | string | No | Ingestion properties clause contents, e.g. format="json", ingestionMappingReference="mymapping" |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `tableName` | string | Name Kusto assigned to the returned result table |
| `columns` | array | Column metadata for the result table |
| ↳ `name` | string | Column name |
| ↳ `type` | string | Kusto scalar type |
| ↳ `dataType` | string | Approximate .NET type |
| `rows` | array | Result rows as positional arrays matching the columns order |
| `records` | array | Result rows keyed by column name |
| `rowCount` | number | Rows carried in this result, after the row cap |
| `totalRowCount` | number | Rows Kusto returned, before the row cap was applied |
| `truncated` | boolean | Whether rows were dropped to stay within the row cap — narrow the query if true |
| `extentIds` | array | Extent IDs created by the ingestion — one per data shard. A single empty or zero-valued ID means no data shard was generated |
### Azure Data Explorer Ingest From Query
Materialize the result of a KQL query into a table with .set, .append, .set-or-append, or .set-or-replace. Use this to build rollup or summary tables instead of pushing rows from a workflow. Kusto matches the query result to the target table by column type and position, NOT by column name, so project the columns in exactly the table's order or the data lands in the wrong columns.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net\) |
| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal |
| `clientId` | string | Yes | Microsoft Entra application \(client\) ID |
| `clientSecret` | string | Yes | Microsoft Entra application client secret |
| `resource` | string | No | Token audience override. Defaults to the cluster URI itself |
| `database` | string | Yes | Database containing the target table |
| `table` | string | Yes | Table to ingest the query result into |
| `mode` | string | No | set \(create, fail if it exists\), append \(add to an existing table\), set-or-append \(default\), or set-or-replace \(replace all data\) |
| `sourceQuery` | string | Yes | KQL query whose result becomes the ingested data \(e.g., LogsTable \| where Level == "Error" \| where Timestamp &gt; ago\(1h\)\). Project the columns in the target table\'s order — matching is positional, not by name |
| `async` | boolean | No | Return immediately with an OperationId and keep ingesting in the background. Check progress with Show Operations |
| `ingestionProperties` | string | No | Optional ingestion properties clause contents, e.g. distributed=true, tags=\"\[''daily''\]\" |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `tableName` | string | Name Kusto assigned to the returned result table |
| `columns` | array | Column metadata for the result table |
| ↳ `name` | string | Column name |
| ↳ `type` | string | Kusto scalar type |
| ↳ `dataType` | string | Approximate .NET type |
| `rows` | array | Result rows as positional arrays matching the columns order |
| `records` | array | Result rows keyed by column name |
| `rowCount` | number | Rows carried in this result, after the row cap |
| `totalRowCount` | number | Rows Kusto returned, before the row cap was applied |
| `truncated` | boolean | Whether rows were dropped to stay within the row cap — narrow the query if true |
### Azure Data Explorer Create Table
Create a table in an Azure Data Explorer database from a CSL column schema. Succeeds without changing anything if a table of the same name already exists.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net\) |
| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal |
| `clientId` | string | Yes | Microsoft Entra application \(client\) ID |
| `clientSecret` | string | Yes | Microsoft Entra application client secret |
| `resource` | string | No | Token audience override. Defaults to the cluster URI itself |
| `database` | string | Yes | Database to create the table in |
| `table` | string | Yes | Name of the table to create |
| `columnSchema` | string | Yes | Comma-separated CSL column schema \(e.g., Timestamp:datetime, Level:string, Count:long\) |
| `tableProperties` | string | No | Optional table properties clause contents, e.g. docstring="Raw logs", folder="Ingest" |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `tableName` | string | Name Kusto assigned to the returned result table |
| `columns` | array | Column metadata for the result table |
| ↳ `name` | string | Column name |
| ↳ `type` | string | Kusto scalar type |
| ↳ `dataType` | string | Approximate .NET type |
| `rows` | array | Result rows as positional arrays matching the columns order |
| `records` | array | Result rows keyed by column name |
| `rowCount` | number | Rows carried in this result, after the row cap |
| `totalRowCount` | number | Rows Kusto returned, before the row cap was applied |
| `truncated` | boolean | Whether rows were dropped to stay within the row cap — narrow the query if true |
### Azure Data Explorer Drop Table
Drop a table from an Azure Data Explorer database. This permanently deletes the table and its data, and returns the tables that remain.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net\) |
| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal |
| `clientId` | string | Yes | Microsoft Entra application \(client\) ID |
| `clientSecret` | string | Yes | Microsoft Entra application client secret |
| `resource` | string | No | Token audience override. Defaults to the cluster URI itself |
| `database` | string | Yes | Database containing the table |
| `table` | string | Yes | Name of the table to drop |
| `ifExists` | boolean | No | Succeed instead of failing when the table does not exist |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `tableName` | string | Name Kusto assigned to the returned result table |
| `columns` | array | Column metadata for the result table |
| ↳ `name` | string | Column name |
| ↳ `type` | string | Kusto scalar type |
| ↳ `dataType` | string | Approximate .NET type |
| `rows` | array | Result rows as positional arrays matching the columns order |
| `records` | array | Result rows keyed by column name |
| `rowCount` | number | Rows carried in this result, after the row cap |
| `totalRowCount` | number | Rows Kusto returned, before the row cap was applied |
| `truncated` | boolean | Whether rows were dropped to stay within the row cap — narrow the query if true |
| `tables` | array | Tables remaining in the database, read from the TableName column |
### Azure Data Explorer Show Ingestion Failures
List ingestion failures recorded for a database, with the failing table, error code, root cause detail, and whether the failure is permanent or transient. Failures are retained for 14 days.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net\) |
| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal |
| `clientId` | string | Yes | Microsoft Entra application \(client\) ID |
| `clientSecret` | string | Yes | Microsoft Entra application client secret |
| `resource` | string | No | Token audience override. Defaults to the cluster URI itself |
| `database` | string | Yes | Database whose ingestion failures should be listed |
| `operationId` | string | No | Limit results to a single ingestion operation ID |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `tableName` | string | Name Kusto assigned to the returned result table |
| `columns` | array | Column metadata for the result table |
| ↳ `name` | string | Column name |
| ↳ `type` | string | Kusto scalar type |
| ↳ `dataType` | string | Approximate .NET type |
| `rows` | array | Result rows as positional arrays matching the columns order |
| `records` | array | Result rows keyed by column name |
| `rowCount` | number | Rows carried in this result, after the row cap |
| `totalRowCount` | number | Rows Kusto returned, before the row cap was applied |
| `truncated` | boolean | Whether rows were dropped to stay within the row cap — narrow the query if true |
### Azure Data Explorer Show Operations
Check the state of administrative operations on a cluster, such as an async ingestion. Given an operation ID it returns that operation latest update; with no ID it returns the operations from the last two weeks.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `clusterUri` | string | Yes | Cluster URI \(e.g., https://mycluster.eastus.kusto.windows.net\) |
| `tenantId` | string | Yes | Microsoft Entra tenant ID hosting the service principal |
| `clientId` | string | Yes | Microsoft Entra application \(client\) ID |
| `clientSecret` | string | Yes | Microsoft Entra application client secret |
| `resource` | string | No | Token audience override. Defaults to the cluster URI itself |
| `database` | string | No | Database context for the command |
| `operationId` | string | No | Operation ID to check, e.g. the ID returned by an async ingestion |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `tableName` | string | Name Kusto assigned to the returned result table |
| `columns` | array | Column metadata for the result table |
| ↳ `name` | string | Column name |
| ↳ `type` | string | Kusto scalar type |
| ↳ `dataType` | string | Approximate .NET type |
| `rows` | array | Result rows as positional arrays matching the columns order |
| `records` | array | Result rows keyed by column name |
| `rowCount` | number | Rows carried in this result, after the row cap |
| `totalRowCount` | number | Rows Kusto returned, before the row cap was applied |
| `truncated` | boolean | Whether rows were dropped to stay within the row cap — narrow the query if true |
@@ -22,6 +22,7 @@
"atlassian-service-account",
"attio",
"attio-service-account",
"azure_data_explorer",
"azure_devops",
"box",
"box-service-account",
@@ -226,6 +226,7 @@ Query rows from a table with filtering, sorting, and pagination
| `totalCount` | number | Total rows matching filter |
| `limit` | number | Limit used in query |
| `offset` | number | Offset used in query |
| `nextCursor` | string | Non-null when more rows match past this page. A page can end early at the byte budget, so this — not a short rowCount — is what says whether more remain. To page, advance offset by rowCount and stop when this is null. |
### Get Row
@@ -0,0 +1,321 @@
/**
* @vitest-environment node
*/
import { createMockRequest, hybridAuthMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
const { mockSecureFetch, MOCK_MAX_JSON_BYTES } = vi.hoisted(() => ({
mockSecureFetch: vi.fn(),
MOCK_MAX_JSON_BYTES: 10 * 1024 * 1024,
}))
vi.mock('@/lib/core/security/input-validation.server', () => ({
secureFetchWithValidation: mockSecureFetch,
MAX_JSON_API_RESPONSE_BYTES: MOCK_MAX_JSON_BYTES,
}))
import { POST } from '@/app/api/tools/azure_data_explorer/proxy/route'
const baseBody = {
clusterUri: 'https://mycluster.eastus.kusto.windows.net',
tenantId: 'tenant-1',
clientId: 'client-1',
clientSecret: 'secret-1',
endpoint: 'query',
database: 'Samples',
csl: 'print Test="Hello, World!"',
}
function jsonResponse(body: unknown, status = 200) {
return {
ok: status >= 200 && status < 300,
status,
headers: new Headers(),
json: async () => body,
text: async () => JSON.stringify(body),
}
}
/** Queues the Entra token response, then the cluster response. */
function mockCluster(clusterBody: unknown, status = 200) {
mockSecureFetch.mockReset()
mockSecureFetch
.mockResolvedValueOnce(jsonResponse({ access_token: 'token-1', expires_in: 3600 }))
.mockResolvedValueOnce(jsonResponse(clusterBody, status))
}
let secretCounter = 0
/**
* The route caches Entra tokens per credential, so every test needs its own
* secret to exercise the token fetch rather than a warm cache entry.
*/
function post(body: Record<string, unknown>) {
secretCounter += 1
return POST(
createMockRequest('POST', { ...body, clientSecret: `secret-${secretCounter}` }) as never,
undefined as never
)
}
/**
* A v1 query answer. The primary result deliberately is NOT the first table, so
* a reader that ignores the table of contents picks the wrong one.
*/
function queryResponse(options: { severity: number; statusDescription: string }) {
return {
Tables: [
{
TableName: 'Table_0',
Columns: [{ ColumnName: 'Value', DataType: 'String', ColumnType: 'string' }],
Rows: [['{"Visualization":null}']],
},
{
TableName: 'Table_1',
Columns: [{ ColumnName: 'Test', DataType: 'String', ColumnType: 'string' }],
Rows: [['Hello, World!']],
},
{
TableName: 'Table_2',
Columns: [
{ ColumnName: 'Severity', DataType: 'Int32', ColumnType: 'int' },
{ ColumnName: 'StatusCode', DataType: 'Int32', ColumnType: 'int' },
{ ColumnName: 'StatusDescription', DataType: 'String', ColumnType: 'string' },
],
Rows: [[options.severity, 0, options.statusDescription]],
},
{
TableName: 'Table_3',
Columns: [
{ ColumnName: 'Ordinal', DataType: 'Int64', ColumnType: 'long' },
{ ColumnName: 'Kind', DataType: 'String', ColumnType: 'string' },
{ ColumnName: 'Name', DataType: 'String', ColumnType: 'string' },
],
Rows: [
[0, 'QueryProperties', '@ExtendedProperties'],
[1, 'QueryResult', 'PrimaryResult'],
[2, 'QueryStatus', 'QueryStatus'],
],
},
],
}
}
describe('POST /api/tools/azure_data_explorer/proxy', () => {
beforeEach(() => {
vi.clearAllMocks()
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({ success: true, userId: 'user-1' })
})
it('returns the primary result table named by the table of contents', async () => {
mockCluster(queryResponse({ severity: 4, statusDescription: 'Query completed successfully' }))
const response = await post(baseBody)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(data.output.tableName).toBe('Table_1')
expect(data.output.columns).toEqual([{ name: 'Test', type: 'string', dataType: 'String' }])
expect(data.output.rows).toEqual([['Hello, World!']])
expect(data.output.records).toEqual([{ Test: 'Hello, World!' }])
expect(data.output.rowCount).toBe(1)
})
it('reports a partial query failure even though the cluster answered 200', async () => {
mockCluster(queryResponse({ severity: 2, statusDescription: 'Query execution has exceeded' }))
const response = await post(baseBody)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.success).toBe(false)
expect(data.error).toBe('Query execution has exceeded')
})
it('does not mistake a result column named Severity for a failed query', async () => {
const shadowed = queryResponse({
severity: 4,
statusDescription: 'Query completed successfully',
})
shadowed.Tables[1] = {
TableName: 'Table_1',
Columns: [
{ ColumnName: 'Severity', DataType: 'Int32', ColumnType: 'int' },
{ ColumnName: 'StatusDescription', DataType: 'String', ColumnType: 'string' },
],
Rows: [[1, 'disk almost full']],
}
mockCluster(shadowed)
const response = await post(baseBody)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(data.output.records).toEqual([{ Severity: 1, StatusDescription: 'disk almost full' }])
})
it('returns the first table for a management command, which has no table of contents', async () => {
mockCluster({
Tables: [
{
TableName: 'Table_0',
Columns: [{ ColumnName: 'TableName', DataType: 'String', ColumnType: 'string' }],
Rows: [['StormEvents'], ['Logs']],
},
],
})
const response = await post({ ...baseBody, endpoint: 'mgmt', csl: '.show tables' })
const data = await response.json()
expect(data.output.records).toEqual([{ TableName: 'StormEvents' }, { TableName: 'Logs' }])
})
it('sends the KQL request to the cluster with a bearer token and read-only header', async () => {
mockCluster(queryResponse({ severity: 4, statusDescription: 'Query completed successfully' }))
await post({ ...baseBody, readOnly: true })
const [url, options] = mockSecureFetch.mock.calls[1]
expect(url).toBe('https://mycluster.eastus.kusto.windows.net/v1/rest/query')
expect(options.headers.Authorization).toBe('Bearer token-1')
expect(options.headers['x-ms-readonly']).toBe('true')
expect(JSON.parse(options.body)).toEqual({ db: 'Samples', csl: baseBody.csl })
})
it('requests an Entra token audience of the cluster origin by default', async () => {
mockCluster(queryResponse({ severity: 4, statusDescription: 'Query completed successfully' }))
await post(baseBody)
const [tokenUrl, tokenOptions] = mockSecureFetch.mock.calls[0]
expect(tokenUrl).toBe('https://login.microsoftonline.com/tenant-1/oauth2/token')
expect(Object.fromEntries(new URLSearchParams(tokenOptions.body))).toEqual({
grant_type: 'client_credentials',
client_id: 'client-1',
client_secret: `secret-${secretCounter}`,
resource: 'https://mycluster.eastus.kusto.windows.net',
})
})
it.each([
['https://c.usgovvirginia.kusto.usgovcloudapi.net', 'https://login.microsoftonline.us'],
['https://c.chinanorth.kusto.chinacloudapi.cn', 'https://login.partner.microsoftonline.cn'],
['https://c.eastus.kusto.windows.net', 'https://login.microsoftonline.com'],
])('authenticates %s against its own cloud Entra authority', async (clusterUri, authority) => {
mockCluster(queryResponse({ severity: 4, statusDescription: 'Query completed successfully' }))
const response = await post({ ...baseBody, clusterUri, resource: undefined })
expect(response.status).toBe(200)
const [tokenUrl] = mockSecureFetch.mock.calls[0]
expect(tokenUrl).toBe(`${authority}/tenant-1/oauth2/token`)
})
it('rejects a cluster URI outside the Kusto service domains', async () => {
mockCluster(queryResponse({ severity: 4, statusDescription: 'Query completed successfully' }))
const response = await post({ ...baseBody, clusterUri: 'https://evil.example.com' })
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toContain('Azure Data Explorer or Fabric Eventhouse endpoint')
expect(mockSecureFetch).not.toHaveBeenCalled()
})
it('surfaces the Kusto error envelope on a failed request', async () => {
mockCluster({ error: { code: 'BadRequest_SyntaxError', message: "Syntax error: 'wher'" } }, 400)
const response = await post(baseBody)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toBe("[BadRequest_SyntaxError] Syntax error: 'wher'")
})
it('rejects an entity name outside the documented Kusto identifier character set', async () => {
const response = await post({ ...baseBody, database: 'Samples"] | drop table X //' })
expect(response.status).toBe(400)
expect(mockSecureFetch).not.toHaveBeenCalled()
})
it('accepts an apex token audience, which is the Fabric Eventhouse form', async () => {
mockCluster(queryResponse({ severity: 4, statusDescription: 'Query completed successfully' }))
const response = await post({ ...baseBody, resource: 'https://kusto.fabric.microsoft.com' })
expect(response.status).toBe(200)
const [, tokenOptions] = mockSecureFetch.mock.calls[0]
expect(Object.fromEntries(new URLSearchParams(tokenOptions.body)).resource).toBe(
'https://kusto.fabric.microsoft.com'
)
})
it('caps the rows it returns and reports what the cluster actually produced', async () => {
const wide = queryResponse({ severity: 4, statusDescription: 'Query completed successfully' })
wide.Tables[1].Rows = Array.from({ length: 10_050 }, (_, i) => [`row-${i}`])
mockCluster(wide)
const response = await post(baseBody)
const data = await response.json()
expect(data.output.rowCount).toBe(10_000)
expect(data.output.rows).toHaveLength(10_000)
expect(data.output.records).toHaveLength(10_000)
expect(data.output.totalRowCount).toBe(10_050)
expect(data.output.truncated).toBe(true)
})
it('reports truncated as false when every row fits', async () => {
mockCluster(queryResponse({ severity: 4, statusDescription: 'Query completed successfully' }))
const response = await post(baseBody)
const data = await response.json()
expect(data.output.truncated).toBe(false)
expect(data.output.totalRowCount).toBe(1)
})
it('bounds the cluster response body rather than reading it unlimited', async () => {
mockCluster(queryResponse({ severity: 4, statusDescription: 'Query completed successfully' }))
await post(baseBody)
const [, options] = mockSecureFetch.mock.calls[1]
expect(options.maxResponseBytes).toBe(MOCK_MAX_JSON_BYTES)
})
it('asks the caller to narrow the query when the response exceeds the cap', async () => {
mockSecureFetch.mockReset()
mockSecureFetch
.mockResolvedValueOnce(jsonResponse({ access_token: 'token-1', expires_in: 3600 }))
.mockRejectedValueOnce(
new PayloadSizeLimitError({
label: 'Azure Data Explorer response',
maxBytes: MOCK_MAX_JSON_BYTES,
})
)
const response = await post(baseBody)
const data = await response.json()
expect(response.status).toBe(413)
expect(data.error).toContain('Narrow the query')
})
it('rejects an unauthenticated request before reaching the cluster', async () => {
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
success: false,
error: 'Authentication required',
})
const response = await post(baseBody)
expect(response.status).toBe(401)
expect(mockSecureFetch).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,403 @@
import { createHash } from 'node:crypto'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { truncate } from '@sim/utils/string'
import { type NextRequest, NextResponse } from 'next/server'
import {
type AzureDataExplorerProxyRequest,
assertSafeAzureDataExplorerClusterUri,
azureDataExplorerProxyContract,
resolveEntraAuthority,
} from '@/lib/api/contracts/tools/azure_data_explorer'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import {
MAX_JSON_API_RESPONSE_BYTES,
secureFetchWithValidation,
} from '@/lib/core/security/input-validation.server'
import { generateRequestId } from '@/lib/core/utils/request'
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
export const dynamic = 'force-dynamic'
const logger = createLogger('AzureDataExplorerProxyAPI')
const OUTBOUND_FETCH_TIMEOUT_MS = 120_000
const TOKEN_FETCH_TIMEOUT_MS = 30_000
const TOKEN_CACHE_MAX_ENTRIES = 500
const TOKEN_SAFETY_WINDOW_MS = 60_000
const MAX_ERROR_MESSAGE_LENGTH = 2000
const MAX_TOKEN_RESPONSE_BYTES = 256 * 1024
/** Rows a single result may carry into a workflow value. */
const MAX_PROJECTED_ROWS = 10_000
interface CachedToken {
accessToken: string
expiresAt: number
}
const TOKEN_CACHE = new Map<string, CachedToken>()
/** The cluster's own origin is the documented default token audience. */
function resolveResource(req: AzureDataExplorerProxyRequest, clusterUrl: URL): string {
return (req.resource || clusterUrl.origin).replace(/\/+$/, '')
}
function tokenCacheKey(
req: AzureDataExplorerProxyRequest,
authority: string,
resource: string
): string {
const secretHash = createHash('sha256').update(req.clientSecret).digest('hex').slice(0, 16)
return `${authority}::${req.tenantId}::${req.clientId}::${secretHash}::${resource}`
}
function rememberToken(key: string, token: CachedToken): void {
if (TOKEN_CACHE.has(key)) TOKEN_CACHE.delete(key)
TOKEN_CACHE.set(key, token)
while (TOKEN_CACHE.size > TOKEN_CACHE_MAX_ENTRIES) {
const oldestKey = TOKEN_CACHE.keys().next().value
if (oldestKey === undefined) break
TOKEN_CACHE.delete(oldestKey)
}
}
async function fetchAccessToken(
req: AzureDataExplorerProxyRequest,
authority: string,
resource: string,
requestId: string
): Promise<string> {
const cacheKey = tokenCacheKey(req, authority, resource)
const cached = TOKEN_CACHE.get(cacheKey)
if (cached && cached.expiresAt - TOKEN_SAFETY_WINDOW_MS > Date.now()) {
return cached.accessToken
}
const body = new URLSearchParams({
grant_type: 'client_credentials',
client_id: req.clientId,
client_secret: req.clientSecret,
resource,
})
const response = await secureFetchWithValidation(
`${authority}/${encodeURIComponent(req.tenantId)}/oauth2/token`,
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: body.toString(),
timeout: TOKEN_FETCH_TIMEOUT_MS,
maxResponseBytes: MAX_TOKEN_RESPONSE_BYTES,
},
'tokenUrl'
)
if (!response.ok) {
const text = await response.text().catch(() => '')
logger.warn(`[${requestId}] Entra token fetch failed (${response.status}): ${text}`)
throw new Error(
`Microsoft Entra token request failed: HTTP ${response.status}. Verify tenantId, clientId, clientSecret, and that the app has access to the cluster.`
)
}
const data = (await response.json()) as { access_token?: string; expires_in?: string | number }
if (!data.access_token) {
throw new Error('Microsoft Entra token response did not include an access token')
}
const expiresInSeconds = Number(data.expires_in)
const expiresInMs = (Number.isFinite(expiresInSeconds) ? expiresInSeconds : 3600) * 1000
rememberToken(cacheKey, {
accessToken: data.access_token,
expiresAt: Date.now() + expiresInMs,
})
return data.access_token
}
interface KustoColumn {
ColumnName?: string
DataType?: string
ColumnType?: string
}
interface KustoTable {
TableName?: string
Columns?: KustoColumn[]
Rows?: unknown[][]
}
function columnNames(table: KustoTable): string[] {
return (table.Columns ?? []).map((column) => column.ColumnName ?? '')
}
interface TableOfContents {
primaryOrdinal: number | null
statusOrdinal: number | null
}
/**
* Reads the trailing table of contents, which maps each ordinal in the response
* to a kind. It is the only thing that identifies which table holds the query's
* results and which holds its status a management command has no table of
* contents, and returns `null` here.
*/
function readTableOfContents(tables: KustoTable[]): TableOfContents | null {
if (tables.length === 0) return null
const contents = tables[tables.length - 1]
const names = columnNames(contents)
const ordinalIndex = names.indexOf('Ordinal')
const kindIndex = names.indexOf('Kind')
if (ordinalIndex < 0 || kindIndex < 0) return null
let primaryOrdinal: number | null = null
let statusOrdinal: number | null = null
for (const row of contents.Rows ?? []) {
const ordinal = Number(row[ordinalIndex])
if (!Number.isInteger(ordinal) || !tables[ordinal]) continue
if (row[kindIndex] === 'QueryResult' && primaryOrdinal === null) primaryOrdinal = ordinal
if (row[kindIndex] === 'QueryStatus' && statusOrdinal === null) statusOrdinal = ordinal
}
return { primaryOrdinal, statusOrdinal }
}
/**
* Picks the table holding the query's own results the first `QueryResult`
* ordinal the table of contents names. A management command returns a single
* table with no table of contents, so the first table is the answer.
*/
function selectPrimaryTable(
tables: KustoTable[],
contents: TableOfContents | null
): KustoTable | null {
if (tables.length === 0) return null
if (contents?.primaryOrdinal != null) return tables[contents.primaryOrdinal] ?? tables[0]
return tables[0]
}
/**
* Finds a partial query failure. Kusto answers 200 as soon as it starts
* processing, then reports later failures through the QueryStatus table, where a
* severity of 2 or lower means the request did not succeed.
*
* Only the table the table of contents names as `QueryStatus` is inspected.
* Scanning every table for `Severity`/`StatusDescription` columns would
* misread an ordinary log query that happens to select columns of those names
* as a failed request.
*/
function findQueryFailure(tables: KustoTable[], contents: TableOfContents | null): string | null {
if (contents?.statusOrdinal == null) return null
const table = tables[contents.statusOrdinal]
if (!table) return null
const names = columnNames(table)
const severityIndex = names.indexOf('Severity')
const descriptionIndex = names.indexOf('StatusDescription')
if (severityIndex < 0 || descriptionIndex < 0) return null
for (const row of table.Rows ?? []) {
const severity = Number(row[severityIndex])
if (!Number.isFinite(severity) || severity > 2) continue
const description = row[descriptionIndex]
return typeof description === 'string' && description.length > 0
? description
: 'Kusto reported a query failure'
}
return null
}
interface ProjectedTable {
tableName: string | null
columns: Array<{ name: string; type: string | null; dataType: string | null }>
rows: unknown[][]
records: Array<Record<string, unknown>>
rowCount: number
totalRowCount: number
truncated: boolean
}
const EMPTY_PROJECTION: ProjectedTable = {
tableName: null,
columns: [],
rows: [],
records: [],
rowCount: 0,
totalRowCount: 0,
truncated: false,
}
/**
* Projects the result table into a bounded payload.
*
* Kusto's own result truncation is a request property the caller can raise or
* disable, so neither the row count nor the byte count of a response is bounded
* upstream. `MAX_PROJECTED_ROWS` is the ceiling on what a single workflow value
* may carry; `truncated` tells the caller to narrow the query rather than
* silently trusting a short answer.
*/
function projectTable(table: KustoTable | null): ProjectedTable {
if (!table) return EMPTY_PROJECTION
const columns = (table.Columns ?? []).map((column) => ({
name: column.ColumnName ?? '',
type: column.ColumnType ?? null,
dataType: column.DataType ?? null,
}))
const allRows = table.Rows ?? []
const rows = allRows.length > MAX_PROJECTED_ROWS ? allRows.slice(0, MAX_PROJECTED_ROWS) : allRows
const records = rows.map((row) => {
const record: Record<string, unknown> = {}
columns.forEach((column, index) => {
if (column.name) record[column.name] = row[index] ?? null
})
return record
})
return {
tableName: table.TableName ?? null,
columns,
rows,
records,
rowCount: rows.length,
totalRowCount: allRows.length,
truncated: allRows.length > rows.length,
}
}
/**
* Kusto failures follow the Microsoft REST guidelines envelope, but a request
* without a JSON body (or a gateway error) can answer with plain text.
*/
function extractKustoError(body: unknown, status: number): string {
if (body && typeof body === 'object') {
const error = (body as { error?: { code?: unknown; message?: unknown } }).error
if (error && typeof error === 'object') {
const message = typeof error.message === 'string' ? error.message : ''
const code = typeof error.code === 'string' ? error.code : ''
if (message) return code ? `[${code}] ${message}` : message
if (code) return code
}
}
if (typeof body === 'string' && body.length > 0) {
return truncate(body, MAX_ERROR_MESSAGE_LENGTH)
}
return `Azure Data Explorer request failed with HTTP ${status}`
}
export const POST = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
try {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
logger.warn(`[${requestId}] Unauthorized Azure Data Explorer request: ${authResult.error}`)
return NextResponse.json(
{ success: false, error: authResult.error || 'Authentication required' },
{ status: 401 }
)
}
const parsed = await parseRequest(
azureDataExplorerProxyContract,
request,
{},
{
validationErrorResponse: (error) =>
NextResponse.json(
{ success: false, error: getValidationErrorMessage(error, 'Validation failed') },
{ status: 400 }
),
}
)
if (!parsed.success) return parsed.response
const proxyReq = parsed.data.body
const clusterUrl = assertSafeAzureDataExplorerClusterUri(proxyReq.clusterUri)
const resource = resolveResource(proxyReq, clusterUrl)
const authority = resolveEntraAuthority(clusterUrl.hostname)
const accessToken = await fetchAccessToken(proxyReq, authority, resource, requestId)
const headers: Record<string, string> = {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'Content-Type': 'application/json; charset=utf-8',
'x-ms-client-request-id': `Sim.Workflow;${requestId}`,
'x-ms-app': 'Sim',
}
if (proxyReq.readOnly) headers['x-ms-readonly'] = 'true'
const response = await secureFetchWithValidation(
`${clusterUrl.origin}/v1/rest/${proxyReq.endpoint}`,
{
method: 'POST',
headers,
body: JSON.stringify({
...(proxyReq.database ? { db: proxyReq.database } : {}),
csl: proxyReq.csl,
...(proxyReq.properties ? { properties: proxyReq.properties } : {}),
}),
timeout: OUTBOUND_FETCH_TIMEOUT_MS,
maxResponseBytes: MAX_JSON_API_RESPONSE_BYTES,
},
'clusterUri'
)
const raw = await response.text()
let body: unknown = null
if (raw.length > 0) {
try {
body = JSON.parse(raw)
} catch {
body = raw
}
}
if (!response.ok) {
const message = extractKustoError(body, response.status)
logger.warn(`[${requestId}] Azure Data Explorer error (${response.status}): ${message}`)
return NextResponse.json(
{ success: false, error: message, status: response.status },
{ status: response.status }
)
}
const tables = Array.isArray((body as { Tables?: KustoTable[] } | null)?.Tables)
? ((body as { Tables: KustoTable[] }).Tables ?? [])
: []
const contents = readTableOfContents(tables)
const failure = findQueryFailure(tables, contents)
if (failure) {
logger.warn(`[${requestId}] Azure Data Explorer partial query failure: ${failure}`)
return NextResponse.json(
{ success: false, error: truncate(failure, MAX_ERROR_MESSAGE_LENGTH), status: 200 },
{ status: 400 }
)
}
return NextResponse.json({
success: true,
output: projectTable(selectPrimaryTable(tables, contents)),
})
} catch (error) {
if (isPayloadSizeLimitError(error)) {
logger.warn(`[${requestId}] Azure Data Explorer response exceeded the size cap`)
return NextResponse.json(
{
success: false,
error:
'The Azure Data Explorer response was too large to return. Narrow the query — add a `where` filter, aggregate with `summarize`, or bound it with `take` or `top N by`.',
},
{ status: 413 }
)
}
logger.error(`[${requestId}] Unexpected Azure Data Explorer proxy error:`, error)
return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 })
}
})
@@ -0,0 +1,657 @@
import { AzureDataExplorerIcon } from '@/components/icons'
import type { BlockConfig, BlockMeta } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
import type { AzureDataExplorerTableResponse } from '@/tools/azure_data_explorer/types'
/** Every operation except the cluster-level database listing runs in a database. */
const DATABASE_SCOPED_OPERATIONS = [
'azure_data_explorer_query',
'azure_data_explorer_management',
'azure_data_explorer_list_tables',
'azure_data_explorer_show_table_schema',
'azure_data_explorer_show_table_details',
'azure_data_explorer_show_database_schema',
'azure_data_explorer_list_functions',
'azure_data_explorer_create_table',
'azure_data_explorer_drop_table',
'azure_data_explorer_ingest_inline',
'azure_data_explorer_ingest_from_query',
'azure_data_explorer_show_ingestion_failures',
'azure_data_explorer_show_operations',
] as const
/** Operations the cluster rejects without a database in context. */
const DATABASE_REQUIRED_OPERATIONS = [
'azure_data_explorer_query',
'azure_data_explorer_list_tables',
'azure_data_explorer_show_table_schema',
'azure_data_explorer_show_table_details',
'azure_data_explorer_show_database_schema',
'azure_data_explorer_list_functions',
'azure_data_explorer_create_table',
'azure_data_explorer_drop_table',
'azure_data_explorer_ingest_inline',
'azure_data_explorer_ingest_from_query',
'azure_data_explorer_show_ingestion_failures',
] as const
/** Operations that name a single table. */
const TABLE_OPERATIONS = [
'azure_data_explorer_show_table_schema',
'azure_data_explorer_show_table_details',
'azure_data_explorer_create_table',
'azure_data_explorer_drop_table',
'azure_data_explorer_ingest_inline',
'azure_data_explorer_ingest_from_query',
] as const
/** Table operations where the table name is mandatory. */
const TABLE_REQUIRED_OPERATIONS = [
'azure_data_explorer_show_table_schema',
'azure_data_explorer_create_table',
'azure_data_explorer_drop_table',
'azure_data_explorer_ingest_inline',
'azure_data_explorer_ingest_from_query',
] as const
/** Operations that accept an operation ID filter. */
const OPERATION_ID_OPERATIONS = [
'azure_data_explorer_show_ingestion_failures',
'azure_data_explorer_show_operations',
] as const
const KQL_WAND_PROMPT = `Write a Kusto Query Language (KQL) query for Azure Data Explorer based on the user's request.
Rules:
- Start with the table name, then pipe into operators with |
- Filter with where, project columns with project, aggregate with summarize ... by
- Limit results with take or top N by <column>
- String comparison is case-sensitive with ==, case-insensitive with =~; use contains / has for substring and term matching
- Time filters use ago(), e.g. where Timestamp > ago(7d)
- String literals use double quotes
Examples:
- StormEvents | where State == "FLORIDA" | summarize count() by EventType | top 10 by count_
- Logs | where Timestamp > ago(1h) and Level == "Error" | project Timestamp, Message | take 100
Return ONLY the KQL query - no explanations, no markdown fences.`
const COLUMN_SCHEMA_WAND_PROMPT = `Write a Kusto CSL column schema for Azure Data Explorer based on the user's request.
Rules:
- Format is a comma-separated list of Name:type pairs
- Valid types are bool, datetime, decimal, dynamic, guid, int, long, real, string, and timespan
- Use datetime for timestamps, long for counts, real for measurements, dynamic for nested JSON
- Column names use letters, digits, and underscores
Examples:
- Timestamp:datetime, Level:string, Message:string, ServiceName:string
- EventTime:datetime, UserId:string, Properties:dynamic, DurationMs:long
Return ONLY the schema - no explanations, no parentheses, no markdown fences.`
/**
* Kusto matches an ingested query result to the target table positionally, by
* column type never by column name so the prompt has to make column order
* explicit or the generated query quietly fills the wrong columns.
*/
const INGEST_QUERY_WAND_PROMPT = `${KQL_WAND_PROMPT.replace('Return ONLY the KQL query - no explanations, no markdown fences.', '')}
This query's result is ingested into an existing table. Kusto matches columns by
position and type, NOT by name, so the projected columns must come out in the
target table's column order. Use an explicit \`project\` listing the columns in
that order as the final operator.
Return ONLY the KQL query - no explanations, no markdown fences.`
/** Switch and dropdown values arrive as booleans or their string form. */
function toBoolean(value: unknown): true | undefined {
return value === true || value === 'true' ? true : undefined
}
export const AzureDataExplorerBlock: BlockConfig<AzureDataExplorerTableResponse> = {
type: 'azure_data_explorer',
name: 'Azure Data Explorer',
description: 'Query and manage Azure Data Explorer (Kusto) clusters with KQL',
longDescription:
'Run Kusto Query Language queries against Azure Data Explorer and Fabric Eventhouse clusters, discover databases, tables, and schemas, push small batches of rows inline, and run management commands. Authenticates with a Microsoft Entra service principal using client credentials, so no interactive sign-in is needed.',
docsLink: 'https://docs.sim.ai/integrations/azure_data_explorer',
category: 'tools',
integrationType: IntegrationType.Databases,
bgColor: '#FFFFFF',
icon: AzureDataExplorerIcon,
authMode: AuthMode.ApiKey,
canvasPresentation: {
typeLabel: 'Azure Data Explorer',
defaultTitle: 'Azure Data Explorer',
operationSubBlockId: 'operation',
sentences: {
byOperation: {
azure_data_explorer_query: [
{ text: 'Run KQL', field: 'query', core: true },
{ text: 'against', field: 'database' },
],
azure_data_explorer_management: [
{ text: 'Run', field: 'command', core: true },
{ text: 'against', field: 'database' },
],
azure_data_explorer_list_databases: ['List the cluster databases'],
azure_data_explorer_list_tables: [
{ text: 'List tables in', field: 'database', core: true },
],
azure_data_explorer_show_table_schema: [
{ text: 'Read the schema of', field: 'table', core: true },
{ text: 'in', field: 'database' },
],
azure_data_explorer_show_database_schema: [
{ text: 'Read the schema of', field: 'database', core: true },
],
azure_data_explorer_ingest_inline: [
{ text: 'Ingest rows into', field: 'table', core: true },
{ text: 'in', field: 'database' },
],
azure_data_explorer_ingest_from_query: [
{ text: 'Ingest a query result into', field: 'table', core: true },
{ text: 'from', field: 'sourceQuery' },
],
azure_data_explorer_create_table: [
{ text: 'Create table', field: 'table', core: true },
{ text: 'with columns', field: 'columnSchema' },
],
azure_data_explorer_drop_table: [{ text: 'Drop table', field: 'table', core: true }],
azure_data_explorer_show_table_details: [
'Read table details',
{ text: 'for', field: 'table' },
{ text: 'in', field: 'database' },
],
azure_data_explorer_list_functions: [
{ text: 'List stored functions in', field: 'database', core: true },
],
azure_data_explorer_show_ingestion_failures: [
{ text: 'List ingestion failures in', field: 'database', core: true },
],
azure_data_explorer_show_operations: [
'Check cluster operations',
{ text: 'for', field: 'operationId' },
],
},
},
},
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
{ label: 'Run Query', id: 'azure_data_explorer_query' },
{ label: 'List Databases', id: 'azure_data_explorer_list_databases' },
{ label: 'List Tables', id: 'azure_data_explorer_list_tables' },
{ label: 'Show Table Schema', id: 'azure_data_explorer_show_table_schema' },
{ label: 'Show Database Schema', id: 'azure_data_explorer_show_database_schema' },
{ label: 'Show Table Details', id: 'azure_data_explorer_show_table_details' },
{ label: 'List Functions', id: 'azure_data_explorer_list_functions' },
{ label: 'Ingest Rows Inline', id: 'azure_data_explorer_ingest_inline' },
{ label: 'Ingest From Query', id: 'azure_data_explorer_ingest_from_query' },
{ label: 'Create Table', id: 'azure_data_explorer_create_table' },
{ label: 'Drop Table', id: 'azure_data_explorer_drop_table' },
{ label: 'Show Ingestion Failures', id: 'azure_data_explorer_show_ingestion_failures' },
{ label: 'Show Operations', id: 'azure_data_explorer_show_operations' },
{ label: 'Management Command (advanced)', id: 'azure_data_explorer_management' },
],
value: () => 'azure_data_explorer_query',
required: true,
},
{
id: 'database',
title: 'Database',
type: 'short-input',
placeholder: 'Samples',
condition: { field: 'operation', value: [...DATABASE_SCOPED_OPERATIONS] },
required: { field: 'operation', value: [...DATABASE_REQUIRED_OPERATIONS] },
},
{
id: 'query',
title: 'KQL Query',
type: 'long-input',
placeholder: 'StormEvents | where State == "FLORIDA" | summarize count() by EventType',
condition: { field: 'operation', value: 'azure_data_explorer_query' },
required: { field: 'operation', value: 'azure_data_explorer_query' },
wandConfig: {
enabled: true,
prompt: KQL_WAND_PROMPT,
placeholder:
'Describe the query you want (e.g., "error count per service in the last day")',
},
},
{
id: 'readOnly',
title: 'Read-only',
type: 'switch',
description: 'Reject any request that would change data (sends x-ms-readonly)',
condition: { field: 'operation', value: 'azure_data_explorer_query' },
mode: 'advanced',
},
{
id: 'properties',
title: 'Request Properties',
type: 'long-input',
placeholder: '{"Options":{"servertimeout":"00:04:00"}}',
condition: { field: 'operation', value: 'azure_data_explorer_query' },
mode: 'advanced',
wandConfig: {
enabled: true,
prompt:
'Generate a Kusto request properties JSON object. The shape is {"Options":{...},"Parameters":{...}}. Common options are servertimeout (a timespan like "00:04:00"), queryconsistency ("strongconsistency" or "weakconsistency"), and truncationmaxrecords. Return ONLY the JSON object.',
generationType: 'json-object',
placeholder: 'Describe the request options you need',
},
},
{
id: 'table',
title: 'Table',
type: 'short-input',
placeholder: 'StormEvents',
condition: { field: 'operation', value: [...TABLE_OPERATIONS] },
required: { field: 'operation', value: [...TABLE_REQUIRED_OPERATIONS] },
},
{
id: 'data',
title: 'Rows',
type: 'long-input',
placeholder: 'Shoes,1000\nWide Shoes,50',
condition: { field: 'operation', value: 'azure_data_explorer_ingest_inline' },
required: { field: 'operation', value: 'azure_data_explorer_ingest_inline' },
},
{
id: 'columnSchema',
title: 'Column Schema',
type: 'long-input',
placeholder: 'Timestamp:datetime, Level:string, Message:string, Count:long',
condition: { field: 'operation', value: 'azure_data_explorer_create_table' },
required: { field: 'operation', value: 'azure_data_explorer_create_table' },
wandConfig: {
enabled: true,
prompt: COLUMN_SCHEMA_WAND_PROMPT,
placeholder:
'Describe the columns you want (e.g., "error logs with a timestamp and service")',
},
},
{
id: 'tableProperties',
title: 'Table Properties',
type: 'short-input',
placeholder: 'docstring="Raw application logs", folder="Ingest"',
condition: { field: 'operation', value: 'azure_data_explorer_create_table' },
mode: 'advanced',
},
{
id: 'ifExists',
title: 'Ignore missing table',
type: 'switch',
description: 'Succeed instead of failing when the table does not exist',
condition: { field: 'operation', value: 'azure_data_explorer_drop_table' },
mode: 'advanced',
},
{
id: 'ingestMode',
title: 'Mode',
type: 'dropdown',
options: [
{ label: 'Set or append — create, or add to an existing table', id: 'set-or-append' },
{ label: 'Set — create, fail if the table exists', id: 'set' },
{ label: 'Append — add to an existing table', id: 'append' },
{ label: 'Set or replace — replace all data in the table', id: 'set-or-replace' },
],
value: () => 'set-or-append',
condition: { field: 'operation', value: 'azure_data_explorer_ingest_from_query' },
required: { field: 'operation', value: 'azure_data_explorer_ingest_from_query' },
},
{
id: 'sourceQuery',
title: 'Source Query',
type: 'long-input',
placeholder: 'LogsTable | where Level == "Error" and Timestamp > ago(1h)',
condition: { field: 'operation', value: 'azure_data_explorer_ingest_from_query' },
required: { field: 'operation', value: 'azure_data_explorer_ingest_from_query' },
wandConfig: {
enabled: true,
prompt: INGEST_QUERY_WAND_PROMPT,
placeholder: 'Describe the rows you want to materialize into the table',
},
},
{
id: 'ingestAsync',
title: 'Run in background',
type: 'switch',
description:
'Return an operation ID immediately and keep ingesting — check it with Show Operations',
condition: { field: 'operation', value: 'azure_data_explorer_ingest_from_query' },
mode: 'advanced',
},
{
id: 'operationId',
title: 'Operation ID',
type: 'short-input',
placeholder: 'Operation ID returned by an async ingestion',
condition: { field: 'operation', value: [...OPERATION_ID_OPERATIONS] },
mode: 'advanced',
},
{
id: 'ingestionProperties',
title: 'Ingestion Properties',
type: 'short-input',
placeholder: 'format="json", ingestionMappingReference="mymapping"',
condition: {
field: 'operation',
value: ['azure_data_explorer_ingest_inline', 'azure_data_explorer_ingest_from_query'],
},
mode: 'advanced',
},
{
id: 'command',
title: 'Management Command',
type: 'long-input',
placeholder: '.show table StormEvents details',
condition: { field: 'operation', value: 'azure_data_explorer_management' },
required: { field: 'operation', value: 'azure_data_explorer_management' },
wandConfig: {
enabled: true,
prompt:
'Write a single Azure Data Explorer management command for the user\'s request. Management commands start with a dot, e.g. ".show tables details", ".create table Events (Timestamp:datetime, Message:string)", ".alter-merge table Events policy retention softdelete = 30d". Return ONLY the command text.',
placeholder: 'Describe the management command you want',
},
},
{
id: 'clusterUri',
title: 'Cluster URI',
type: 'short-input',
placeholder: 'https://mycluster.eastus.kusto.windows.net',
required: true,
},
{
id: 'tenantId',
title: 'Tenant ID',
type: 'short-input',
placeholder: 'Microsoft Entra directory (tenant) ID',
required: true,
},
{
id: 'clientId',
title: 'Client ID',
type: 'short-input',
placeholder: 'Application (client) ID of the service principal',
password: true,
required: true,
},
{
id: 'clientSecret',
title: 'Client Secret',
type: 'short-input',
placeholder: 'Client secret of the service principal',
password: true,
required: true,
},
{
id: 'resource',
title: 'Token Audience',
type: 'short-input',
placeholder: 'Defaults to the cluster URI',
mode: 'advanced',
},
],
tools: {
access: [
'azure_data_explorer_query',
'azure_data_explorer_management',
'azure_data_explorer_list_databases',
'azure_data_explorer_list_tables',
'azure_data_explorer_show_table_schema',
'azure_data_explorer_show_database_schema',
'azure_data_explorer_show_table_details',
'azure_data_explorer_list_functions',
'azure_data_explorer_ingest_inline',
'azure_data_explorer_ingest_from_query',
'azure_data_explorer_create_table',
'azure_data_explorer_drop_table',
'azure_data_explorer_show_ingestion_failures',
'azure_data_explorer_show_operations',
],
config: {
tool: (params) => params.operation,
params: (params) => {
const auth = {
clusterUri: params.clusterUri,
tenantId: params.tenantId,
clientId: params.clientId,
clientSecret: params.clientSecret,
resource: params.resource || undefined,
}
switch (params.operation) {
case 'azure_data_explorer_query':
return {
...auth,
database: params.database,
query: params.query,
properties: params.properties || undefined,
readOnly: toBoolean(params.readOnly),
}
case 'azure_data_explorer_management':
return {
...auth,
database: params.database || undefined,
command: params.command,
}
case 'azure_data_explorer_list_databases':
return auth
case 'azure_data_explorer_list_tables':
case 'azure_data_explorer_show_database_schema':
return { ...auth, database: params.database }
case 'azure_data_explorer_show_table_schema':
return { ...auth, database: params.database, table: params.table }
case 'azure_data_explorer_ingest_inline':
return {
...auth,
database: params.database,
table: params.table,
data: params.data,
ingestionProperties: params.ingestionProperties || undefined,
}
case 'azure_data_explorer_ingest_from_query':
return {
...auth,
database: params.database,
table: params.table,
mode: params.ingestMode || undefined,
sourceQuery: params.sourceQuery,
async: toBoolean(params.ingestAsync),
ingestionProperties: params.ingestionProperties || undefined,
}
case 'azure_data_explorer_create_table':
return {
...auth,
database: params.database,
table: params.table,
columnSchema: params.columnSchema,
tableProperties: params.tableProperties || undefined,
}
case 'azure_data_explorer_drop_table':
return {
...auth,
database: params.database,
table: params.table,
ifExists: toBoolean(params.ifExists),
}
case 'azure_data_explorer_show_table_details':
return { ...auth, database: params.database, table: params.table || undefined }
case 'azure_data_explorer_list_functions':
return { ...auth, database: params.database }
case 'azure_data_explorer_show_ingestion_failures':
return {
...auth,
database: params.database,
operationId: params.operationId || undefined,
}
case 'azure_data_explorer_show_operations':
return {
...auth,
database: params.database || undefined,
operationId: params.operationId || undefined,
}
default:
return auth
}
},
},
},
inputs: {
operation: { type: 'string', description: 'Operation to run' },
clusterUri: { type: 'string', description: 'Azure Data Explorer cluster URI' },
tenantId: { type: 'string', description: 'Microsoft Entra tenant ID' },
clientId: { type: 'string', description: 'Microsoft Entra application (client) ID' },
clientSecret: { type: 'string', description: 'Microsoft Entra client secret' },
resource: { type: 'string', description: 'Token audience override' },
database: { type: 'string', description: 'Database context for the request' },
query: { type: 'string', description: 'KQL query text' },
properties: { type: 'json', description: 'Kusto request properties object' },
readOnly: { type: 'boolean', description: 'Reject any request that would change data' },
command: { type: 'string', description: 'Management command text' },
table: { type: 'string', description: 'Target table name' },
data: { type: 'string', description: 'Rows to ingest, one record per line' },
ingestionProperties: { type: 'string', description: 'Ingestion properties clause contents' },
columnSchema: { type: 'string', description: 'CSL column schema for a new table' },
tableProperties: { type: 'string', description: 'Table properties clause contents' },
ifExists: { type: 'boolean', description: 'Ignore a missing table when dropping' },
ingestMode: { type: 'string', description: 'set | append | set-or-append | set-or-replace' },
sourceQuery: { type: 'string', description: 'KQL query whose result is ingested' },
ingestAsync: { type: 'boolean', description: 'Ingest in the background' },
operationId: { type: 'string', description: 'Operation ID to look up' },
},
outputs: {
tableName: { type: 'string', description: 'Name Kusto assigned to the result table' },
columns: { type: 'json', description: 'Column metadata for the result table' },
rows: { type: 'json', description: 'Result rows as positional arrays' },
records: { type: 'json', description: 'Result rows keyed by column name' },
rowCount: { type: 'number', description: 'Rows carried in this result, after the row cap' },
totalRowCount: { type: 'number', description: 'Rows Kusto returned, before the row cap' },
truncated: { type: 'boolean', description: 'Whether rows were dropped to stay within the cap' },
databases: { type: 'json', description: 'Database names (List Databases)' },
tables: { type: 'json', description: 'Table names (List Tables)' },
extentIds: { type: 'json', description: 'Extent IDs created (Ingest Rows Inline)' },
schema: { type: 'string', description: 'CSL column schema (Show Table Schema)' },
databaseName: { type: 'string', description: "Table's database (Show Table Schema)" },
folder: { type: 'string', description: "Table's folder (Show Table Schema)" },
docString: { type: 'string', description: "Table's docstring (Show Table Schema)" },
functions: { type: 'json', description: 'Stored function names (List Functions)' },
},
}
export const AzureDataExplorerBlockMeta = {
tags: ['data-analytics', 'data-warehouse', 'cloud', 'monitoring'],
url: 'https://azure.microsoft.com/products/data-explorer',
templates: [
{
icon: AzureDataExplorerIcon,
title: 'Ask questions of Kusto data',
prompt:
'Build an agent that turns plain-English questions about our telemetry into KQL, reads the Azure Data Explorer table schema first so the columns are real, runs the query, and answers with the numbers plus the query it used.',
modules: ['agent', 'workflows'],
category: 'operations',
tags: ['analytics', 'automation'],
},
{
icon: AzureDataExplorerIcon,
title: 'Daily telemetry digest',
prompt:
'Create a scheduled workflow that queries Azure Data Explorer every morning for yesterday error rates, latency percentiles, and traffic by service, summarizes what moved against the prior week, and posts the digest to our engineering Slack channel.',
modules: ['scheduled', 'agent', 'workflows'],
category: 'operations',
tags: ['analytics', 'monitoring', 'reporting'],
alsoIntegrations: ['slack'],
},
{
icon: AzureDataExplorerIcon,
title: 'Kusto anomaly alerts',
prompt:
'Build a workflow that runs every 15 minutes, queries Azure Data Explorer for request failures grouped by endpoint, compares each endpoint against its own trailing baseline, and pages the on-call engineer through PagerDuty only when a real spike appears.',
modules: ['scheduled', 'agent', 'workflows'],
category: 'operations',
tags: ['monitoring', 'incident-response', 'automation'],
alsoIntegrations: ['pagerduty'],
},
{
icon: AzureDataExplorerIcon,
title: 'Incident timeline from telemetry',
prompt:
'Create a workflow that takes an incident window and service name, pulls the matching logs, traces, and deploy events out of Azure Data Explorer, and writes an ordered timeline file that an engineer can drop straight into a postmortem.',
modules: ['agent', 'files', 'workflows'],
category: 'operations',
tags: ['monitoring', 'incident-response', 'reporting'],
},
{
icon: AzureDataExplorerIcon,
title: 'Event pipeline into Kusto',
prompt:
'Build a workflow triggered by a webhook that validates each incoming event payload, maps it onto our Azure Data Explorer table schema, ingests the rows inline, and records any rejected payloads in a table so nothing is lost silently.',
modules: ['tables', 'agent', 'workflows'],
category: 'operations',
tags: ['automation', 'data-pipeline'],
},
{
icon: AzureDataExplorerIcon,
title: 'Kusto cluster inventory',
prompt:
'Create a scheduled workflow that lists every Azure Data Explorer database and table, reads their schemas, and keeps a Sim table up to date as a searchable data-model catalog with a note on what changed since the last run.',
modules: ['scheduled', 'tables', 'agent', 'workflows'],
category: 'operations',
tags: ['analytics', 'documentation', 'automation'],
},
{
icon: AzureDataExplorerIcon,
title: 'Usage report to stakeholders',
prompt:
'Build a scheduled weekly workflow that queries Azure Data Explorer for product usage by account and feature, ranks the biggest movers, generates a report file with the supporting numbers, and emails it to the account team.',
modules: ['scheduled', 'agent', 'files', 'workflows'],
category: 'operations',
tags: ['analytics', 'reporting', 'automation'],
},
],
skills: [
{
name: 'explore-kusto-schema',
description:
'Discover what data an Azure Data Explorer cluster holds before writing a query against it.',
content:
'# Explore Kusto Schema\n\nMap out a cluster before querying it, so queries reference columns that actually exist.\n\n## Steps\n1. Run List Databases to see what the service principal can reach.\n2. Run List Tables on the database that looks relevant.\n3. Run Show Table Schema on each candidate table to read its columns and types, or Show Database Schema to pull every table and column in one call.\n\n## Output\nReturn the database name, the tables that matter for the question at hand, and each relevant column with its type.',
},
{
name: 'answer-with-kql',
description: 'Answer a data question by writing and running a KQL query, then explaining it.',
content:
'# Answer With KQL\n\nTurn a question about the data into a query and a trustworthy answer.\n\n## Steps\n1. Run Show Table Schema on the table involved so column names and types are known rather than guessed.\n2. Write the KQL: filter with `where`, aggregate with `summarize ... by`, bound the result with `take` or `top N by`.\n3. Run Query with the read-only option enabled so the request cannot change data.\n4. If the result looks wrong or empty, widen the time filter or relax the string comparison and try again.\n\n## Output\nReport the answer, the row count, and the exact KQL that produced it so the result can be re-run and checked.',
},
{
name: 'materialize-a-rollup-table',
description:
'Build or refresh a summary table in Azure Data Explorer from a query over raw data.',
content:
'# Materialize A Rollup Table\n\nTurn an expensive query over raw telemetry into a small table that is cheap to read.\n\n## Steps\n1. Run Show Table Details on the source table to see how much data the query will scan.\n2. Run Show Table Schema on the **target** table. Kusto matches an ingested query result to the table by column position and type, never by name, so you need its exact column order before writing the query.\n3. Write the aggregating KQL — `summarize` the raw rows into the shape you want, bucketing time with `bin()` where relevant — and end it with an explicit `project` listing the columns in the target table order.\n4. Run Ingest From Query against the target table. Use `set-or-append` to add the new window to an existing rollup, `set-or-replace` to rebuild it from scratch, and `set` only for the first run.\n5. For a large backfill, enable the background option and poll Show Operations with the returned operation ID until the state is Completed.\n6. Run Query against the rollup and spot-check a few rows to confirm each column holds what it should, not just that the row count is plausible.\n\n## Output\nReport the target table, the mode used, the rows or extents produced, and the verification query result. Call out that `set-or-replace` discards the existing data in the table, and that a mismatched column order corrupts data silently rather than failing.',
},
{
name: 'debug-ingestion-failures',
description:
'Diagnose why data is missing from an Azure Data Explorer table and say whether it will retry.',
content:
'# Debug Ingestion Failures\n\nFind out why expected rows never arrived.\n\n## Steps\n1. Run Show Ingestion Failures for the database. Failures are kept for 14 days, so anything older will not appear.\n2. Group what comes back by table, error code, and failure kind. `Permanent` failures will not retry on their own; `Transient` ones may already have succeeded on a later attempt.\n3. Read the failure detail for the root cause — schema mismatch, a malformed source file, or a missing ingestion mapping are the usual ones.\n4. Run Show Table Schema on the target table and compare it against the source data shape named in the failure.\n5. Take the operation ID from a failure and run Show Operations on it for the fuller history of that operation.\n\n## Output\nReport the affected tables, the distinct error codes with counts, the likely root cause, and whether each failure is permanent (needs a fix) or transient (may self-resolve). Confirm with a Query against the table whether the rows are genuinely absent.',
},
{
name: 'ingest-rows-into-kusto',
description: 'Push a small batch of records into an Azure Data Explorer table.',
content:
'# Ingest Rows Into Kusto\n\nLoad a handful of records into a table without setting up a pipeline.\n\n## Steps\n1. Run Show Table Schema on the target table to learn the column order and types.\n2. Format the records as CSV in that exact column order, one record per line, quoting any value containing a comma.\n3. Run Ingest Rows Inline against the table.\n4. Run Query to read the newly ingested rows back and confirm they landed as intended.\n\n## Output\nReport how many rows were sent, the extent IDs returned, and the verification query result. Note that inline ingestion is for small batches — recommend queued or streaming ingestion for ongoing volume.',
},
],
} as const satisfies BlockMeta
+6
View File
@@ -18,6 +18,10 @@ import { AsanaBlock, AsanaBlockMeta } from '@/blocks/blocks/asana'
import { AshbyBlock, AshbyBlockMeta } from '@/blocks/blocks/ashby'
import { AthenaBlock, AthenaBlockMeta } from '@/blocks/blocks/athena'
import { AttioBlock, AttioBlockMeta } from '@/blocks/blocks/attio'
import {
AzureDataExplorerBlock,
AzureDataExplorerBlockMeta,
} from '@/blocks/blocks/azure_data_explorer'
import { AzureDevOpsBlock, AzureDevOpsBlockMeta } from '@/blocks/blocks/azure_devops'
import { BoxBlock, BoxBlockMeta } from '@/blocks/blocks/box'
import { BrandfetchBlock, BrandfetchBlockMeta } from '@/blocks/blocks/brandfetch'
@@ -374,6 +378,7 @@ export const BLOCK_REGISTRY: Record<string, BlockConfig> = {
ashby: AshbyBlock,
athena: AthenaBlock,
attio: AttioBlock,
azure_data_explorer: AzureDataExplorerBlock,
azure_devops: AzureDevOpsBlock,
box: BoxBlock,
brandfetch: BrandfetchBlock,
@@ -711,6 +716,7 @@ export const BLOCK_META_REGISTRY: Record<string, BlockMeta> = {
ashby: AshbyBlockMeta,
athena: AthenaBlockMeta,
attio: AttioBlockMeta,
azure_data_explorer: AzureDataExplorerBlockMeta,
azure_devops: AzureDevOpsBlockMeta,
box: BoxBlockMeta,
brandfetch: BrandfetchBlockMeta,
+132
View File
@@ -3898,6 +3898,138 @@ export const ClaudeIcon = (props: SVGProps<SVGSVGElement>) => (
</svg>
)
export function AzureDataExplorerIcon(props: SVGProps<SVGSVGElement>) {
const id = useId()
const wedgeGradient = `adx_wedge_${id}`
const dashLongGradient = `adx_dash_long_${id}`
const dashTopGradient = `adx_dash_top_${id}`
const dashBottomGradient = `adx_dash_bottom_${id}`
return (
<svg
{...props}
width='18'
height='18'
viewBox='0 0 18 18'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M1.47,1.47,16.53,16.53a.57.57,0,0,0,1-.4V1.07A.57.57,0,0,0,16.93.5H1.87A.57.57,0,0,0,1.47,1.47Z'
fill={`url(#${wedgeGradient})`}
/>
<path d='M5.53,5.53l6.94,6.94,5-5V1.07A.57.57,0,0,0,16.93.5H10.56Z' fill='#50e6ff' />
<rect
x='-0.38'
y='12.85'
width='9.37'
height='1.7'
rx='0.27'
transform='translate(-8.43 7.06) rotate(-45)'
fill={`url(#${dashLongGradient})`}
/>
<rect
x='0.12'
y='9.23'
width='5.99'
height='1.7'
rx='0.27'
transform='translate(-6.22 5.15) rotate(-45)'
fill={`url(#${dashTopGradient})`}
/>
<rect
x='4.89'
y='14.04'
width='5.99'
height='1.7'
rx='0.27'
transform='translate(-8.22 9.93) rotate(-45)'
fill={`url(#${dashBottomGradient})`}
/>
<rect
x='9.19'
y='4.75'
width='1.7'
height='1.7'
rx='0.38'
transform='translate(-1.02 8.74) rotate(-45)'
fill='#fff'
/>
<rect
x='11.59'
y='2.35'
width='1.7'
height='1.7'
rx='0.38'
transform='translate(1.38 9.73) rotate(-45)'
fill='#fff'
/>
<rect
x='11.59'
y='7.15'
width='1.7'
height='1.7'
rx='0.38'
transform='translate(-2.01 11.14) rotate(-45)'
fill='#fff'
/>
<path
d='M13.91,5.33l.66-.66a.38.38,0,0,1,.54,0l.66.66a.38.38,0,0,1,0,.54l-.66.66a.38.38,0,0,1-.54,0l-.66-.66a.38.38,0,0,1,0-.54'
fill='#fff'
/>
<defs>
<linearGradient
id={wedgeGradient}
x1='1.3'
y1='8.6'
x2='17.5'
y2='8.6'
gradientUnits='userSpaceOnUse'
>
<stop offset='0.1' stopColor='#54aef0' />
<stop offset='1' stopColor='#1988d9' />
</linearGradient>
<linearGradient
id={dashLongGradient}
x1='0.5'
y1='13.7'
x2='8.11'
y2='13.7'
gradientTransform='translate(10.95 0.97) rotate(45)'
gradientUnits='userSpaceOnUse'
>
<stop offset='0' stopColor='#b3b2b3' />
<stop offset='1' stopColor='#979797' />
</linearGradient>
<linearGradient
id={dashTopGradient}
x1='0.5'
y1='10.08'
x2='5.72'
y2='10.08'
gradientTransform='translate(8.04 0.75) rotate(45)'
gradientUnits='userSpaceOnUse'
>
<stop offset='0' stopColor='#b3b2b3' />
<stop offset='1' stopColor='#979797' />
</linearGradient>
<linearGradient
id={dashBottomGradient}
x1='5.28'
y1='14.89'
x2='10.49'
y2='14.89'
gradientTransform='translate(12.84 -1.21) rotate(45)'
gradientUnits='userSpaceOnUse'
>
<stop offset='0' stopColor='#b3b2b3' />
<stop offset='1' stopColor='#979797' />
</linearGradient>
</defs>
</svg>
)
}
export function AzureIcon(props: SVGProps<SVGSVGElement>) {
const id = useId()
const gradient0 = `azure_paint0_${id}`
@@ -0,0 +1,168 @@
import { isPrivateIpHost } from '@sim/security/ssrf'
import { z } from 'zod'
import { genericToolResponseSchema } from '@/lib/api/contracts/tools/shared'
import { defineRouteContract } from '@/lib/api/contracts/types'
/**
* Kusto service domains Sim will talk to, each paired with the Microsoft Entra
* authority that issues tokens for it.
*
* A cluster URI is user-supplied, so the proxy is pinned to the documented Azure
* Data Explorer and Fabric Eventhouse domains rather than trusting any HTTPS
* host. Host and authority are declared together on purpose: a sovereign cloud
* authenticates against its own isolated Entra instance, so accepting a cluster
* host without its authority would pass validation and then fail to get a token.
*
* Apex hosts match as well as subdomains, because the documented token
* audiences (`https://api.kusto.windows.net`,
* `https://kusto.fabric.microsoft.com`) sit at the apex.
*
* Every entry is a domain Microsoft documents: the Kusto connection-string
* reference for `kusto.windows.net`, the national-cloud endpoint tables for the
* two sovereign domains, and the Fabric KQL-database REST reference for
* `kusto.fabric.microsoft.com` (both its `queryServiceUri` and
* `ingestionServiceUri` sit under it). Do not add a host without one.
*/
const KUSTO_CLOUDS = [
{ hostSuffix: 'kusto.windows.net', authority: 'https://login.microsoftonline.com' },
{ hostSuffix: 'kusto.fabric.microsoft.com', authority: 'https://login.microsoftonline.com' },
{ hostSuffix: 'kusto.usgovcloudapi.net', authority: 'https://login.microsoftonline.us' },
{ hostSuffix: 'kusto.chinacloudapi.cn', authority: 'https://login.partner.microsoftonline.cn' },
] as const
const ALLOWED_CLUSTER_HOSTS = KUSTO_CLOUDS.map((cloud) => cloud.hostSuffix).join(', ')
function matchKustoCloud(host: string): (typeof KUSTO_CLOUDS)[number] | null {
return (
KUSTO_CLOUDS.find(
(cloud) => host === cloud.hostSuffix || host.endsWith(`.${cloud.hostSuffix}`)
) ?? null
)
}
/**
* Resolves the Entra authority that issues tokens for a cluster host. Callers
* pass a host already accepted by {@link checkAzureDataExplorerClusterUri}, so
* an unmatched host here means the two fell out of sync and is a bug, not input.
*/
export function resolveEntraAuthority(clusterHost: string): string {
const cloud = matchKustoCloud(clusterHost.toLowerCase())
if (!cloud) {
throw new Error(`No Microsoft Entra authority is configured for cluster host ${clusterHost}`)
}
return cloud.authority
}
export function checkAzureDataExplorerClusterUri(
rawUrl: string,
label = 'clusterUri'
): { ok: true; url: URL } | { ok: false; message: string } {
let parsed: URL
try {
parsed = new URL(rawUrl)
} catch {
return {
ok: false,
message: `${label} must be a full URL (e.g., https://mycluster.eastus.kusto.windows.net)`,
}
}
if (parsed.protocol !== 'https:') {
return { ok: false, message: `${label} must use https://` }
}
const host = parsed.hostname.toLowerCase()
if (isPrivateIpHost(host)) {
return { ok: false, message: `${label} host is not allowed (private/loopback range)` }
}
if (!matchKustoCloud(host)) {
return {
ok: false,
message: `${label} host must be an Azure Data Explorer or Fabric Eventhouse endpoint (${ALLOWED_CLUSTER_HOSTS})`,
}
}
return { ok: true, url: parsed }
}
export function assertSafeAzureDataExplorerClusterUri(rawUrl: string, label?: string): URL {
const result = checkAzureDataExplorerClusterUri(rawUrl, label)
if (!result.ok) throw new Error(result.message)
return result.url
}
/**
* The exact character set Kusto documents for an identifier: letters, digits,
* underscores, spaces, dots, and dashes, 1-1024 characters. An allowlist rather
* than a denylist, so nothing that could terminate `["..."]` name quoting or
* that Kusto would reject anyway reaches a command string.
*/
const entityNameSchema = z
.string()
.trim()
.min(1, 'name is required')
.max(1024, 'name must be at most 1024 characters')
.regex(
/^[\p{L}\p{N}_ .-]+$/u,
'name may contain only letters, digits, underscores, spaces, dots, and dashes'
)
/** A Microsoft Entra tenant is addressed by GUID or by verified domain name. */
const tenantIdSchema = z
.string()
.trim()
.min(1, 'tenantId is required')
.max(253, 'tenantId is too long')
.regex(
/^[A-Za-z0-9][A-Za-z0-9.-]*$/,
'tenantId must be a GUID or a domain name (e.g., contoso.onmicrosoft.com)'
)
export const azureDataExplorerEndpointSchema = z.enum(['query', 'mgmt'])
export const azureDataExplorerProxyBodySchema = z
.object({
clusterUri: z.string().min(1, 'clusterUri is required'),
tenantId: tenantIdSchema,
clientId: z.string().min(1, 'clientId is required'),
clientSecret: z.string().min(1, 'clientSecret is required'),
/**
* Microsoft Entra token audience. Defaults to the cluster's own origin, which
* is the form the Kusto REST reference uses for client-credential tokens.
*/
resource: z.string().optional(),
endpoint: azureDataExplorerEndpointSchema,
database: entityNameSchema.optional(),
csl: z.string().min(1, 'csl is required').max(1_000_000, 'csl is too long'),
properties: z.record(z.string(), z.unknown()).optional(),
/** Sends `x-ms-readonly`, which makes the cluster reject data-changing requests. */
readOnly: z.boolean().optional(),
})
.superRefine((req, ctx) => {
const clusterCheck = checkAzureDataExplorerClusterUri(req.clusterUri)
if (!clusterCheck.ok) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['clusterUri'],
message: clusterCheck.message,
})
}
if (req.resource === undefined) return
const resourceCheck = checkAzureDataExplorerClusterUri(req.resource, 'resource')
if (!resourceCheck.ok) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['resource'],
message: resourceCheck.message,
})
}
})
export type AzureDataExplorerProxyRequest = z.infer<typeof azureDataExplorerProxyBodySchema>
export const azureDataExplorerProxyContract = defineRouteContract({
method: 'POST',
path: '/api/tools/azure_data_explorer/proxy',
body: azureDataExplorerProxyBodySchema,
response: {
mode: 'json',
schema: genericToolResponseSchema,
},
})
@@ -22,6 +22,7 @@ import {
AshbyIcon,
AthenaIcon,
AttioIcon,
AzureDataExplorerIcon,
AzureIcon,
BoxCompanyIcon,
BrainIcon,
@@ -278,6 +279,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
ashby: AshbyIcon,
athena: AthenaIcon,
attio: AttioIcon,
azure_data_explorer: AzureDataExplorerIcon,
azure_devops: AzureIcon,
box: BoxCompanyIcon,
brandfetch: BrandfetchIcon,
@@ -2217,6 +2217,81 @@
"integrationType": "security",
"tags": ["identity", "microsoft-365"]
},
{
"type": "azure_data_explorer",
"slug": "azure-data-explorer",
"name": "Azure Data Explorer",
"description": "Query and manage Azure Data Explorer (Kusto) clusters with KQL",
"longDescription": "Run Kusto Query Language queries against Azure Data Explorer and Fabric Eventhouse clusters, discover databases, tables, and schemas, push small batches of rows inline, and run management commands. Authenticates with a Microsoft Entra service principal using client credentials, so no interactive sign-in is needed.",
"bgColor": "#FFFFFF",
"iconName": "AzureDataExplorerIcon",
"docsUrl": "https://docs.sim.ai/integrations/azure_data_explorer",
"operations": [
{
"name": "Run Query",
"description": "Run a Kusto Query Language (KQL) query against an Azure Data Explorer database and return the primary result table."
},
{
"name": "List Databases",
"description": "List the databases on an Azure Data Explorer cluster that the service principal can access."
},
{
"name": "List Tables",
"description": "List the tables in an Azure Data Explorer database, with their folder and docstring."
},
{
"name": "Show Table Schema",
"description": "Read the column schema of an Azure Data Explorer table in CSL form (e.g., \"Timestamp:datetime,Level:string\"). Use this before writing a KQL query against an unfamiliar table."
},
{
"name": "Show Database Schema",
"description": "Read the full schema of an Azure Data Explorer database as a flat list of every table and column, so an agent can discover the data model in one call."
},
{
"name": "Show Table Details",
"description": "Read size, row count, hot-cache footprint, and effective policies for a table — or for every table in the database when no table is given. Use it to see how much data a table actually holds before querying it."
},
{
"name": "List Functions",
"description": "List the stored functions in an Azure Data Explorer database, with their parameters and bodies, so an agent can reuse existing logic instead of rewriting it."
},
{
"name": "Ingest Rows Inline",
"description": "Push rows directly into an Azure Data Explorer table with .ingest inline. Data is parsed as CSV against the table schema unless an ingestion property says otherwise. Intended for small batches — use queued or streaming ingestion for production volumes."
},
{
"name": "Ingest From Query",
"description": "Materialize the result of a KQL query into a table with .set, .append, .set-or-append, or .set-or-replace. Use this to build rollup or summary tables instead of pushing rows from a workflow. Kusto matches the query result to the target table by column type and position, NOT by column name, so project the columns in exactly the table's order or the data lands in the wrong columns."
},
{
"name": "Create Table",
"description": "Create a table in an Azure Data Explorer database from a CSL column schema. Succeeds without changing anything if a table of the same name already exists."
},
{
"name": "Drop Table",
"description": "Drop a table from an Azure Data Explorer database. This permanently deletes the table and its data, and returns the tables that remain."
},
{
"name": "Show Ingestion Failures",
"description": "List ingestion failures recorded for a database, with the failing table, error code, root cause detail, and whether the failure is permanent or transient. Failures are retained for 14 days."
},
{
"name": "Show Operations",
"description": "Check the state of administrative operations on a cluster, such as an async ingestion. Given an operation ID it returns that operation latest update; with no ID it returns the operations from the last two weeks."
},
{
"name": "Management Command (advanced)",
"description": "Run an Azure Data Explorer management command (a control command starting with \".\") such as .show, .create, .alter, or .drop. Write commands change cluster state permanently; use the Query operation for reads."
}
],
"operationCount": 14,
"triggers": [],
"triggerCount": 0,
"authType": "api-key",
"category": "tools",
"integrationType": "databases",
"tags": ["data-analytics", "data-warehouse", "cloud", "monitoring"]
},
{
"type": "azure_devops",
"slug": "azure-devops",
@@ -0,0 +1,135 @@
import type {
AzureDataExplorerCreateTableParams,
AzureDataExplorerTableResponse,
} from '@/tools/azure_data_explorer/types'
import {
AZURE_DATA_EXPLORER_PROXY_URL,
azureDataExplorerAuthBody,
buildWithClause,
renderColumnSchema,
renderEntityName,
transformAzureDataExplorerResponse,
} from '@/tools/azure_data_explorer/utils'
import type { ToolConfig } from '@/tools/types'
export const azureDataExplorerCreateTableTool: ToolConfig<
AzureDataExplorerCreateTableParams,
AzureDataExplorerTableResponse
> = {
id: 'azure_data_explorer_create_table',
name: 'Azure Data Explorer Create Table',
description:
'Create a table in an Azure Data Explorer database from a CSL column schema. Succeeds without changing anything if a table of the same name already exists.',
version: '1.0.0',
params: {
clusterUri: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Cluster URI (e.g., https://mycluster.eastus.kusto.windows.net)',
},
tenantId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Microsoft Entra tenant ID hosting the service principal',
},
clientId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Microsoft Entra application (client) ID',
},
clientSecret: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Microsoft Entra application client secret',
},
resource: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Token audience override. Defaults to the cluster URI itself',
},
database: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Database to create the table in',
},
table: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Name of the table to create',
},
columnSchema: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description:
'Comma-separated CSL column schema (e.g., Timestamp:datetime, Level:string, Count:long)',
},
tableProperties: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Optional table properties clause contents, e.g. docstring="Raw logs", folder="Ingest"',
},
},
request: {
url: AZURE_DATA_EXPLORER_PROXY_URL,
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => ({
...azureDataExplorerAuthBody(params),
endpoint: 'mgmt',
database: params.database,
csl: `.create table ${renderEntityName(params.table)} (${renderColumnSchema(
params.columnSchema
)})${buildWithClause(params.tableProperties, 'docstring="Raw logs"')}`,
}),
},
transformResponse: transformAzureDataExplorerResponse,
outputs: {
tableName: {
type: 'string',
description: 'Name Kusto assigned to the returned result table',
nullable: true,
},
columns: {
type: 'array',
description: 'Column metadata for the result table',
items: {
type: 'object',
properties: {
name: { type: 'string', description: 'Column name' },
type: { type: 'string', description: 'Kusto scalar type', nullable: true },
dataType: { type: 'string', description: 'Approximate .NET type', nullable: true },
},
},
},
rows: {
type: 'array',
description: 'Result rows as positional arrays matching the columns order',
items: { type: 'array' },
},
records: {
type: 'array',
description: 'Result rows keyed by column name',
items: { type: 'object' },
},
rowCount: { type: 'number', description: 'Rows carried in this result, after the row cap' },
totalRowCount: {
type: 'number',
description: 'Rows Kusto returned, before the row cap was applied',
},
truncated: {
type: 'boolean',
description:
'Whether rows were dropped to stay within the row cap — narrow the query if true',
},
},
}
@@ -0,0 +1,128 @@
import type {
AzureDataExplorerDropTableParams,
AzureDataExplorerTableListResponse,
} from '@/tools/azure_data_explorer/types'
import {
AZURE_DATA_EXPLORER_PROXY_URL,
azureDataExplorerAuthBody,
renderEntityName,
transformColumnListResponse,
} from '@/tools/azure_data_explorer/utils'
import type { ToolConfig } from '@/tools/types'
export const azureDataExplorerDropTableTool: ToolConfig<
AzureDataExplorerDropTableParams,
AzureDataExplorerTableListResponse
> = {
id: 'azure_data_explorer_drop_table',
name: 'Azure Data Explorer Drop Table',
description:
'Drop a table from an Azure Data Explorer database. This permanently deletes the table and its data, and returns the tables that remain.',
version: '1.0.0',
params: {
clusterUri: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Cluster URI (e.g., https://mycluster.eastus.kusto.windows.net)',
},
tenantId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Microsoft Entra tenant ID hosting the service principal',
},
clientId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Microsoft Entra application (client) ID',
},
clientSecret: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Microsoft Entra application client secret',
},
resource: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Token audience override. Defaults to the cluster URI itself',
},
database: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Database containing the table',
},
table: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Name of the table to drop',
},
ifExists: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Succeed instead of failing when the table does not exist',
},
},
request: {
url: AZURE_DATA_EXPLORER_PROXY_URL,
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => ({
...azureDataExplorerAuthBody(params),
endpoint: 'mgmt',
database: params.database,
csl: `.drop table ${renderEntityName(params.table)}${params.ifExists ? ' ifexists' : ''}`,
}),
},
transformResponse: transformColumnListResponse('TableName', 'tables'),
outputs: {
tableName: {
type: 'string',
description: 'Name Kusto assigned to the returned result table',
nullable: true,
},
columns: {
type: 'array',
description: 'Column metadata for the result table',
items: {
type: 'object',
properties: {
name: { type: 'string', description: 'Column name' },
type: { type: 'string', description: 'Kusto scalar type', nullable: true },
dataType: { type: 'string', description: 'Approximate .NET type', nullable: true },
},
},
},
rows: {
type: 'array',
description: 'Result rows as positional arrays matching the columns order',
items: { type: 'array' },
},
records: {
type: 'array',
description: 'Result rows keyed by column name',
items: { type: 'object' },
},
rowCount: { type: 'number', description: 'Rows carried in this result, after the row cap' },
totalRowCount: {
type: 'number',
description: 'Rows Kusto returned, before the row cap was applied',
},
truncated: {
type: 'boolean',
description:
'Whether rows were dropped to stay within the row cap — narrow the query if true',
},
tables: {
type: 'array',
description: 'Tables remaining in the database, read from the TableName column',
items: { type: 'string' },
},
},
}
@@ -0,0 +1,15 @@
export { azureDataExplorerCreateTableTool } from '@/tools/azure_data_explorer/create_table'
export { azureDataExplorerDropTableTool } from '@/tools/azure_data_explorer/drop_table'
export { azureDataExplorerIngestFromQueryTool } from '@/tools/azure_data_explorer/ingest_from_query'
export { azureDataExplorerIngestInlineTool } from '@/tools/azure_data_explorer/ingest_inline'
export { azureDataExplorerListDatabasesTool } from '@/tools/azure_data_explorer/list_databases'
export { azureDataExplorerListFunctionsTool } from '@/tools/azure_data_explorer/list_functions'
export { azureDataExplorerListTablesTool } from '@/tools/azure_data_explorer/list_tables'
export { azureDataExplorerManagementTool } from '@/tools/azure_data_explorer/management'
export { azureDataExplorerQueryTool } from '@/tools/azure_data_explorer/query'
export { azureDataExplorerShowDatabaseSchemaTool } from '@/tools/azure_data_explorer/show_database_schema'
export { azureDataExplorerShowIngestionFailuresTool } from '@/tools/azure_data_explorer/show_ingestion_failures'
export { azureDataExplorerShowOperationsTool } from '@/tools/azure_data_explorer/show_operations'
export { azureDataExplorerShowTableDetailsTool } from '@/tools/azure_data_explorer/show_table_details'
export { azureDataExplorerShowTableSchemaTool } from '@/tools/azure_data_explorer/show_table_schema'
export * from '@/tools/azure_data_explorer/types'
@@ -0,0 +1,149 @@
import type {
AzureDataExplorerIngestFromQueryParams,
AzureDataExplorerTableResponse,
} from '@/tools/azure_data_explorer/types'
import {
AZURE_DATA_EXPLORER_PROXY_URL,
azureDataExplorerAuthBody,
buildWithClause,
renderEntityName,
renderIngestMode,
transformAzureDataExplorerResponse,
} from '@/tools/azure_data_explorer/utils'
import type { ToolConfig } from '@/tools/types'
export const azureDataExplorerIngestFromQueryTool: ToolConfig<
AzureDataExplorerIngestFromQueryParams,
AzureDataExplorerTableResponse
> = {
id: 'azure_data_explorer_ingest_from_query',
name: 'Azure Data Explorer Ingest From Query',
description:
"Materialize the result of a KQL query into a table with .set, .append, .set-or-append, or .set-or-replace. Use this to build rollup or summary tables instead of pushing rows from a workflow. Kusto matches the query result to the target table by column type and position, NOT by column name, so project the columns in exactly the table's order or the data lands in the wrong columns.",
version: '1.0.0',
params: {
clusterUri: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Cluster URI (e.g., https://mycluster.eastus.kusto.windows.net)',
},
tenantId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Microsoft Entra tenant ID hosting the service principal',
},
clientId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Microsoft Entra application (client) ID',
},
clientSecret: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Microsoft Entra application client secret',
},
resource: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Token audience override. Defaults to the cluster URI itself',
},
database: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Database containing the target table',
},
table: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Table to ingest the query result into',
},
mode: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'set (create, fail if it exists), append (add to an existing table), set-or-append (default), or set-or-replace (replace all data)',
},
sourceQuery: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description:
'KQL query whose result becomes the ingested data (e.g., LogsTable | where Level == "Error" | where Timestamp > ago(1h)). Project the columns in the target table\'s order — matching is positional, not by name',
},
async: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description:
'Return immediately with an OperationId and keep ingesting in the background. Check progress with Show Operations',
},
ingestionProperties: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
"Optional ingestion properties clause contents, e.g. distributed=true, tags=\"[''daily'']\"",
},
},
request: {
url: AZURE_DATA_EXPLORER_PROXY_URL,
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => ({
...azureDataExplorerAuthBody(params),
endpoint: 'mgmt',
database: params.database,
csl: `${renderIngestMode(params.mode)}${params.async ? ' async' : ''} ${renderEntityName(
params.table
)}${buildWithClause(params.ingestionProperties, 'distributed=true')} <|\n${params.sourceQuery}`,
}),
},
transformResponse: transformAzureDataExplorerResponse,
outputs: {
tableName: {
type: 'string',
description: 'Name Kusto assigned to the returned result table',
nullable: true,
},
columns: {
type: 'array',
description: 'Column metadata for the result table',
items: {
type: 'object',
properties: {
name: { type: 'string', description: 'Column name' },
type: { type: 'string', description: 'Kusto scalar type', nullable: true },
dataType: { type: 'string', description: 'Approximate .NET type', nullable: true },
},
},
},
rows: {
type: 'array',
description: 'Result rows as positional arrays matching the columns order',
items: { type: 'array' },
},
records: {
type: 'array',
description: 'Result rows keyed by column name',
items: { type: 'object' },
},
rowCount: { type: 'number', description: 'Rows carried in this result, after the row cap' },
totalRowCount: {
type: 'number',
description: 'Rows Kusto returned, before the row cap was applied',
},
truncated: {
type: 'boolean',
description:
'Whether rows were dropped to stay within the row cap — narrow the query if true',
},
},
}
@@ -0,0 +1,141 @@
import type {
AzureDataExplorerIngestInlineParams,
AzureDataExplorerIngestResponse,
} from '@/tools/azure_data_explorer/types'
import {
AZURE_DATA_EXPLORER_PROXY_URL,
azureDataExplorerAuthBody,
buildWithClause,
renderEntityName,
transformColumnListResponse,
} from '@/tools/azure_data_explorer/utils'
import type { ToolConfig } from '@/tools/types'
export const azureDataExplorerIngestInlineTool: ToolConfig<
AzureDataExplorerIngestInlineParams,
AzureDataExplorerIngestResponse
> = {
id: 'azure_data_explorer_ingest_inline',
name: 'Azure Data Explorer Ingest Inline',
description:
'Push rows directly into an Azure Data Explorer table with .ingest inline. Data is parsed as CSV against the table schema unless an ingestion property says otherwise. Intended for small batches — use queued or streaming ingestion for production volumes.',
version: '1.0.0',
params: {
clusterUri: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Cluster URI (e.g., https://mycluster.eastus.kusto.windows.net)',
},
tenantId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Microsoft Entra tenant ID hosting the service principal',
},
clientId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Microsoft Entra application (client) ID',
},
clientSecret: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Microsoft Entra application client secret',
},
resource: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Token audience override. Defaults to the cluster URI itself',
},
database: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Database containing the target table',
},
table: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Table to ingest into. Its schema is the assumed schema for the data',
},
data: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description:
'Rows to ingest, one record per line, parsed as CSV by default (e.g., "Shoes,1000\\nWide Shoes,50")',
},
ingestionProperties: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Ingestion properties clause contents, e.g. format="json", ingestionMappingReference="mymapping"',
},
},
request: {
url: AZURE_DATA_EXPLORER_PROXY_URL,
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => ({
...azureDataExplorerAuthBody(params),
endpoint: 'mgmt',
database: params.database,
csl: `.ingest inline into table ${renderEntityName(params.table)}${buildWithClause(
params.ingestionProperties,
'format="json"'
)} <|\n${params.data}`,
}),
},
transformResponse: transformColumnListResponse('ExtentId', 'extentIds'),
outputs: {
tableName: {
type: 'string',
description: 'Name Kusto assigned to the returned result table',
nullable: true,
},
columns: {
type: 'array',
description: 'Column metadata for the result table',
items: {
type: 'object',
properties: {
name: { type: 'string', description: 'Column name' },
type: { type: 'string', description: 'Kusto scalar type', nullable: true },
dataType: { type: 'string', description: 'Approximate .NET type', nullable: true },
},
},
},
rows: {
type: 'array',
description: 'Result rows as positional arrays matching the columns order',
items: { type: 'array' },
},
records: {
type: 'array',
description: 'Result rows keyed by column name',
items: { type: 'object' },
},
rowCount: { type: 'number', description: 'Rows carried in this result, after the row cap' },
totalRowCount: {
type: 'number',
description: 'Rows Kusto returned, before the row cap was applied',
},
truncated: {
type: 'boolean',
description:
'Whether rows were dropped to stay within the row cap — narrow the query if true',
},
extentIds: {
type: 'array',
description:
'Extent IDs created by the ingestion — one per data shard. A single empty or zero-valued ID means no data shard was generated',
items: { type: 'string' },
},
},
}
@@ -0,0 +1,108 @@
import type {
AzureDataExplorerDatabaseListResponse,
AzureDataExplorerListDatabasesParams,
} from '@/tools/azure_data_explorer/types'
import {
AZURE_DATA_EXPLORER_PROXY_URL,
azureDataExplorerAuthBody,
transformColumnListResponse,
} from '@/tools/azure_data_explorer/utils'
import type { ToolConfig } from '@/tools/types'
export const azureDataExplorerListDatabasesTool: ToolConfig<
AzureDataExplorerListDatabasesParams,
AzureDataExplorerDatabaseListResponse
> = {
id: 'azure_data_explorer_list_databases',
name: 'Azure Data Explorer List Databases',
description:
'List the databases on an Azure Data Explorer cluster that the service principal can access.',
version: '1.0.0',
params: {
clusterUri: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Cluster URI (e.g., https://mycluster.eastus.kusto.windows.net)',
},
tenantId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Microsoft Entra tenant ID hosting the service principal',
},
clientId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Microsoft Entra application (client) ID',
},
clientSecret: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Microsoft Entra application client secret',
},
resource: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Token audience override. Defaults to the cluster URI itself',
},
},
request: {
url: AZURE_DATA_EXPLORER_PROXY_URL,
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => ({
...azureDataExplorerAuthBody(params),
endpoint: 'mgmt',
csl: '.show databases',
}),
},
transformResponse: transformColumnListResponse('DatabaseName', 'databases'),
outputs: {
tableName: {
type: 'string',
description: 'Name Kusto assigned to the returned result table',
nullable: true,
},
columns: {
type: 'array',
description: 'Column metadata for the result table',
items: {
type: 'object',
properties: {
name: { type: 'string', description: 'Column name' },
type: { type: 'string', description: 'Kusto scalar type', nullable: true },
dataType: { type: 'string', description: 'Approximate .NET type', nullable: true },
},
},
},
rows: {
type: 'array',
description: 'Result rows as positional arrays matching the columns order',
items: { type: 'array' },
},
records: {
type: 'array',
description: 'Result rows keyed by column name',
items: { type: 'object' },
},
rowCount: { type: 'number', description: 'Rows carried in this result, after the row cap' },
totalRowCount: {
type: 'number',
description: 'Rows Kusto returned, before the row cap was applied',
},
truncated: {
type: 'boolean',
description:
'Whether rows were dropped to stay within the row cap — narrow the query if true',
},
databases: {
type: 'array',
description: 'Database names, read from the DatabaseName column',
items: { type: 'string' },
},
},
}
@@ -0,0 +1,115 @@
import type {
AzureDataExplorerFunctionListResponse,
AzureDataExplorerListFunctionsParams,
} from '@/tools/azure_data_explorer/types'
import {
AZURE_DATA_EXPLORER_PROXY_URL,
azureDataExplorerAuthBody,
transformColumnListResponse,
} from '@/tools/azure_data_explorer/utils'
import type { ToolConfig } from '@/tools/types'
export const azureDataExplorerListFunctionsTool: ToolConfig<
AzureDataExplorerListFunctionsParams,
AzureDataExplorerFunctionListResponse
> = {
id: 'azure_data_explorer_list_functions',
name: 'Azure Data Explorer List Functions',
description:
'List the stored functions in an Azure Data Explorer database, with their parameters and bodies, so an agent can reuse existing logic instead of rewriting it.',
version: '1.0.0',
params: {
clusterUri: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Cluster URI (e.g., https://mycluster.eastus.kusto.windows.net)',
},
tenantId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Microsoft Entra tenant ID hosting the service principal',
},
clientId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Microsoft Entra application (client) ID',
},
clientSecret: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Microsoft Entra application client secret',
},
resource: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Token audience override. Defaults to the cluster URI itself',
},
database: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Database whose stored functions should be listed',
},
},
request: {
url: AZURE_DATA_EXPLORER_PROXY_URL,
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => ({
...azureDataExplorerAuthBody(params),
endpoint: 'mgmt',
database: params.database,
csl: '.show functions',
}),
},
transformResponse: transformColumnListResponse('Name', 'functions'),
outputs: {
tableName: {
type: 'string',
description: 'Name Kusto assigned to the returned result table',
nullable: true,
},
columns: {
type: 'array',
description: 'Column metadata for the result table',
items: {
type: 'object',
properties: {
name: { type: 'string', description: 'Column name' },
type: { type: 'string', description: 'Kusto scalar type', nullable: true },
dataType: { type: 'string', description: 'Approximate .NET type', nullable: true },
},
},
},
rows: {
type: 'array',
description: 'Result rows as positional arrays matching the columns order',
items: { type: 'array' },
},
records: {
type: 'array',
description: 'Result rows keyed by column name',
items: { type: 'object' },
},
rowCount: { type: 'number', description: 'Rows carried in this result, after the row cap' },
totalRowCount: {
type: 'number',
description: 'Rows Kusto returned, before the row cap was applied',
},
truncated: {
type: 'boolean',
description:
'Whether rows were dropped to stay within the row cap — narrow the query if true',
},
functions: {
type: 'array',
description: 'Stored function names, read from the Name column',
items: { type: 'string' },
},
},
}
@@ -0,0 +1,115 @@
import type {
AzureDataExplorerListTablesParams,
AzureDataExplorerTableListResponse,
} from '@/tools/azure_data_explorer/types'
import {
AZURE_DATA_EXPLORER_PROXY_URL,
azureDataExplorerAuthBody,
transformColumnListResponse,
} from '@/tools/azure_data_explorer/utils'
import type { ToolConfig } from '@/tools/types'
export const azureDataExplorerListTablesTool: ToolConfig<
AzureDataExplorerListTablesParams,
AzureDataExplorerTableListResponse
> = {
id: 'azure_data_explorer_list_tables',
name: 'Azure Data Explorer List Tables',
description:
'List the tables in an Azure Data Explorer database, with their folder and docstring.',
version: '1.0.0',
params: {
clusterUri: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Cluster URI (e.g., https://mycluster.eastus.kusto.windows.net)',
},
tenantId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Microsoft Entra tenant ID hosting the service principal',
},
clientId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Microsoft Entra application (client) ID',
},
clientSecret: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Microsoft Entra application client secret',
},
resource: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Token audience override. Defaults to the cluster URI itself',
},
database: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Database whose tables should be listed',
},
},
request: {
url: AZURE_DATA_EXPLORER_PROXY_URL,
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => ({
...azureDataExplorerAuthBody(params),
endpoint: 'mgmt',
database: params.database,
csl: '.show tables',
}),
},
transformResponse: transformColumnListResponse('TableName', 'tables'),
outputs: {
tableName: {
type: 'string',
description: 'Name Kusto assigned to the returned result table',
nullable: true,
},
columns: {
type: 'array',
description: 'Column metadata for the result table',
items: {
type: 'object',
properties: {
name: { type: 'string', description: 'Column name' },
type: { type: 'string', description: 'Kusto scalar type', nullable: true },
dataType: { type: 'string', description: 'Approximate .NET type', nullable: true },
},
},
},
rows: {
type: 'array',
description: 'Result rows as positional arrays matching the columns order',
items: { type: 'array' },
},
records: {
type: 'array',
description: 'Result rows keyed by column name',
items: { type: 'object' },
},
rowCount: { type: 'number', description: 'Rows carried in this result, after the row cap' },
totalRowCount: {
type: 'number',
description: 'Rows Kusto returned, before the row cap was applied',
},
truncated: {
type: 'boolean',
description:
'Whether rows were dropped to stay within the row cap — narrow the query if true',
},
tables: {
type: 'array',
description: 'Table names, read from the TableName column',
items: { type: 'string' },
},
},
}
@@ -0,0 +1,123 @@
import type {
AzureDataExplorerManagementParams,
AzureDataExplorerTableResponse,
} from '@/tools/azure_data_explorer/types'
import {
AZURE_DATA_EXPLORER_PROXY_URL,
azureDataExplorerAuthBody,
transformAzureDataExplorerResponse,
} from '@/tools/azure_data_explorer/utils'
import type { ToolConfig } from '@/tools/types'
export const azureDataExplorerManagementTool: ToolConfig<
AzureDataExplorerManagementParams,
AzureDataExplorerTableResponse
> = {
id: 'azure_data_explorer_management',
name: 'Azure Data Explorer Management Command',
description:
'Run an Azure Data Explorer management command (a control command starting with ".") such as .show, .create, .alter, or .drop. Write commands change cluster state permanently; use the Query operation for reads.',
version: '1.0.0',
params: {
clusterUri: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Cluster URI (e.g., https://mycluster.eastus.kusto.windows.net)',
},
tenantId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Microsoft Entra tenant ID hosting the service principal',
},
clientId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Microsoft Entra application (client) ID',
},
clientSecret: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Microsoft Entra application client secret',
},
resource: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Token audience override. Defaults to the cluster URI itself',
},
command: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Management command text, starting with "." (e.g., .show table Events details)',
},
database: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Database context for the command. Required for all commands except cluster-level ones such as .show databases',
},
},
request: {
url: AZURE_DATA_EXPLORER_PROXY_URL,
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => {
const command = params.command.trim()
if (!command.startsWith('.')) {
throw new Error('Management commands must start with "." — use the Query operation for KQL')
}
return {
...azureDataExplorerAuthBody(params),
endpoint: 'mgmt',
...(params.database ? { database: params.database } : {}),
csl: command,
}
},
},
transformResponse: transformAzureDataExplorerResponse,
outputs: {
tableName: {
type: 'string',
description: 'Name Kusto assigned to the returned result table',
nullable: true,
},
columns: {
type: 'array',
description: 'Column metadata for the result table',
items: {
type: 'object',
properties: {
name: { type: 'string', description: 'Column name' },
type: { type: 'string', description: 'Kusto scalar type', nullable: true },
dataType: { type: 'string', description: 'Approximate .NET type', nullable: true },
},
},
},
rows: {
type: 'array',
description: 'Result rows as positional arrays matching the columns order',
items: { type: 'array' },
},
records: {
type: 'array',
description: 'Result rows keyed by column name',
items: { type: 'object' },
},
rowCount: { type: 'number', description: 'Rows carried in this result, after the row cap' },
totalRowCount: {
type: 'number',
description: 'Rows Kusto returned, before the row cap was applied',
},
truncated: {
type: 'boolean',
description:
'Whether rows were dropped to stay within the row cap — narrow the query if true',
},
},
}
+151
View File
@@ -0,0 +1,151 @@
import type {
AzureDataExplorerQueryParams,
AzureDataExplorerTableResponse,
} from '@/tools/azure_data_explorer/types'
import {
AZURE_DATA_EXPLORER_PROXY_URL,
azureDataExplorerAuthBody,
transformAzureDataExplorerResponse,
} from '@/tools/azure_data_explorer/utils'
import type { ToolConfig } from '@/tools/types'
function parseProperties(
input: AzureDataExplorerQueryParams['properties']
): Record<string, unknown> | undefined {
if (input === undefined || input === null || input === '') return undefined
if (typeof input === 'object') return input
try {
const parsed = JSON.parse(input)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>
}
} catch {
throw new Error('Invalid properties: must be a JSON object')
}
throw new Error('Invalid properties: must be a JSON object')
}
export const azureDataExplorerQueryTool: ToolConfig<
AzureDataExplorerQueryParams,
AzureDataExplorerTableResponse
> = {
id: 'azure_data_explorer_query',
name: 'Azure Data Explorer Query',
description:
'Run a Kusto Query Language (KQL) query against an Azure Data Explorer database and return the primary result table.',
version: '1.0.0',
params: {
clusterUri: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Cluster URI (e.g., https://mycluster.eastus.kusto.windows.net)',
},
tenantId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Microsoft Entra tenant ID hosting the service principal',
},
clientId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Microsoft Entra application (client) ID',
},
clientSecret: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Microsoft Entra application client secret',
},
resource: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Token audience override. Defaults to the cluster URI itself',
},
database: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Database to run the query against',
},
query: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description:
'KQL query text (e.g., StormEvents | where State == "FLORIDA" | summarize count() by EventType)',
},
properties: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description:
'Kusto request properties object, e.g. {"Options":{"servertimeout":"00:04:00","queryconsistency":"strongconsistency"}}',
},
readOnly: {
type: 'boolean',
required: false,
visibility: 'user-only',
description: 'Send x-ms-readonly so the cluster rejects any request that would change data',
},
},
request: {
url: AZURE_DATA_EXPLORER_PROXY_URL,
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => {
const properties = parseProperties(params.properties)
return {
...azureDataExplorerAuthBody(params),
endpoint: 'query',
database: params.database,
csl: params.query,
...(properties ? { properties } : {}),
...(params.readOnly ? { readOnly: true } : {}),
}
},
},
transformResponse: transformAzureDataExplorerResponse,
outputs: {
tableName: {
type: 'string',
description: 'Name Kusto assigned to the returned result table',
nullable: true,
},
columns: {
type: 'array',
description: 'Column metadata for the result table',
items: {
type: 'object',
properties: {
name: { type: 'string', description: 'Column name' },
type: { type: 'string', description: 'Kusto scalar type', nullable: true },
dataType: { type: 'string', description: 'Approximate .NET type', nullable: true },
},
},
},
rows: {
type: 'array',
description: 'Result rows as positional arrays matching the columns order',
items: { type: 'array' },
},
records: {
type: 'array',
description: 'Result rows keyed by column name',
items: { type: 'object' },
},
rowCount: { type: 'number', description: 'Rows carried in this result, after the row cap' },
totalRowCount: {
type: 'number',
description: 'Rows Kusto returned, before the row cap was applied',
},
truncated: {
type: 'boolean',
description:
'Whether rows were dropped to stay within the row cap — narrow the query if true',
},
},
}
@@ -0,0 +1,111 @@
import type {
AzureDataExplorerShowDatabaseSchemaParams,
AzureDataExplorerTableResponse,
} from '@/tools/azure_data_explorer/types'
import {
AZURE_DATA_EXPLORER_PROXY_URL,
azureDataExplorerAuthBody,
renderEntityName,
transformAzureDataExplorerResponse,
} from '@/tools/azure_data_explorer/utils'
import type { ToolConfig } from '@/tools/types'
export const azureDataExplorerShowDatabaseSchemaTool: ToolConfig<
AzureDataExplorerShowDatabaseSchemaParams,
AzureDataExplorerTableResponse
> = {
id: 'azure_data_explorer_show_database_schema',
name: 'Azure Data Explorer Show Database Schema',
description:
'Read the full schema of an Azure Data Explorer database as a flat list of every table and column, so an agent can discover the data model in one call.',
version: '1.0.0',
params: {
clusterUri: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Cluster URI (e.g., https://mycluster.eastus.kusto.windows.net)',
},
tenantId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Microsoft Entra tenant ID hosting the service principal',
},
clientId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Microsoft Entra application (client) ID',
},
clientSecret: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Microsoft Entra application client secret',
},
resource: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Token audience override. Defaults to the cluster URI itself',
},
database: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Database whose schema should be read',
},
},
request: {
url: AZURE_DATA_EXPLORER_PROXY_URL,
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => ({
...azureDataExplorerAuthBody(params),
endpoint: 'mgmt',
database: params.database,
csl: `.show database ${renderEntityName(params.database)} schema`,
}),
},
transformResponse: transformAzureDataExplorerResponse,
outputs: {
tableName: {
type: 'string',
description: 'Name Kusto assigned to the returned result table',
nullable: true,
},
columns: {
type: 'array',
description: 'Column metadata for the result table',
items: {
type: 'object',
properties: {
name: { type: 'string', description: 'Column name' },
type: { type: 'string', description: 'Kusto scalar type', nullable: true },
dataType: { type: 'string', description: 'Approximate .NET type', nullable: true },
},
},
},
rows: {
type: 'array',
description: 'Result rows as positional arrays matching the columns order',
items: { type: 'array' },
},
records: {
type: 'array',
description: 'Result rows keyed by column name',
items: { type: 'object' },
},
rowCount: { type: 'number', description: 'Rows carried in this result, after the row cap' },
totalRowCount: {
type: 'number',
description: 'Rows Kusto returned, before the row cap was applied',
},
truncated: {
type: 'boolean',
description:
'Whether rows were dropped to stay within the row cap — narrow the query if true',
},
},
}
@@ -0,0 +1,119 @@
import type {
AzureDataExplorerShowIngestionFailuresParams,
AzureDataExplorerTableResponse,
} from '@/tools/azure_data_explorer/types'
import {
AZURE_DATA_EXPLORER_PROXY_URL,
azureDataExplorerAuthBody,
renderOperationId,
transformAzureDataExplorerResponse,
} from '@/tools/azure_data_explorer/utils'
import type { ToolConfig } from '@/tools/types'
export const azureDataExplorerShowIngestionFailuresTool: ToolConfig<
AzureDataExplorerShowIngestionFailuresParams,
AzureDataExplorerTableResponse
> = {
id: 'azure_data_explorer_show_ingestion_failures',
name: 'Azure Data Explorer Show Ingestion Failures',
description:
'List ingestion failures recorded for a database, with the failing table, error code, root cause detail, and whether the failure is permanent or transient. Failures are retained for 14 days.',
version: '1.0.0',
params: {
clusterUri: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Cluster URI (e.g., https://mycluster.eastus.kusto.windows.net)',
},
tenantId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Microsoft Entra tenant ID hosting the service principal',
},
clientId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Microsoft Entra application (client) ID',
},
clientSecret: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Microsoft Entra application client secret',
},
resource: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Token audience override. Defaults to the cluster URI itself',
},
database: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Database whose ingestion failures should be listed',
},
operationId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Limit results to a single ingestion operation ID',
},
},
request: {
url: AZURE_DATA_EXPLORER_PROXY_URL,
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => ({
...azureDataExplorerAuthBody(params),
endpoint: 'mgmt',
database: params.database,
csl: params.operationId
? `.show ingestion failures with (OperationId = ${renderOperationId(params.operationId)})`
: '.show ingestion failures',
}),
},
transformResponse: transformAzureDataExplorerResponse,
outputs: {
tableName: {
type: 'string',
description: 'Name Kusto assigned to the returned result table',
nullable: true,
},
columns: {
type: 'array',
description: 'Column metadata for the result table',
items: {
type: 'object',
properties: {
name: { type: 'string', description: 'Column name' },
type: { type: 'string', description: 'Kusto scalar type', nullable: true },
dataType: { type: 'string', description: 'Approximate .NET type', nullable: true },
},
},
},
rows: {
type: 'array',
description: 'Result rows as positional arrays matching the columns order',
items: { type: 'array' },
},
records: {
type: 'array',
description: 'Result rows keyed by column name',
items: { type: 'object' },
},
rowCount: { type: 'number', description: 'Rows carried in this result, after the row cap' },
totalRowCount: {
type: 'number',
description: 'Rows Kusto returned, before the row cap was applied',
},
truncated: {
type: 'boolean',
description:
'Whether rows were dropped to stay within the row cap — narrow the query if true',
},
},
}
@@ -0,0 +1,119 @@
import type {
AzureDataExplorerShowOperationsParams,
AzureDataExplorerTableResponse,
} from '@/tools/azure_data_explorer/types'
import {
AZURE_DATA_EXPLORER_PROXY_URL,
azureDataExplorerAuthBody,
renderOperationId,
transformAzureDataExplorerResponse,
} from '@/tools/azure_data_explorer/utils'
import type { ToolConfig } from '@/tools/types'
export const azureDataExplorerShowOperationsTool: ToolConfig<
AzureDataExplorerShowOperationsParams,
AzureDataExplorerTableResponse
> = {
id: 'azure_data_explorer_show_operations',
name: 'Azure Data Explorer Show Operations',
description:
'Check the state of administrative operations on a cluster, such as an async ingestion. Given an operation ID it returns that operation latest update; with no ID it returns the operations from the last two weeks.',
version: '1.0.0',
params: {
clusterUri: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Cluster URI (e.g., https://mycluster.eastus.kusto.windows.net)',
},
tenantId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Microsoft Entra tenant ID hosting the service principal',
},
clientId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Microsoft Entra application (client) ID',
},
clientSecret: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Microsoft Entra application client secret',
},
resource: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Token audience override. Defaults to the cluster URI itself',
},
database: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Database context for the command',
},
operationId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Operation ID to check, e.g. the ID returned by an async ingestion',
},
},
request: {
url: AZURE_DATA_EXPLORER_PROXY_URL,
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => ({
...azureDataExplorerAuthBody(params),
endpoint: 'mgmt',
database: params.database,
csl: params.operationId
? `.show operations ${renderOperationId(params.operationId)}`
: '.show operations',
}),
},
transformResponse: transformAzureDataExplorerResponse,
outputs: {
tableName: {
type: 'string',
description: 'Name Kusto assigned to the returned result table',
nullable: true,
},
columns: {
type: 'array',
description: 'Column metadata for the result table',
items: {
type: 'object',
properties: {
name: { type: 'string', description: 'Column name' },
type: { type: 'string', description: 'Kusto scalar type', nullable: true },
dataType: { type: 'string', description: 'Approximate .NET type', nullable: true },
},
},
},
rows: {
type: 'array',
description: 'Result rows as positional arrays matching the columns order',
items: { type: 'array' },
},
records: {
type: 'array',
description: 'Result rows keyed by column name',
items: { type: 'object' },
},
rowCount: { type: 'number', description: 'Rows carried in this result, after the row cap' },
totalRowCount: {
type: 'number',
description: 'Rows Kusto returned, before the row cap was applied',
},
truncated: {
type: 'boolean',
description:
'Whether rows were dropped to stay within the row cap — narrow the query if true',
},
},
}
@@ -0,0 +1,119 @@
import type {
AzureDataExplorerShowTableDetailsParams,
AzureDataExplorerTableResponse,
} from '@/tools/azure_data_explorer/types'
import {
AZURE_DATA_EXPLORER_PROXY_URL,
azureDataExplorerAuthBody,
renderEntityName,
transformAzureDataExplorerResponse,
} from '@/tools/azure_data_explorer/utils'
import type { ToolConfig } from '@/tools/types'
export const azureDataExplorerShowTableDetailsTool: ToolConfig<
AzureDataExplorerShowTableDetailsParams,
AzureDataExplorerTableResponse
> = {
id: 'azure_data_explorer_show_table_details',
name: 'Azure Data Explorer Show Table Details',
description:
'Read size, row count, hot-cache footprint, and effective policies for a table — or for every table in the database when no table is given. Use it to see how much data a table actually holds before querying it.',
version: '1.0.0',
params: {
clusterUri: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Cluster URI (e.g., https://mycluster.eastus.kusto.windows.net)',
},
tenantId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Microsoft Entra tenant ID hosting the service principal',
},
clientId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Microsoft Entra application (client) ID',
},
clientSecret: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Microsoft Entra application client secret',
},
resource: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Token audience override. Defaults to the cluster URI itself',
},
database: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Database to read table details from',
},
table: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Table to describe. Omit to describe every table in the database',
},
},
request: {
url: AZURE_DATA_EXPLORER_PROXY_URL,
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => ({
...azureDataExplorerAuthBody(params),
endpoint: 'mgmt',
database: params.database,
csl: params.table
? `.show table ${renderEntityName(params.table)} details`
: '.show tables details',
}),
},
transformResponse: transformAzureDataExplorerResponse,
outputs: {
tableName: {
type: 'string',
description: 'Name Kusto assigned to the returned result table',
nullable: true,
},
columns: {
type: 'array',
description: 'Column metadata for the result table',
items: {
type: 'object',
properties: {
name: { type: 'string', description: 'Column name' },
type: { type: 'string', description: 'Kusto scalar type', nullable: true },
dataType: { type: 'string', description: 'Approximate .NET type', nullable: true },
},
},
},
rows: {
type: 'array',
description: 'Result rows as positional arrays matching the columns order',
items: { type: 'array' },
},
records: {
type: 'array',
description: 'Result rows keyed by column name',
items: { type: 'object' },
},
rowCount: { type: 'number', description: 'Rows carried in this result, after the row cap' },
totalRowCount: {
type: 'number',
description: 'Rows Kusto returned, before the row cap was applied',
},
truncated: {
type: 'boolean',
description:
'Whether rows were dropped to stay within the row cap — narrow the query if true',
},
},
}
@@ -0,0 +1,89 @@
import type {
AzureDataExplorerShowTableSchemaParams,
AzureDataExplorerTableSchemaResponse,
} from '@/tools/azure_data_explorer/types'
import {
AZURE_DATA_EXPLORER_PROXY_URL,
azureDataExplorerAuthBody,
renderEntityName,
transformTableSchemaResponse,
} from '@/tools/azure_data_explorer/utils'
import type { ToolConfig } from '@/tools/types'
export const azureDataExplorerShowTableSchemaTool: ToolConfig<
AzureDataExplorerShowTableSchemaParams,
AzureDataExplorerTableSchemaResponse
> = {
id: 'azure_data_explorer_show_table_schema',
name: 'Azure Data Explorer Show Table Schema',
description:
'Read the column schema of an Azure Data Explorer table in CSL form (e.g., "Timestamp:datetime,Level:string"). Use this before writing a KQL query against an unfamiliar table.',
version: '1.0.0',
params: {
clusterUri: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Cluster URI (e.g., https://mycluster.eastus.kusto.windows.net)',
},
tenantId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Microsoft Entra tenant ID hosting the service principal',
},
clientId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Microsoft Entra application (client) ID',
},
clientSecret: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Microsoft Entra application client secret',
},
resource: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Token audience override. Defaults to the cluster URI itself',
},
database: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Database containing the table',
},
table: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Table whose schema should be read',
},
},
request: {
url: AZURE_DATA_EXPLORER_PROXY_URL,
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => ({
...azureDataExplorerAuthBody(params),
endpoint: 'mgmt',
database: params.database,
csl: `.show table ${renderEntityName(params.table)} cslschema`,
}),
},
transformResponse: transformTableSchemaResponse,
outputs: {
tableName: { type: 'string', description: 'Name of the table', nullable: true },
schema: {
type: 'string',
description: 'Comma-separated CSL column schema (name:type)',
nullable: true,
},
databaseName: { type: 'string', description: "The table's database", nullable: true },
folder: { type: 'string', description: "The table's folder", nullable: true },
docString: { type: 'string', description: "The table's docstring", nullable: true },
},
}
+133
View File
@@ -0,0 +1,133 @@
import type { ToolResponse } from '@/tools/types'
export interface AzureDataExplorerBaseParams {
clusterUri: string
tenantId: string
clientId: string
clientSecret: string
resource?: string
}
export interface AzureDataExplorerColumn {
name: string
type: string | null
dataType: string | null
}
/** Primary result table of a Kusto query or management command. */
export interface AzureDataExplorerTable {
tableName: string | null
columns: AzureDataExplorerColumn[]
rows: unknown[][]
records: Array<Record<string, unknown>>
/** Rows carried in this payload, after the row cap. */
rowCount: number
/** Rows Kusto returned, before the row cap. */
totalRowCount: number
truncated: boolean
}
export interface AzureDataExplorerTableResponse extends ToolResponse {
output: AzureDataExplorerTable
}
export interface AzureDataExplorerDatabaseListResponse extends ToolResponse {
output: AzureDataExplorerTable & { databases: string[] }
}
export interface AzureDataExplorerTableListResponse extends ToolResponse {
output: AzureDataExplorerTable & { tables: string[] }
}
export interface AzureDataExplorerIngestResponse extends ToolResponse {
output: AzureDataExplorerTable & { extentIds: string[] }
}
export interface AzureDataExplorerTableSchemaResponse extends ToolResponse {
output: {
tableName: string | null
schema: string | null
databaseName: string | null
folder: string | null
docString: string | null
}
}
export interface AzureDataExplorerQueryParams extends AzureDataExplorerBaseParams {
database: string
query: string
properties?: Record<string, unknown> | string
readOnly?: boolean
}
export interface AzureDataExplorerManagementParams extends AzureDataExplorerBaseParams {
command: string
database?: string
}
export interface AzureDataExplorerListDatabasesParams extends AzureDataExplorerBaseParams {}
export interface AzureDataExplorerListTablesParams extends AzureDataExplorerBaseParams {
database: string
}
export interface AzureDataExplorerShowTableSchemaParams extends AzureDataExplorerBaseParams {
database: string
table: string
}
export interface AzureDataExplorerShowDatabaseSchemaParams extends AzureDataExplorerBaseParams {
database: string
}
export interface AzureDataExplorerFunctionListResponse extends ToolResponse {
output: AzureDataExplorerTable & { functions: string[] }
}
export interface AzureDataExplorerCreateTableParams extends AzureDataExplorerBaseParams {
database: string
table: string
columnSchema: string
tableProperties?: string
}
export interface AzureDataExplorerDropTableParams extends AzureDataExplorerBaseParams {
database: string
table: string
ifExists?: boolean
}
export interface AzureDataExplorerIngestFromQueryParams extends AzureDataExplorerBaseParams {
database: string
table: string
mode?: string
sourceQuery: string
async?: boolean
ingestionProperties?: string
}
export interface AzureDataExplorerShowTableDetailsParams extends AzureDataExplorerBaseParams {
database: string
table?: string
}
export interface AzureDataExplorerShowIngestionFailuresParams extends AzureDataExplorerBaseParams {
database: string
operationId?: string
}
export interface AzureDataExplorerListFunctionsParams extends AzureDataExplorerBaseParams {
database: string
}
export interface AzureDataExplorerShowOperationsParams extends AzureDataExplorerBaseParams {
database?: string
operationId?: string
}
export interface AzureDataExplorerIngestInlineParams extends AzureDataExplorerBaseParams {
database: string
table: string
data: string
ingestionProperties?: string
}
@@ -0,0 +1,200 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
buildWithClause,
renderColumnSchema,
renderEntityName,
renderIngestMode,
renderOperationId,
transformColumnListResponse,
} from '@/tools/azure_data_explorer/utils'
describe('renderEntityName', () => {
it('leaves a plain identifier bare, as the reference commands are written', () => {
expect(renderEntityName('StormEvents')).toBe('StormEvents')
expect(renderEntityName('_internal_logs2')).toBe('_internal_logs2')
})
it('trims surrounding whitespace from a pasted name', () => {
expect(renderEntityName(' StormEvents ')).toBe('StormEvents')
})
it('quotes the identifier characters Kusto allows but cannot parse bare', () => {
expect(renderEntityName('My Table')).toBe('["My Table"]')
expect(renderEntityName('web-requests')).toBe('["web-requests"]')
expect(renderEntityName('prod.logs')).toBe('["prod.logs"]')
expect(renderEntityName('1day')).toBe('["1day"]')
})
})
describe('buildWithClause', () => {
it('returns nothing when no properties are given', () => {
expect(buildWithClause(undefined, 'format="json"')).toBe('')
expect(buildWithClause(' ', 'format="json"')).toBe('')
expect(buildWithClause('with ()', 'format="json"')).toBe('')
})
it('builds the clause from bare name=value pairs', () => {
expect(buildWithClause('format="json"', 'format="json"')).toBe(' with (format="json")')
expect(
buildWithClause('format="json", ingestionMappingReference="mymapping"', 'format="json"')
).toBe(' with (format="json", ingestionMappingReference="mymapping")')
})
it('accepts a clause the user already wrapped in with (...)', () => {
expect(buildWithClause('with (format="csv")', 'format="json"')).toBe(' with (format="csv")')
})
it('accepts unquoted and numeric values', () => {
expect(buildWithClause('ignoreFirstRecord=true', 'format="json"')).toBe(
' with (ignoreFirstRecord=true)'
)
expect(buildWithClause('creationTime=2024-01-01T00:00:00', 'format="json"')).toBe(
' with (creationTime=2024-01-01T00:00:00)'
)
})
it('keeps a comma that sits inside a quoted value', () => {
expect(buildWithClause('docstring="Raw logs, archived nightly"', 'format="json"')).toBe(
' with (docstring="Raw logs, archived nightly")'
)
expect(buildWithClause(`tags="['daily','prod']"`, 'format="json"')).toBe(
` with (tags="['daily','prod']")`
)
})
it('still separates properties on the commas between them', () => {
expect(
buildWithClause('docstring="Logs, raw", folder="Ingest", distributed=true', 'format="json"')
).toBe(' with (docstring="Logs, raw", folder="Ingest", distributed=true)')
})
it('rejects an unterminated quote rather than swallowing the rest of the clause', () => {
expect(() => buildWithClause('docstring="never closed', 'format="json"')).toThrow(
/Unterminated " quote/
)
})
it('rejects a value that would close the clause and extend the command', () => {
expect(() => buildWithClause('format="json") <| evil', 'format="json"')).toThrow(
/Invalid property/
)
})
it('rejects a property that is not a name=value pair', () => {
expect(() => buildWithClause('drop table StormEvents', 'format="json"')).toThrow(
/Invalid property/
)
})
})
describe('renderColumnSchema', () => {
it('normalizes a CSL schema and lowercases the types', () => {
expect(renderColumnSchema('Timestamp:DateTime, Level:string, Count:LONG')).toBe(
'Timestamp:datetime, Level:string, Count:long'
)
})
it('accepts every documented scalar type and alias', () => {
expect(renderColumnSchema('a:bool, b:boolean, c:date, d:guid, e:uuid, f:double, g:time')).toBe(
'a:bool, b:boolean, c:date, d:guid, e:uuid, f:double, g:time'
)
})
it('quotes a column name that cannot be written bare', () => {
expect(renderColumnSchema('Event Time:datetime')).toBe('["Event Time"]:datetime')
})
it('rejects a type Kusto does not define', () => {
expect(() => renderColumnSchema('Amount:money')).toThrow(/Unknown column type "money"/)
})
it('rejects a column that is not a name:type pair', () => {
expect(() => renderColumnSchema('Timestamp')).toThrow(/expected name:type pairs/)
})
it('rejects a schema that would close the column list and extend the command', () => {
expect(() => renderColumnSchema('a:string) //')).toThrow(/Unknown column type/)
})
it('rejects an empty schema', () => {
expect(() => renderColumnSchema(' , ')).toThrow(/Column schema is required/)
})
})
describe('renderIngestMode', () => {
it('defaults to the non-destructive set-or-append command', () => {
expect(renderIngestMode(undefined)).toBe('.set-or-append')
expect(renderIngestMode('')).toBe('.set-or-append')
})
it('maps each documented mode onto its command word', () => {
expect(renderIngestMode('set')).toBe('.set')
expect(renderIngestMode('append')).toBe('.append')
expect(renderIngestMode('set-or-replace')).toBe('.set-or-replace')
})
it('rejects a mode that is not one of the four commands', () => {
expect(() => renderIngestMode('drop')).toThrow(/Unknown ingest mode "drop"/)
})
})
describe('renderOperationId', () => {
it('accepts a GUID', () => {
expect(renderOperationId(' 8f1e5c4a-1b2c-4d3e-9f80-0a1b2c3d4e5f ')).toBe(
'8f1e5c4a-1b2c-4d3e-9f80-0a1b2c3d4e5f'
)
})
it('rejects anything that could extend the command it lands in', () => {
expect(() => renderOperationId('abc") | drop table X //')).toThrow(/Invalid operation ID/)
})
})
/** Minimal stand-in for the proxy's JSON envelope. */
function proxyResponse(records: Array<Record<string, unknown>>): Response {
return {
ok: true,
status: 200,
json: async () => ({
success: true,
output: {
tableName: 'Table_0',
columns: [],
rows: [],
records,
rowCount: records.length,
totalRowCount: records.length,
truncated: false,
},
}),
} as unknown as Response
}
describe('transformColumnListResponse', () => {
it('collects the values of the named column', async () => {
const transform = transformColumnListResponse('TableName', 'tables')
const result = await transform(
proxyResponse([{ TableName: 'StormEvents' }, { TableName: 'Logs' }])
)
expect(result.output.tables).toEqual(['StormEvents', 'Logs'])
})
it("keeps an empty extent ID, which is how Kusto reports 'no data shard was written'", async () => {
const transform = transformColumnListResponse('ExtentId', 'extentIds')
const result = await transform(proxyResponse([{ ExtentId: '' }]))
expect(result.output.extentIds).toEqual([''])
expect(result.output.rowCount).toBe(1)
})
it('skips a null value rather than coercing it to a string', async () => {
const transform = transformColumnListResponse('TableName', 'tables')
const result = await transform(proxyResponse([{ TableName: null }, { TableName: 'Logs' }]))
expect(result.output.tables).toEqual(['Logs'])
})
})
+288
View File
@@ -0,0 +1,288 @@
import type {
AzureDataExplorerBaseParams,
AzureDataExplorerTable,
} from '@/tools/azure_data_explorer/types'
export const AZURE_DATA_EXPLORER_PROXY_URL = '/api/tools/azure_data_explorer/proxy'
export function azureDataExplorerAuthBody(params: AzureDataExplorerBaseParams) {
return {
clusterUri: params.clusterUri,
tenantId: params.tenantId,
clientId: params.clientId,
clientSecret: params.clientSecret,
...(params.resource ? { resource: params.resource } : {}),
}
}
/** A name Kusto accepts bare, matching the form every documented example uses. */
const PLAIN_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/
/**
* Renders a Kusto entity name for a command string.
*
* A plain identifier is emitted bare, exactly as the reference commands are
* written. A name carrying a space, dot, or dash the other characters Kusto
* permits in an identifier is wrapped in `["..."]`, the documented quoting for
* names with special characters. The proxy contract restricts names to Kusto's
* documented character set, so neither form can carry a quote or bracket and no
* escaping is needed.
*/
export function renderEntityName(name: string): string {
const trimmed = name.trim()
return PLAIN_IDENTIFIER.test(trimmed) ? trimmed : `["${trimmed}"]`
}
/** One `name = value` command property, with a quoted, numeric, or bare value. */
const COMMAND_PROPERTY = /^[A-Za-z_][A-Za-z0-9_]*\s*=\s*(?:"[^"\\]*"|'[^'\\]*'|[A-Za-z0-9_.:+-]+)$/
/**
* Splits a property list on the commas that separate properties, ignoring the
* ones inside a quoted value.
*
* A plain `split(',')` would break the documented cases where a value legally
* contains a comma a `docstring` sentence, or a `tags` array with more than
* one entry.
*/
function splitProperties(input: string): string[] {
const parts: string[] = []
let current = ''
let quote: '"' | "'" | null = null
for (const char of input) {
if (quote) {
current += char
if (char === quote) quote = null
continue
}
if (char === '"' || char === "'") {
quote = char
current += char
continue
}
if (char === ',') {
parts.push(current)
current = ''
continue
}
current += char
}
if (quote) throw new Error(`Unterminated ${quote} quote in property list: ${input}`)
parts.push(current)
return parts.map((part) => part.trim()).filter(Boolean)
}
/**
* Builds the `with (...)` clause shared by the create and ingest commands.
*
* The clause is interpolated into a command string, so each property is checked
* against the `name = value` grammar rather than passed through an
* unvalidated value could otherwise close the clause and extend the command.
*/
export function buildWithClause(input: string | undefined, example: string): string {
const trimmed = input?.trim()
if (!trimmed) return ''
const inner = trimmed
.replace(/^with\s*\(/i, '')
.replace(/\)$/, '')
.trim()
if (!inner) return ''
const properties = splitProperties(inner)
for (const property of properties) {
if (!COMMAND_PROPERTY.test(property)) {
throw new Error(
`Invalid property "${property}": expected comma-separated name=value pairs, e.g. ${example}`
)
}
}
return ` with (${properties.join(', ')})`
}
/** The scalar type names and aliases Kusto documents for a column. */
const KUSTO_SCALAR_TYPES = new Set([
'bool',
'boolean',
'datetime',
'date',
'decimal',
'dynamic',
'guid',
'uuid',
'uniqueid',
'int',
'long',
'real',
'double',
'string',
'timespan',
'time',
])
const COLUMN_NAME = /^[\p{L}\p{N}_ .-]+$/u
/**
* Renders a CSL column schema `Timestamp:datetime, Level:string` for a
* create command.
*
* Each column name goes through {@link renderEntityName} and each type is
* checked against Kusto's documented scalar types, so the schema cannot carry
* anything that would extend the command.
*/
export function renderColumnSchema(schema: string): string {
const columns = schema
.split(',')
.map((column) => column.trim())
.filter(Boolean)
if (columns.length === 0) {
throw new Error('Column schema is required, e.g. Timestamp:datetime, Level:string')
}
return columns
.map((column) => {
const separator = column.lastIndexOf(':')
if (separator <= 0) {
throw new Error(
`Invalid column "${column}": expected name:type pairs, e.g. Timestamp:datetime, Level:string`
)
}
const name = column.slice(0, separator).trim()
const type = column
.slice(separator + 1)
.trim()
.toLowerCase()
if (!COLUMN_NAME.test(name)) {
throw new Error(
`Invalid column name "${name}": may contain only letters, digits, underscores, spaces, dots, and dashes`
)
}
if (!KUSTO_SCALAR_TYPES.has(type)) {
throw new Error(
`Unknown column type "${type}" for column "${name}": expected one of ${[...KUSTO_SCALAR_TYPES].join(', ')}`
)
}
return `${renderEntityName(name)}:${type}`
})
.join(', ')
}
/** The four documented ingest-from-query commands. */
const INGEST_MODE_COMMANDS = {
set: '.set',
append: '.append',
'set-or-append': '.set-or-append',
'set-or-replace': '.set-or-replace',
} as const
export type AzureDataExplorerIngestMode = keyof typeof INGEST_MODE_COMMANDS
/**
* Maps an ingest mode onto its command word. Unknown modes are rejected rather
* than passed through, since the word leads the command string.
*/
export function renderIngestMode(mode: string | undefined): string {
const key = (mode?.trim() || 'set-or-append') as AzureDataExplorerIngestMode
const command = INGEST_MODE_COMMANDS[key]
if (!command) {
throw new Error(
`Unknown ingest mode "${mode}": expected one of ${Object.keys(INGEST_MODE_COMMANDS).join(', ')}`
)
}
return command
}
/**
* Kusto operation IDs are GUIDs. The `.show operations` and
* `.show ingestion failures` syntax lines take the ID as a bare argument, so it
* is emitted unquoted and restricted to GUID characters.
*/
const OPERATION_ID = /^[0-9a-fA-F-]{1,64}$/
export function renderOperationId(operationId: string): string {
const trimmed = operationId.trim()
if (!OPERATION_ID.test(trimmed)) {
throw new Error(`Invalid operation ID "${operationId}": expected a GUID`)
}
return trimmed
}
interface ProxyEnvelope {
success?: boolean
output?: AzureDataExplorerTable
error?: string
}
async function readProxyEnvelope(response: Response): Promise<AzureDataExplorerTable> {
const data = (await response.json().catch(() => ({}))) as ProxyEnvelope
if (!response.ok || data.success === false) {
throw new Error(data.error || `Azure Data Explorer request failed: HTTP ${response.status}`)
}
return (
data.output ?? {
tableName: null,
columns: [],
rows: [],
records: [],
rowCount: 0,
totalRowCount: 0,
truncated: false,
}
)
}
export async function transformAzureDataExplorerResponse(response: Response) {
return { success: true as const, output: await readProxyEnvelope(response) }
}
/**
* Adds a flat list of the strings in one documented column, so a `.show` or
* `.ingest` command surfaces its identifiers alongside the full result table.
*
* Every string value is kept, including an empty one: `.ingest inline` reports
* "no data shards were generated" as a single record carrying an empty
* (zero-valued) extent ID, so dropping it would turn a no-op load into what
* looks like a missing column.
*/
export function transformColumnListResponse<K extends string>(columnName: string, outputKey: K) {
return async (response: Response) => {
const output = await readProxyEnvelope(response)
const values = output.records
.map((record) => record[columnName])
.filter((value): value is string => typeof value === 'string')
return {
success: true as const,
output: { ...output, [outputKey]: values } as AzureDataExplorerTable & Record<K, string[]>,
}
}
}
function stringOrNull(value: unknown): string | null {
return typeof value === 'string' ? value : null
}
/**
* Projects `.show table ... cslschema`, whose single row carries the documented
* TableName, Schema, DatabaseName, Folder, and DocString columns.
*/
export async function transformTableSchemaResponse(response: Response) {
const output = await readProxyEnvelope(response)
const record = output.records[0] ?? {}
return {
success: true as const,
output: {
tableName: stringOrNull(record.TableName),
schema: stringOrNull(record.Schema),
databaseName: stringOrNull(record.DatabaseName),
folder: stringOrNull(record.Folder),
docString: stringOrNull(record.DocString),
},
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+30
View File
@@ -311,6 +311,22 @@ import {
attioUpdateTaskTool,
attioUpdateWebhookTool,
} from '@/tools/attio'
import {
azureDataExplorerCreateTableTool,
azureDataExplorerDropTableTool,
azureDataExplorerIngestFromQueryTool,
azureDataExplorerIngestInlineTool,
azureDataExplorerListDatabasesTool,
azureDataExplorerListFunctionsTool,
azureDataExplorerListTablesTool,
azureDataExplorerManagementTool,
azureDataExplorerQueryTool,
azureDataExplorerShowDatabaseSchemaTool,
azureDataExplorerShowIngestionFailuresTool,
azureDataExplorerShowOperationsTool,
azureDataExplorerShowTableDetailsTool,
azureDataExplorerShowTableSchemaTool,
} from '@/tools/azure_data_explorer'
import {
addCommentTool as azureDevopsAddCommentTool,
createWorkItemTool as azureDevopsCreateWorkItemTool,
@@ -5214,6 +5230,20 @@ export const tools: Record<string, ToolConfig> = {
brightdata_serp_search: brightDataSerpSearchTool,
brightdata_snapshot_status: brightDataSnapshotStatusTool,
brightdata_sync_scrape: brightDataSyncScrapeTool,
azure_data_explorer_query: azureDataExplorerQueryTool,
azure_data_explorer_management: azureDataExplorerManagementTool,
azure_data_explorer_list_databases: azureDataExplorerListDatabasesTool,
azure_data_explorer_list_tables: azureDataExplorerListTablesTool,
azure_data_explorer_show_table_schema: azureDataExplorerShowTableSchemaTool,
azure_data_explorer_show_database_schema: azureDataExplorerShowDatabaseSchemaTool,
azure_data_explorer_ingest_inline: azureDataExplorerIngestInlineTool,
azure_data_explorer_ingest_from_query: azureDataExplorerIngestFromQueryTool,
azure_data_explorer_create_table: azureDataExplorerCreateTableTool,
azure_data_explorer_drop_table: azureDataExplorerDropTableTool,
azure_data_explorer_show_table_details: azureDataExplorerShowTableDetailsTool,
azure_data_explorer_show_ingestion_failures: azureDataExplorerShowIngestionFailuresTool,
azure_data_explorer_list_functions: azureDataExplorerListFunctionsTool,
azure_data_explorer_show_operations: azureDataExplorerShowOperationsTool,
azure_devops_list_pipelines: azureDevopsListPipelinesTool,
azure_devops_get_pipeline: azureDevopsGetPipelineTool,
azure_devops_list_pipeline_runs: azureDevopsListPipelineRunsTool,
+2 -2
View File
@@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries')
const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors')
const BASELINE = {
totalRoutes: 1106,
zodRoutes: 1106,
totalRoutes: 1107,
zodRoutes: 1107,
nonZodRoutes: 0,
} as const