diff --git a/docs/docs/intro.md b/docs/docs/intro.md
index e9ba88644..c318e66ca 100644
--- a/docs/docs/intro.md
+++ b/docs/docs/intro.md
@@ -44,6 +44,8 @@ Slack channel in the Tableau #DataDev workspace.
| [get-datasource-metadata](tools/data-qna/get-datasource-metadata.md) | Fetches datasource metadata including table relationships, datasource and field descriptions, field roles and types, calculation strings, and parameters for the specified datasource ([Metadata API][meta] & [VDS API][vds]) | All SKUs\* |
| [get-workbook](tools/workbooks/get-workbook.md) | Retrieves information about a workbook for a specified workbook on a Tableau site ([REST API][get-workbook]) | All SKUs |
| [get-flow](tools/flows/get-flow.md) | Retrieves information on a Tableau Prep flow including output steps and recent runs ([REST API][get-flow]) | All SKUs |
+| [list-flow-runs](tools/flows/list-flow-runs.md) | Retrieves the run history (executions) of Tableau Prep flows on a site ([REST API][list-flow-runs]) | All SKUs |
+| [list-flow-tasks](tools/flows/list-flow-tasks.md) | Retrieves the scheduled flow run tasks (schedules) for Tableau Prep flows on a site ([REST API][list-flow-tasks]) | All SKUs |
| [delete-content](tools/content/delete-content.md) | Admin-only. Two-phase (preview/confirm) delete of a workbook, data source, or extract refresh task ([REST API][delete-workbook], [REST API][delete-datasource], [REST API][delete-extract-refresh-task]) | All SKUs |
| [get-view-data](tools/views/get-view-data.md) | Retrieves data in CSV format for the specified view in a Tableau workbook. *Note: the get-view-data api currently has a limitation that when used on a dashboard sheet type, it will only return data for the first worksheet in the dashboard. This will be fixed in the 26.3 fall release.* ([REST API][get-view-data]) | All SKUs |
| [get-view-image](tools/views/get-view-image.md) | Retrieves an image for the specified view in a Tableau workbook ([REST API][get-view-image]) | All SKUs |
@@ -83,6 +85,10 @@ Slack channel in the Tableau #DataDev workspace.
https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#query_workbook
[get-flow]:
https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#query_flow
+[list-flow-runs]:
+ https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#get_flow_runs
+[list-flow-tasks]:
+ https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#get_flow_run_tasks
[delete-workbook]:
https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#delete_workbook
[delete-datasource]:
diff --git a/docs/docs/tools/flows/list-flow-runs.md b/docs/docs/tools/flows/list-flow-runs.md
new file mode 100644
index 000000000..56d953ff4
--- /dev/null
+++ b/docs/docs/tools/flows/list-flow-runs.md
@@ -0,0 +1,202 @@
+---
+sidebar_position: 3
+---
+
+# List Flow Runs
+
+Retrieves the run history (executions) of Tableau Prep flows on a site. Each flow run records one
+execution attempt with its status, start/finish timestamps, progress, the flow it belongs to, and
+the background job id.
+
+This is the dedicated, filterable, site-wide run-history tool:
+
+- [List Flows](list-flows.md) lists flow _definitions_, not executions.
+- [Get Flow](get-flow.md) returns recent runs for a **single** flow (as a capped sidecar). Use
+ `list-flow-runs` for cross-flow questions ("all failures today") or deeper single-flow history.
+
+## APIs called
+
+- [Get Flow Runs](https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#get_flow_runs)
+ (requires Tableau REST API >= 3.10)
+
+## Required Tableau API scopes
+
+When the MCP server authenticates with OAuth (connected-app JWT), this tool requests:
+
+- `tableau:flow_runs:read`
+- `tableau:flows:read` — to resolve a failed run's flow `webpageUrl` into the run-history deep link
+ in `mcp.failureInsight`.
+- `tableau:mcp_site_settings:read`
+
+See [OAuth configuration](../../configuration/mcp-config/oauth.md) for how scopes are negotiated.
+
+## Caller-role visibility
+
+- **Non-admin callers** — Tableau returns runs only for flows the caller can **run** (the Execute /
+ "Run Flow Now" capability), _not_ flows they can only view. This is stricter than the Tableau web UI
+ run-history page (which needs only view permission), so a user may see a flow's runs in the browser
+ yet get nothing for that flow here.
+- **Server / site-admin callers** — Tableau returns runs for **every** flow on the site, so
+ [`mcp.resultInfo.truncated`](#response-shape) is more likely to be `true` on a broad call.
+
+## Optional arguments
+
+### `filter`
+
+A filter expression in the format `field:operator:value`. Multiple expressions are combined with a
+comma using a logical AND.
+
+| Field | Operators | Notes |
+| ------------- | ---------------------- | --------------------------------------------------------------------------- |
+| `flowId` | `eq, in` | Flow LUID (UUID). The most common filter — scope runs to one or more flows. |
+| `userId` | `eq, in` | LUID of the user who initiated the run. |
+| `progress` | `eq, gt, gte, lt, lte` | Percent complete (0–100). |
+| `startedAt` | `eq, gt, gte, lt, lte` | ISO 8601 `YYYY-MM-DDTHH:MM:SSZ` or date-only `YYYY-MM-DD` (midnight UTC). |
+| `completedAt` | `eq, gt, gte, lt, lte` | ISO 8601 `YYYY-MM-DDTHH:MM:SSZ` or date-only `YYYY-MM-DD` (midnight UTC). |
+| `status` | `eq, in` | One of `Pending, InProgress, Success, Failed, Cancelled`. **Client-side.** |
+
+Examples:
+
+- `flowId:eq:6f8a2966-e173-11e8-ae74-ffd84c19d7f3`
+- `status:eq:Failed,startedAt:gt:2025-01-01`
+- `status:in:[Failed,Cancelled]`
+
+#### `status` is applied client-side
+
+The Tableau "Get Flow Runs" endpoint does **not** support filtering runs by `status` server-side
+(verified live against REST 3.30). This tool therefore fetches runs using the rest of the filter and
+applies `status` in-process. Pair `status` with a `flowId` and/or a `startedAt` window so the
+client-side match operates on a bounded set of runs. Unknown `status` values are rejected with the
+allowed list, and the value is case-sensitive.
+
+#### `flowId` must be a UUID
+
+`flowId` matches the flow's LUID (canonical 8-4-4-4-12 hex form). The Get Flow Runs endpoint responds
+`404` for a `flowId` that does not resolve to a real, visible flow (a flow _name_, or a valid-format
+but nonexistent UUID); the tool converts that `404` into an empty result rather than surfacing a raw
+HTTP error. When the result is empty and `flowId` is not a UUID, the tool also returns a recovery hint
+suggesting a [List Flows](list-flows.md) lookup by name.
+
+#### `in:` — bracket-and-comma form
+
+Multi-value lists use the form `flowId:in:[uuid1,uuid2]` or `status:in:[Failed,Cancelled]`, where
+commas separate the items. Items are not quoted, so a single item's value cannot itself contain a
+comma (every comma is read as an item separator).
+
+
+
+### `sort`
+
+A sort expression in the format `field:asc` or `field:desc` using `startedAt` or `completedAt`.
+
+When omitted, the result is ordered **newest first by run recency** — by `completedAt`, falling back
+to `startedAt` for runs that haven't completed; runs with neither timestamp (e.g. `Pending`) sort
+last. This is a best-effort "most recent" within the returned window.
+
+> **Why the default isn't a plain `startedAt:desc`.** The Tableau Get Flow Runs endpoint orders rows
+> whose chosen sort field is _empty_ to the **front** under `:desc`. `startedAt` is empty for every
+> never-started run (e.g. `Cancelled`-before-start), so `startedAt:desc` surfaces a stale, unordered
+> block of those runs ahead of genuinely recent ones. The tool fetches `completedAt:desc` (a far
+> smaller empty band) and then re-orders the fetched window by `completedAt ?? startedAt`. Supplying
+> an explicit `sort` is honored verbatim and bypasses this correction.
+
+Example: `completedAt:desc`
+
+
+
+### `limit`
+
+The maximum number of runs to return. The tool paginates the Tableau endpoint and returns at most
+this many runs. When omitted, it falls back to the administrator cap if one is configured; otherwise
+it returns the newest **100** runs as a safety backstop (reported as `truncated` with
+`truncationReason: "default-cap"`) rather than walking the entire run history. Pass an explicit
+`limit` to go beyond the default.
+
+See also: [`MAX_RESULT_LIMIT`](../../configuration/mcp-config/env-vars.md#max_result_limit) and
+[`MAX_RESULT_LIMITS=list-flow-runs:N`](../../configuration/mcp-config/env-vars.md#max_result_limits).
+
+## Response shape
+
+The tool returns a JSON object:
+
+```json
+{
+ "flowRuns": [
+ /* one record per run, see "Example result" below */
+ ],
+ "mcp": {
+ "resultInfo": {
+ "returnedCount": 12,
+ "truncated": false
+ }
+ }
+}
+```
+
+The top-level `flowRuns` array and `mcp.resultInfo` are **always present**. `resultInfo` reports
+whether the returned list is complete:
+
+- `returnedCount` — the number of runs in `flowRuns`.
+- `truncated` — `false` means `flowRuns` is the **complete** set matching the request; `true` means
+ more matching runs exist on the server than were returned.
+- `truncationReason` — present only when `truncated` is `true`:
+ - `"requested-limit"` — the caller's own `limit` cut the result short. Call again with a higher
+ `limit`.
+ - `"default-cap"` — the caller supplied **no `limit`** and **no admin cap** is configured, so the
+ tool returned the newest **100** runs as a safety backstop rather than walking the entire run
+ history (which accumulates quickly and would load the server). Pass a higher `limit` and/or a
+ narrower `filter` (e.g. a single `flowId` or a `startedAt` window) to go deeper.
+ - `"admin-cap"` — a site-administrator per-call cap
+ ([`MAX_RESULT_LIMIT`](../../configuration/mcp-config/env-vars.md#max_result_limit) or
+ `MAX_RESULT_LIMITS=list-flow-runs:N`) cut the result short. Narrow the `filter` (e.g. a tighter
+ `startedAt` window or a single `flowId`), or ask the administrator to raise the cap.
+
+Unlike [List Flows](list-flows.md), there is **no `totalAvailable`** — the Tableau Flow Runs
+endpoint does not return a total count, so completeness is reported via the `truncated` flag only
+(computed with a "+1 probe" — the tool fetches one more run than `limit` to detect that more exist).
+When `truncated` is `true`, report "at least N" — never invent a total.
+
+:::tip For client / LLM authors `mcp.resultInfo` is a signal **for the model**, not text to show the
+user. Translate it into one plain sentence — "These are all 12 matching runs" or "Here are the first
+50; more match" — and never surface the field names to the end user. :::
+
+## Bounded context (fail-closed)
+
+A flow run carries no project or tag, so when the server is restricted to a
+[`INCLUDE_PROJECT_IDS`](../../configuration/mcp-config/tool-scoping.md#include_project_ids) or
+[`INCLUDE_TAGS`](../../configuration/mcp-config/tool-scoping.md#include_tags) bounded context this tool **cannot** prove
+a run belongs to the allowed set. Rather than risk leaking runs for flows outside the allow-list, it
+returns no runs and explains why. To inspect a specific allowed flow's run history under a bounded
+context, use [Get Flow](get-flow.md) with the flow id (it enforces the bounded context on the flow
+itself).
+
+## Limitations
+
+- **No error details for `Failed` runs.** The `status` is available, but the underlying job error
+ message is not surfaced by the public Tableau REST API. Inspect the run in the Tableau UI.
+- **No exact total-run count.** The endpoint does not expose `totalAvailable`; the tool reports only
+ whether more runs exist via `truncated`.
+
+## Example result
+
+```json
+{
+ "flowRuns": [
+ {
+ "id": "a1a1a1a1-1111-1111-1111-111111111111",
+ "flowId": "d00700fe-28a0-4ece-a7af-5543ddf38a82",
+ "status": "Success",
+ "startedAt": "2025-04-01T10:00:00Z",
+ "completedAt": "2025-04-01T10:05:00Z",
+ "progress": 100,
+ "backgroundJobId": "b2b2b2b2-2222-2222-2222-222222222222"
+ }
+ ],
+ "mcp": {
+ "resultInfo": {
+ "returnedCount": 1,
+ "truncated": false
+ }
+ }
+}
+```
diff --git a/docs/docs/tools/flows/list-flow-tasks.md b/docs/docs/tools/flows/list-flow-tasks.md
new file mode 100644
index 000000000..87aa13bc2
--- /dev/null
+++ b/docs/docs/tools/flows/list-flow-tasks.md
@@ -0,0 +1,143 @@
+---
+sidebar_position: 4
+---
+
+# List Flow Tasks
+
+Retrieves the scheduled flow run tasks on a site. A flow run task is the **schedule** for a Tableau
+Prep flow — when and how often it is configured to run — **not** a record of past executions. For
+run history, use [List Flow Runs](list-flow-runs.md).
+
+Each task includes the target flow (`flow.id`, `flow.name`), the schedule (frequency, next run time,
+state), and the task `id` used to trigger an on-demand run.
+
+## APIs called
+
+- [Get Flow Run Tasks](https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#get_flow_run_tasks)
+
+## Required Tableau API scopes
+
+When the MCP server authenticates with OAuth (connected-app JWT), this tool requests:
+
+- `tableau:flow_tasks:read`
+- `tableau:mcp_site_settings:read`
+
+The `tableau:flow_tasks:read` scope was added in Tableau Cloud December 2025 / Server 2025.3. See
+[OAuth configuration](../../configuration/mcp-config/oauth.md) for how scopes are negotiated.
+
+## Caller-role visibility
+
+- **Non-admin callers** — Tableau returns the scheduled tasks only for flows the caller owns.
+- **Server / site-admin callers** — Tableau returns **every** scheduled flow task on the site.
+
+This tool is **not** admin-gated; any authenticated user can call it and will see the tasks Tableau
+exposes to them.
+
+## Optional arguments
+
+### `filter`
+
+A client-side filter string in the format `field:operator:value`. Multiple expressions are combined
+with a comma using a logical AND. The Tableau REST API does not support server-side filtering for
+this endpoint, so all tasks are fetched and filtered in-process.
+
+| Field | Type | Operators | Example |
+| ------------------------ | ----------------- | ---------------------- | --------------------------------------------- |
+| `id` | string | `eq, in` | `id:eq:1bff10bb-57ae-43df-8774-a86d14aef432` |
+| `type` | string | `eq, in` | `type:eq:RunFlowTask` |
+| `priority` | number | `eq, gt, gte, lt, lte` | `priority:gte:5` |
+| `consecutiveFailedCount` | number | `eq, gt, gte, lt, lte` | `consecutiveFailedCount:gt:0` |
+| `flow.id` | string | `eq, in` | `flow.id:eq:8a320dca-9151-41ea-8474-...` |
+| `flow.name` | string | `eq, in` | `flow.name:eq:Daily Sales` |
+| `schedule.id` | string | `eq, in` | `schedule.id:eq:36d6fab2-2a0a-...` |
+| `schedule.name` | string | `eq, in` | `schedule.name:eq:Daily Refresh` |
+| `schedule.state` | string | `eq, in` | `schedule.state:eq:Active` |
+| `schedule.frequency` | string | `eq, in` | `schedule.frequency:eq:Daily` |
+| `schedule.nextRunAt` | string (ISO 8601) | `eq, gt, gte, lt, lte` | `schedule.nextRunAt:lt:2026-05-25T00:00:00Z` |
+| `schedule.createdAt` | string (ISO 8601) | `eq, gt, gte, lt, lte` | `schedule.createdAt:gte:2026-01-01T00:00:00Z` |
+| `schedule.updatedAt` | string (ISO 8601) | `eq, gt, gte, lt, lte` | `schedule.updatedAt:gte:2026-05-01T00:00:00Z` |
+
+The `in` operator accepts a bracket/comma list (e.g. `schedule.state:in:[Active,Suspended]`),
+matching every other `list-*` tool; the legacy pipe-delimited form
+(`schedule.state:in:Active|Suspended`) is also accepted. Date-time values must be full ISO 8601
+(e.g. `2026-05-25T00:00:00Z`).
+
+Examples:
+
+- `schedule.frequency:eq:Daily,schedule.state:eq:Active`
+- `consecutiveFailedCount:gt:0` (schedules that are failing repeatedly)
+
+
+
+### `pageSize`
+
+The maximum number of results per page, applied client-side after filtering.
+
+### `limit`
+
+The maximum total number of tasks to return, applied client-side after filtering.
+
+## Response-size guidance
+
+The Tableau "Get Flow Run Tasks" endpoint has **no server-side filtering or pagination**, so the
+tool fetches **every** scheduled task on the site and then applies `filter`/`limit` in-process. On
+large sites that is a big, slow response, so the tool description steers the LLM toward the narrowest
+call that answers the question:
+
+| Question shape | Recommended arguments |
+| ----------------------------------- | ---------------------------------------------------------------- |
+| "When does flow X run next?" | `filter: "flow.id:eq:"` |
+| "Which schedules are failing?" | `filter: "consecutiveFailedCount:gt:0"` |
+| "Which daily schedules are active?" | `filter: "schedule.frequency:eq:Daily,schedule.state:eq:Active"` |
+| "Are any flows scheduled at all?" | a small `limit` (e.g. `10`) |
+
+## Bounded context (fail-closed)
+
+A flow run task carries the flow's id and name but no project or tag, so when the server is
+restricted to an [`INCLUDE_PROJECT_IDS`](../../configuration/mcp-config/tool-scoping.md#include_project_ids) or
+[`INCLUDE_TAGS`](../../configuration/mcp-config/tool-scoping.md#include_tags) bounded context this tool **cannot** prove
+a task's flow belongs to the allowed set. Rather than risk leaking schedules for flows outside the
+allow-list, it returns no tasks and explains why.
+
+## Result info
+
+The response is an object `{ flowTasks: [...], mcp: { resultInfo } }`. The `flowTasks` array holds one
+record per scheduled task; `mcp.resultInfo` is **always present** and reports whether that array is
+complete:
+
+- `returnedCount` — number of tasks in `flowTasks`.
+- `totalAvailable` — number of tasks matching the filter before any limit. Always exact here, because
+ the endpoint returns every task and filtering/limiting happen in-process.
+- `truncated` — `true` when a `limit`/`pageSize` or an admin `MAX_RESULT_LIMIT` cut the list short.
+- `truncationReason` — `requested-limit` (caller's `limit`/`pageSize`) or `admin-cap` (site limit), present only when `truncated` is `true`.
+
+## Example result
+
+```json
+{
+ "flowTasks": [
+ {
+ "id": "1bff10bb-57ae-43df-8774-a86d14aef432",
+ "priority": 50,
+ "consecutiveFailedCount": 2,
+ "type": "RunFlowTask",
+ "schedule": {
+ "id": "36d6fab2-2a0a-432e-b464-9fe4229a9937",
+ "name": "Every 2 Minutes",
+ "state": "Active",
+ "priority": 50,
+ "createdAt": "2018-11-08T21:57:49Z",
+ "updatedAt": "2018-11-09T17:30:08Z",
+ "type": "Flow",
+ "frequency": "Hourly",
+ "nextRunAt": "2018-11-09T17:32:00Z"
+ },
+ "flow": {
+ "id": "8a320dca-9151-41ea-8474-a0bb71961cc0",
+ "name": "allUseCaseTFLX2"
+ }
+ }
+ ],
+ "mcp": { "resultInfo": { "returnedCount": 1, "truncated": false, "totalAvailable": 1 } }
+}
+```
diff --git a/package-lock.json b/package-lock.json
index 0e0462e2f..d844f9bfe 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@tableau/mcp-server",
- "version": "3.5.4",
+ "version": "3.5.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@tableau/mcp-server",
- "version": "3.5.4",
+ "version": "3.5.5",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/ext-apps": "^1.7.2",
diff --git a/package.json b/package.json
index d1a1edc18..77f5c90b3 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "@tableau/mcp-server",
"description": "Helping agents see and understand data.",
- "version": "3.5.4",
+ "version": "3.5.5",
"repository": {
"type": "git",
"url": "git+https://github.com/tableau/tableau-mcp.git"
diff --git a/src/restApiInstance.ts b/src/restApiInstance.ts
index fc52ef9f7..f74d4b216 100644
--- a/src/restApiInstance.ts
+++ b/src/restApiInstance.ts
@@ -41,6 +41,7 @@ type JwtScopes =
| 'tableau:datasource_tags:update'
| 'tableau:datasources:delete'
| 'tableau:jobs:read'
+ | 'tableau:flow_tasks:read'
| 'tableau:users:read'
| 'tableau:users:update'
| 'tableau:flows:read'
diff --git a/src/sdks/tableau/apis/tasksApi.test.ts b/src/sdks/tableau/apis/tasksApi.test.ts
index 7af22115c..5582fa0e7 100644
--- a/src/sdks/tableau/apis/tasksApi.test.ts
+++ b/src/sdks/tableau/apis/tasksApi.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
-import { parseListExtractRefreshTasksResponse } from './tasksApi.js';
+import { parseGetFlowRunTasksResponse, parseListExtractRefreshTasksResponse } from './tasksApi.js';
describe('parseListExtractRefreshTasksResponse', () => {
it('accepts Tableau empty tasks: {} by normalizing to task: []', () => {
@@ -66,3 +66,26 @@ describe('parseListExtractRefreshTasksResponse', () => {
});
});
});
+
+describe('parseGetFlowRunTasksResponse', () => {
+ it('accepts Tableau empty tasks: {} by normalizing to task: []', () => {
+ const data = parseGetFlowRunTasksResponse({ tasks: {} });
+ expect(data.tasks).toEqual({ task: [] });
+ });
+
+ it('does not change responses that already include task as array', () => {
+ const entry = {
+ flowRun: { id: 't1', flow: { id: 'f1', name: 'Flow One' } },
+ };
+ const data = parseGetFlowRunTasksResponse({ tasks: { task: [entry] } });
+ expect(data.tasks).toEqual({ task: [entry] });
+ });
+
+ it('normalizes a single flowRun task object to an array', () => {
+ const entry = {
+ flowRun: { id: 't1', flow: { id: 'f1' } },
+ };
+ const data = parseGetFlowRunTasksResponse({ tasks: { task: entry } });
+ expect(data.tasks).toEqual({ task: [entry] });
+ });
+});
diff --git a/src/sdks/tableau/apis/tasksApi.ts b/src/sdks/tableau/apis/tasksApi.ts
index d9c0d2808..26776e994 100644
--- a/src/sdks/tableau/apis/tasksApi.ts
+++ b/src/sdks/tableau/apis/tasksApi.ts
@@ -6,11 +6,16 @@ import {
updateCloudExtractRefreshTaskRequestSchema,
updateCloudExtractRefreshTaskResponseSchema,
} from '../types/extractRefreshTask.js';
+import { flowRunTaskSchema } from '../types/flowRunTask.js';
const taskEntrySchema = z.object({
extractRefresh: extractRefreshTaskSchema,
});
+const flowRunTaskEntrySchema = z.object({
+ flowRun: flowRunTaskSchema,
+});
+
/**
* Tableau API response schema with transform to normalize different response shapes:
* - `{ tasks: { task: [...] } }` → normalized to `{ tasks: { task: [...] } }`
@@ -58,6 +63,58 @@ const listExtractRefreshTasksEndpoint = makeEndpoint({
response: listExtractRefreshTasksBodySchema,
});
+/**
+ * Tableau API response schema for "Get Flow Run Tasks", normalized the same way
+ * as {@link listExtractRefreshTasksBodySchema}:
+ * - `{ tasks: { task: [...] } }` → as-is
+ * - `{ tasks: { task: {...} } }` → wrapped in an array
+ * - `{ tasks: [...] }` → normalized to `{ tasks: { task: [...] } }`
+ * - `{ tasks: {} }` → normalized to `{ tasks: { task: [] } }`
+ */
+const getFlowRunTasksBodySchema = z.object({
+ tasks: z.union([
+ z.object({
+ task: z.union([
+ z.array(flowRunTaskEntrySchema),
+ flowRunTaskEntrySchema.transform((task) => [task]),
+ ]),
+ }),
+ z.array(flowRunTaskEntrySchema).transform((tasks) => ({ task: tasks })),
+ z.object({}).transform(() => ({ task: [] })),
+ ]),
+});
+
+export type GetFlowRunTasksBody = z.infer;
+
+/** Parse response using Zod schema with built-in transforms for normalization. */
+export function parseGetFlowRunTasksResponse(raw: unknown): GetFlowRunTasksBody {
+ return getFlowRunTasksBodySchema.parse(raw);
+}
+
+/**
+ * Get Flow Run Tasks
+ * GET /api/api-version/sites/site-id/tasks/runFlow
+ * Returns the list of scheduled flow run tasks for the site. Each task describes
+ * the schedule for a flow (frequency, next run time) plus the flow it targets.
+ * Tableau Cloud scope: tableau:flow_tasks:read
+ * @see https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#get_flow_run_tasks
+ */
+const getFlowRunTasksEndpoint = makeEndpoint({
+ method: 'get',
+ path: '/sites/:siteId/tasks/runFlow',
+ alias: 'getFlowRunTasks',
+ description:
+ 'Returns the list of scheduled flow run tasks for the site. Each task includes the flow it targets and schedule information (frequency, next run time).',
+ parameters: [
+ {
+ name: 'siteId',
+ type: 'Path',
+ schema: z.string(),
+ },
+ ],
+ response: getFlowRunTasksBodySchema,
+});
+
/**
* Delete Extract Refresh Task
* DELETE /api/api-version/sites/site-id/tasks/extractRefreshes/task-id
@@ -121,6 +178,7 @@ const updateCloudExtractRefreshTaskEndpoint = makeEndpoint({
const tasksApi = makeApi([
listExtractRefreshTasksEndpoint,
+ getFlowRunTasksEndpoint,
deleteExtractRefreshTaskEndpoint,
updateCloudExtractRefreshTaskEndpoint,
]);
diff --git a/src/sdks/tableau/methods/tasksMethods.test.ts b/src/sdks/tableau/methods/tasksMethods.test.ts
index 1f18c0c68..ae6c2e8a1 100644
--- a/src/sdks/tableau/methods/tasksMethods.test.ts
+++ b/src/sdks/tableau/methods/tasksMethods.test.ts
@@ -261,4 +261,70 @@ describe('TasksMethods', () => {
}
});
});
+
+ describe('getFlowRunTasks', () => {
+ it('should handle object with task array format', async () => {
+ const mockApiClient = {
+ getFlowRunTasks: vi.fn().mockResolvedValue({
+ tasks: {
+ task: [
+ {
+ flowRun: {
+ id: 't1',
+ type: 'RunFlowTask',
+ flow: { id: 'f1', name: 'Flow One' },
+ schedule: { id: 's1', name: 'Hourly', frequency: 'Hourly' },
+ },
+ },
+ {
+ flowRun: { id: 't2', flow: { id: 'f2' } },
+ },
+ ],
+ },
+ }),
+ };
+
+ const tasksMethods = new TasksMethods('http://test', { type: 'Bearer', token: 'test' }, {});
+ // @ts-expect-error - Mocking private property
+ tasksMethods._apiClient = mockApiClient;
+
+ const result = await tasksMethods.getFlowRunTasks({ siteId: 'site-1' });
+
+ expect(result).toHaveLength(2);
+ expect(result[0]).toMatchObject({
+ id: 't1',
+ flow: { id: 'f1', name: 'Flow One' },
+ schedule: { name: 'Hourly', frequency: 'Hourly' },
+ });
+ expect(result[1]).toMatchObject({ id: 't2', flow: { id: 'f2' } });
+ });
+
+ it('should normalize a single task object into an array', async () => {
+ const mockApiClient = {
+ getFlowRunTasks: vi.fn().mockResolvedValue({
+ tasks: { task: { flowRun: { id: 't1', flow: { id: 'f1' } } } },
+ }),
+ };
+
+ const tasksMethods = new TasksMethods('http://test', { type: 'Bearer', token: 'test' }, {});
+ // @ts-expect-error - Mocking private property
+ tasksMethods._apiClient = mockApiClient;
+
+ const result = await tasksMethods.getFlowRunTasks({ siteId: 'site-1' });
+ expect(result).toEqual([{ id: 't1', flow: { id: 'f1' } }]);
+ });
+
+ it('should handle an empty tasks object', async () => {
+ const mockApiClient = {
+ getFlowRunTasks: vi.fn().mockResolvedValue({ tasks: {} }),
+ };
+
+ const tasksMethods = new TasksMethods('http://test', { type: 'Bearer', token: 'test' }, {});
+ // @ts-expect-error - Mocking private property
+ tasksMethods._apiClient = mockApiClient;
+
+ const result = await tasksMethods.getFlowRunTasks({ siteId: 'site-1' });
+ expect(result).toEqual([]);
+ });
+ });
});
diff --git a/src/sdks/tableau/methods/tasksMethods.ts b/src/sdks/tableau/methods/tasksMethods.ts
index 74845885c..41e769513 100644
--- a/src/sdks/tableau/methods/tasksMethods.ts
+++ b/src/sdks/tableau/methods/tasksMethods.ts
@@ -3,13 +3,18 @@ import { Err, Ok, Result } from 'ts-results-es';
import { AxiosRequestConfig } from '../../../utils/axios.js';
import { getExceptionMessage } from '../../../utils/getExceptionMessage.js';
-import { parseListExtractRefreshTasksResponse, tasksApis } from '../apis/tasksApi.js';
+import {
+ parseGetFlowRunTasksResponse,
+ parseListExtractRefreshTasksResponse,
+ tasksApis,
+} from '../apis/tasksApi.js';
import { RestApiCredentials } from '../restApi.js';
import { parseTableauApiError } from '../tableauApiError.js';
import {
ExtractRefreshTask,
UpdateCloudExtractRefreshSchedule,
} from '../types/extractRefreshTask.js';
+import { FlowRunTask } from '../types/flowRunTask.js';
import AuthenticatedMethods from './authenticatedMethods.js';
/**
@@ -128,4 +133,26 @@ export default class TasksMethods extends AuthenticatedMethods
return new Err({ type: 'unknown', message: getExceptionMessage(error) });
}
};
+
+ /**
+ * Returns the list of scheduled flow run tasks for the site.
+ * Each task describes the schedule for a flow (frequency, next run time) plus
+ * the flow it targets.
+ *
+ * Required scopes (Tableau Cloud): `tableau:flow_tasks:read`
+ *
+ * Permissions: non-administrators see only the scheduled flow run tasks for
+ * flows they own; administrators see all flow run tasks on the site.
+ *
+ * @param siteId - The Tableau site ID
+ * @link https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_flow.htm#get_flow_run_tasks
+ */
+ getFlowRunTasks = async ({ siteId }: { siteId: string }): Promise => {
+ const raw = await this._apiClient.getFlowRunTasks({
+ params: { siteId },
+ ...this.authHeader,
+ });
+ const response = parseGetFlowRunTasksResponse(raw);
+ return response.tasks.task.map((t) => t.flowRun);
+ };
}
diff --git a/src/sdks/tableau/types/flowRunTask.ts b/src/sdks/tableau/types/flowRunTask.ts
new file mode 100644
index 000000000..318487200
--- /dev/null
+++ b/src/sdks/tableau/types/flowRunTask.ts
@@ -0,0 +1,31 @@
+import { z } from 'zod';
+
+import { extractRefreshScheduleSchema } from './extractRefreshTask.js';
+
+/**
+ * A scheduled flow run task as returned by "Get Flow Run Tasks"
+ * (GET /sites/:siteId/tasks/runFlow).
+ *
+ * This is the *schedule* for a flow (when/how often it is configured to run),
+ * NOT a record of an individual execution — that is a flow run (see
+ * {@link FlowRun}). Each task is keyed by the flow run task id (the `id` below),
+ * which the "Run Flow Now" endpoint consumes as its `task-id`.
+ *
+ * The schedule sub-object has the same shape Tableau returns for extract refresh
+ * tasks, so we reuse {@link extractRefreshScheduleSchema} rather than duplicate it.
+ */
+export const flowRunTaskSchema = z.object({
+ id: z.string(),
+ priority: z.coerce.number().optional(),
+ consecutiveFailedCount: z.coerce.number().optional(),
+ type: z.string().optional(),
+ schedule: extractRefreshScheduleSchema.optional(),
+ flow: z
+ .object({
+ id: z.string(),
+ name: z.string().optional(),
+ })
+ .optional(),
+});
+
+export type FlowRunTask = z.infer;
diff --git a/src/server.web.test.ts b/src/server.web.test.ts
index 5738fddfd..9ca12d15e 100644
--- a/src/server.web.test.ts
+++ b/src/server.web.test.ts
@@ -140,6 +140,8 @@ describe('server', () => {
// Flow tools are gated off by default...
expect(registeredToolNames).not.toContain('list-flows');
expect(registeredToolNames).not.toContain('get-flow');
+ expect(registeredToolNames).not.toContain('list-flow-runs');
+ expect(registeredToolNames).not.toContain('list-flow-tasks');
// ...while unrelated tools stay registered.
expect(registeredToolNames).toContain('list-datasources');
});
@@ -156,6 +158,8 @@ describe('server', () => {
// The single switch turns on every flow tool...
expect(registeredToolNames).toContain('list-flows');
expect(registeredToolNames).toContain('get-flow');
+ expect(registeredToolNames).toContain('list-flow-runs');
+ expect(registeredToolNames).toContain('list-flow-tasks');
// ...alongside the unrelated tools.
expect(registeredToolNames).toContain('list-datasources');
});
diff --git a/src/server/oauth/scopes.ts b/src/server/oauth/scopes.ts
index fed868f59..4d7d3d7d5 100644
--- a/src/server/oauth/scopes.ts
+++ b/src/server/oauth/scopes.ts
@@ -53,6 +53,7 @@ export type TableauApiScope =
| 'tableau:datasource_tags:update'
| 'tableau:datasources:delete'
| 'tableau:jobs:read'
+ | 'tableau:flow_tasks:read'
| 'tableau:users:read'
| 'tableau:users:update';
@@ -120,6 +121,18 @@ export const GET_FLOW_BASE_API_SCOPES: ReadonlyArray = [
export const GET_FLOW_CONNECTIONS_API_SCOPE: TableauApiScope = 'tableau:flow_connections:read';
export const GET_FLOW_RUNS_API_SCOPE: TableauApiScope = 'tableau:flow_runs:read';
+/**
+ * Per-call scopes for `list-flow-runs`. The run fetch is the primary result and
+ * must not request the optional Query Flow scope used only to build a
+ * best-effort failure-insight link. The static tool scope map remains the
+ * maximum surface and is composed from these constants.
+ */
+export const LIST_FLOW_RUNS_PRIMARY_API_SCOPES: ReadonlyArray = [
+ 'tableau:flow_runs:read',
+ 'tableau:mcp_site_settings:read',
+];
+export const LIST_FLOW_RUNS_FAILURE_INSIGHT_API_SCOPE: TableauApiScope = 'tableau:flows:read';
+
/**
* Validates that a scope string is a valid MCP scope
*/
@@ -194,6 +207,17 @@ const toolScopeMap: Record<
GET_FLOW_RUNS_API_SCOPE,
]),
},
+ 'list-flow-runs': {
+ mcp: ['tableau:mcp:flow:read'],
+ // `flows:read` is needed in addition to `flow_runs:read` because, when the
+ // returned window contains a Failed run, the tool resolves one flow's
+ // `webpageUrl` (Query Flow) to build a run-history deep link for the caller.
+ api: new Set([...LIST_FLOW_RUNS_PRIMARY_API_SCOPES, LIST_FLOW_RUNS_FAILURE_INSIGHT_API_SCOPE]),
+ },
+ 'list-flow-tasks': {
+ mcp: ['tableau:mcp:flow:read'],
+ api: new Set(['tableau:flow_tasks:read', 'tableau:mcp_site_settings:read']),
+ },
'query-datasource': {
mcp: ['tableau:mcp:datasource:read'],
api: new Set(['tableau:viz_data_service:read', ...RESOURCE_ACCESS_CHECKER_REQUIRED_API_SCOPES]),
@@ -378,6 +402,8 @@ async function getEnabledToolNames(): Promise> {
if (!config.flowToolsEnabled) {
enabledTools.delete('list-flows');
enabledTools.delete('get-flow');
+ enabledTools.delete('list-flow-runs');
+ enabledTools.delete('list-flow-tasks');
}
return enabledTools;
diff --git a/src/tools/web/extractRefreshTasks/extractRefreshTasksFilterUtils.test.ts b/src/tools/web/extractRefreshTasks/extractRefreshTasksFilterUtils.test.ts
index cd2ab1be2..6812b5536 100644
--- a/src/tools/web/extractRefreshTasks/extractRefreshTasksFilterUtils.test.ts
+++ b/src/tools/web/extractRefreshTasks/extractRefreshTasksFilterUtils.test.ts
@@ -189,6 +189,12 @@ describe('extractRefreshTasksFilterUtils', () => {
expect(result[1].id).toBe('task-456');
});
+ it('should keep a bracketed in list intact when combined with another filter', () => {
+ const result = applyTaskFilters(tasks, 'id:in:[task-123,task-456],priority:gte:5');
+
+ expect(result.map(({ id }) => id)).toEqual(['task-123', 'task-456']);
+ });
+
it('should return empty array when no tasks match', () => {
const result = applyTaskFilters(tasks, 'priority:gt:100');
expect(result).toHaveLength(0);
diff --git a/src/tools/web/extractRefreshTasks/extractRefreshTasksFilterUtils.ts b/src/tools/web/extractRefreshTasks/extractRefreshTasksFilterUtils.ts
index 497caceb7..75d1648d6 100644
--- a/src/tools/web/extractRefreshTasks/extractRefreshTasksFilterUtils.ts
+++ b/src/tools/web/extractRefreshTasks/extractRefreshTasksFilterUtils.ts
@@ -1,6 +1,10 @@
import { z } from 'zod';
import { ExtractRefreshTask } from '../../../sdks/tableau/types/extractRefreshTask.js';
+import {
+ applyClientSideFilters,
+ matchesClientSideFilter,
+} from '../../../utils/clientSideFilter.js';
import {
FilterOperator,
FilterOperatorSchema,
@@ -68,26 +72,11 @@ export function applyTaskFilters(
tasks: ExtractRefreshTask[],
filterString: string | undefined,
): ExtractRefreshTask[] {
- if (!filterString) {
- return tasks;
- }
-
- // Parse and validate the filter string
- const validatedFilter = parseAndValidateExtractRefreshTasksFilterString(filterString);
- const filters = validatedFilter.split(',').map((f) => {
- const [field, operator, ...valueParts] = f.split(':');
- return {
- field: field as FilterField,
- operator: operator as FilterOperator,
- value: valueParts.join(':'),
- };
- });
-
- return tasks.filter((task) => {
- return filters.every(({ field, operator, value }) => {
- const fieldValue = getFieldValue(task, field);
- return matchesFilter(fieldValue, operator, value);
- });
+ return applyClientSideFilters({
+ items: tasks,
+ filterString,
+ validateFilterString: parseAndValidateExtractRefreshTasksFilterString,
+ getFieldValue,
});
}
@@ -124,46 +113,9 @@ function getFieldValue(task: ExtractRefreshTask, field: FilterField): string | n
}
}
-function matchesFilter(
- fieldValue: string | number | undefined,
- operator: FilterOperator,
- filterValue: string,
-): boolean {
- if (fieldValue === undefined || fieldValue === null) {
- return false;
- }
-
- const fieldStr = String(fieldValue);
-
- switch (operator) {
- case 'eq':
- return fieldStr === filterValue;
- case 'in':
- return filterValue.split('|').includes(fieldStr);
- case 'gt':
- return typeof fieldValue === 'number'
- ? fieldValue > Number(filterValue)
- : fieldStr > filterValue;
- case 'gte':
- return typeof fieldValue === 'number'
- ? fieldValue >= Number(filterValue)
- : fieldStr >= filterValue;
- case 'lt':
- return typeof fieldValue === 'number'
- ? fieldValue < Number(filterValue)
- : fieldStr < filterValue;
- case 'lte':
- return typeof fieldValue === 'number'
- ? fieldValue <= Number(filterValue)
- : fieldStr <= filterValue;
- default:
- return false;
- }
-}
-
export const exportedForTesting = {
FilterFieldSchema,
applyTaskFilters,
getFieldValue,
- matchesFilter,
+ matchesFilter: matchesClientSideFilter,
};
diff --git a/src/tools/web/flows/listFlowRuns/flowRunsFilterUtils.test.ts b/src/tools/web/flows/listFlowRuns/flowRunsFilterUtils.test.ts
new file mode 100644
index 000000000..14485fae0
--- /dev/null
+++ b/src/tools/web/flows/listFlowRuns/flowRunsFilterUtils.test.ts
@@ -0,0 +1,64 @@
+import { FlowRun } from '../../../../sdks/tableau/types/flow.js';
+import { parseAndValidateFlowRunsFilterString } from './flowRunsFilterUtils.js';
+
+const FLOW_ID = 'd00700fe-28a0-4ece-a7af-5543ddf38a82';
+
+function run(status: FlowRun['status']): FlowRun {
+ return { id: 'r', flowId: FLOW_ID, status };
+}
+
+describe('parseAndValidateFlowRunsFilterString', () => {
+ it('keeps server-side fields in serverFilter and accepts all statuses when no status clause', () => {
+ const { serverFilter, matchesStatus } = parseAndValidateFlowRunsFilterString(
+ `flowId:eq:${FLOW_ID}`,
+ );
+ expect(serverFilter).toBe(`flowId:eq:${FLOW_ID}`);
+ expect(matchesStatus(run('Success'))).toBe(true);
+ expect(matchesStatus(run('Failed'))).toBe(true);
+ });
+
+ it('strips status into a client-side predicate (eq)', () => {
+ const { serverFilter, matchesStatus } =
+ parseAndValidateFlowRunsFilterString('status:eq:Failed');
+ expect(serverFilter).toBe('');
+ expect(matchesStatus(run('Failed'))).toBe(true);
+ expect(matchesStatus(run('Success'))).toBe(false);
+ });
+
+ it('strips status into a client-side predicate (in:[...])', () => {
+ const { serverFilter, matchesStatus } = parseAndValidateFlowRunsFilterString(
+ `flowId:eq:${FLOW_ID},status:in:[Failed,Cancelled]`,
+ );
+ expect(serverFilter).toBe(`flowId:eq:${FLOW_ID}`);
+ expect(matchesStatus(run('Failed'))).toBe(true);
+ expect(matchesStatus(run('Cancelled'))).toBe(true);
+ expect(matchesStatus(run('Success'))).toBe(false);
+ });
+
+ it('treats a run with no status as non-matching when a status filter is present', () => {
+ const { matchesStatus } = parseAndValidateFlowRunsFilterString('status:eq:Success');
+ expect(matchesStatus({ id: 'r' })).toBe(false);
+ });
+
+ it('normalizes date-only startedAt/completedAt to midnight UTC', () => {
+ const { serverFilter } = parseAndValidateFlowRunsFilterString(
+ 'startedAt:gt:2025-01-01,completedAt:lt:2025-02-01',
+ );
+ expect(serverFilter).toContain('startedAt:gt:2025-01-01T00:00:00Z');
+ expect(serverFilter).toContain('completedAt:lt:2025-02-01T00:00:00Z');
+ });
+
+ it('rejects an unknown status value', () => {
+ expect(() => parseAndValidateFlowRunsFilterString('status:eq:Nope')).toThrow(
+ /Allowed flow-run/,
+ );
+ });
+
+ it('rejects a disallowed operator for status', () => {
+ expect(() => parseAndValidateFlowRunsFilterString('status:gt:Failed')).toThrow();
+ });
+
+ it('rejects an unknown field', () => {
+ expect(() => parseAndValidateFlowRunsFilterString('owner:eq:x')).toThrow();
+ });
+});
diff --git a/src/tools/web/flows/listFlowRuns/flowRunsFilterUtils.ts b/src/tools/web/flows/listFlowRuns/flowRunsFilterUtils.ts
new file mode 100644
index 000000000..190b39183
--- /dev/null
+++ b/src/tools/web/flows/listFlowRuns/flowRunsFilterUtils.ts
@@ -0,0 +1,142 @@
+import { z } from 'zod';
+
+import { FlowRun, flowRunStatusSchema } from '../../../../sdks/tableau/types/flow.js';
+import {
+ FilterOperator,
+ FilterOperatorSchema,
+ parseAndValidateFilterString,
+ splitTopLevel,
+} from '../../../../utils/parseAndValidateFilterString.js';
+
+// The Tableau "Get Flow Runs" endpoint (GET /sites/:siteId/flows/runs) supports
+// server-side filtering on the fields below. `status` is the ONE exception: it
+// is NOT a server-side filter field (live-verified against REST 3.30 — passing
+// `status:eq:Failed` is ignored by the server), so this tool fetches runs with
+// the server-side fields applied and filters `status` client-side. We still
+// validate `status` here (fields + values) so a typo surfaces as a clear error
+// rather than a silent no-op.
+//
+// Field/operator allow-lists mirror the official spec at
+// https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_concepts_filtering_and_sorting.htm
+const SERVER_FILTER_FIELDS = ['flowId', 'userId', 'progress', 'startedAt', 'completedAt'] as const;
+
+const FilterFieldSchema = z.enum([...SERVER_FILTER_FIELDS, 'status']);
+
+type FilterField = z.infer;
+
+const allowedOperatorsByField: Record = {
+ flowId: ['eq', 'in'],
+ userId: ['eq', 'in'],
+ progress: ['eq', 'gt', 'gte', 'lt', 'lte'],
+ startedAt: ['eq', 'gt', 'gte', 'lt', 'lte'],
+ completedAt: ['eq', 'gt', 'gte', 'lt', 'lte'],
+ status: ['eq', 'in'],
+};
+
+const _FilterExpressionSchema = z.object({
+ field: FilterFieldSchema,
+ operator: FilterOperatorSchema,
+ value: z.string(),
+});
+
+type FilterExpression = z.infer;
+
+export type ValidatedFlowRunsFilter = {
+ /**
+ * The portion of the filter that the Tableau API can apply server-side (the
+ * `status` clause, if any, has been removed). May be an empty string.
+ */
+ serverFilter: string;
+ /**
+ * Predicate that enforces the client-side `status` clause. When the caller
+ * supplied no `status` filter this is the identity predicate (always `true`).
+ */
+ matchesStatus: (run: FlowRun) => boolean;
+ /** The full normalized filter (server + status) — used for empty-result hints. */
+ normalizedFilter: string;
+};
+
+/**
+ * Validate a flow-runs filter string and split it into the server-side portion
+ * (passed to the REST API) and a client-side `status` predicate.
+ *
+ * @throws on unknown fields, disallowed operators, malformed brackets, bad
+ * date-times (startedAt/completedAt), or an unrecognized `status` value.
+ */
+export function parseAndValidateFlowRunsFilterString(
+ filterString: string,
+): ValidatedFlowRunsFilter {
+ // Validates fields/operators, normalizes date-only values for
+ // startedAt/completedAt, and dedupes repeated fields (last one wins).
+ const normalizedFilter = parseAndValidateFilterString({
+ filterString,
+ allowedOperatorsByField,
+ filterFieldSchema: FilterFieldSchema,
+ });
+
+ const serverClauses: string[] = [];
+ let statusClause: { operator: FilterOperator; values: string[] } | undefined;
+
+ for (const clause of splitTopLevel(normalizedFilter, ',')
+ .map((c) => c.trim())
+ .filter(Boolean)) {
+ const [field, operator, ...valueParts] = clause.split(':');
+ const value = valueParts.join(':');
+ if (field === 'status') {
+ const values = parseListOrSingle(operator as FilterOperator, value);
+ assertValidStatusValues(values);
+ statusClause = { operator: operator as FilterOperator, values };
+ } else {
+ serverClauses.push(clause);
+ }
+ }
+
+ return {
+ serverFilter: serverClauses.join(','),
+ matchesStatus: buildStatusMatcher(statusClause),
+ normalizedFilter,
+ };
+}
+
+/**
+ * Expand an operator value into the list of values to match against. For `in`
+ * the Tableau-style bracket/comma form `[A,B,C]` is unwrapped; for `eq` the
+ * single value is returned as-is.
+ */
+function parseListOrSingle(operator: FilterOperator, value: string): string[] {
+ if (operator !== 'in') {
+ return [value];
+ }
+ const inner = value.startsWith('[') && value.endsWith(']') ? value.slice(1, -1) : value;
+ return inner
+ .split(',')
+ .map((v) => v.trim())
+ .filter(Boolean);
+}
+
+function assertValidStatusValues(values: string[]): void {
+ const allowed = flowRunStatusSchema.options;
+ for (const value of values) {
+ if (!(allowed as readonly string[]).includes(value)) {
+ throw new Error(
+ `Invalid status value '${value}'. Allowed flow-run statuses: ${allowed.join(', ')}.`,
+ );
+ }
+ }
+}
+
+function buildStatusMatcher(
+ statusClause: { operator: FilterOperator; values: string[] } | undefined,
+): (run: FlowRun) => boolean {
+ if (!statusClause) {
+ return () => true;
+ }
+ const allowed = new Set(statusClause.values);
+ return (run) => run.status !== undefined && allowed.has(run.status);
+}
+
+export const exportedForTesting = {
+ FilterFieldSchema,
+ parseListOrSingle,
+ buildStatusMatcher,
+};
diff --git a/src/tools/web/flows/listFlowRuns/listFlowRuns.test.ts b/src/tools/web/flows/listFlowRuns/listFlowRuns.test.ts
new file mode 100644
index 000000000..c2a4204fa
--- /dev/null
+++ b/src/tools/web/flows/listFlowRuns/listFlowRuns.test.ts
@@ -0,0 +1,703 @@
+import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
+
+import { useRestApi } from '../../../../restApiInstance.js';
+import { FlowRun } from '../../../../sdks/tableau/types/flow.js';
+import { WebMcpServer } from '../../../../server.web.js';
+import { stubDefaultEnvVars } from '../../../../testShared.js';
+import invariant from '../../../../utils/invariant.js';
+import { Provider } from '../../../../utils/provider.js';
+import { getMockRequestHandlerExtra } from '../../toolContext.mock.js';
+import { constrainFlowRuns, getListFlowRunsTool } from './listFlowRuns.js';
+import { mockFlowRuns } from './mockFlowRuns.js';
+
+const mocks = vi.hoisted(() => ({
+ mockGetFlowRuns: vi.fn(),
+ mockQueryFlow: vi.fn(),
+ mockVersionIsAtLeast: vi.fn((_version: `${number}.${number}`): boolean => true),
+ rejectFlowsRead: false,
+}));
+
+vi.mock('../../../../sdks/tableau/restApi.js', async () => {
+ const actual = await vi.importActual(
+ '../../../../sdks/tableau/restApi.js',
+ );
+ return {
+ ...actual,
+ RestApi: {
+ ...actual.RestApi,
+ versionIsAtLeast: (version: `${number}.${number}`) => mocks.mockVersionIsAtLeast(version),
+ },
+ };
+});
+
+vi.mock('../../../../restApiInstance.js', () => ({
+ useRestApi: vi.fn().mockImplementation(async ({ callback, jwtScopes }) => {
+ if (mocks.rejectFlowsRead && jwtScopes.includes('tableau:flows:read')) {
+ throw new Error('Connected app does not grant tableau:flows:read');
+ }
+ return callback({
+ flowsMethods: {
+ getFlowRuns: mocks.mockGetFlowRuns,
+ queryFlow: mocks.mockQueryFlow,
+ },
+ siteId: 'test-site-id',
+ });
+ }),
+}));
+
+vi.mock('../../../../config.js', () => ({
+ getConfig: vi.fn(() => ({
+ flowToolsEnabled: true,
+ productTelemetryEnabled: false,
+ productTelemetryEndpoint: 'https://test.com',
+ server: 'https://test.tableau.com',
+ })),
+}));
+
+const FLOW_ID = 'd00700fe-28a0-4ece-a7af-5543ddf38a82';
+
+function buildRuns(count: number, status = 'Success', startIndex = 0): FlowRun[] {
+ return Array.from({ length: count }, (_, i) => {
+ const n = startIndex + i;
+ return {
+ id: `run-${n.toString().padStart(8, '0')}-0000-0000-0000-000000000000`,
+ flowId: FLOW_ID,
+ status: status as FlowRun['status'],
+ startedAt: `2025-01-${String((n % 28) + 1).padStart(2, '0')}T10:00:00Z`,
+ progress: 100,
+ };
+ });
+}
+
+const NO_BOUNDED_CONTEXT = {
+ projectIds: null,
+ datasourceIds: null,
+ workbookIds: null,
+ viewIds: null,
+ tags: null,
+} as const;
+
+const FLOW_WEBPAGE_URL = 'https://my.tableau.example.com/#/site/mysite/flows/96151';
+
+describe('listFlowRunsTool', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.mockVersionIsAtLeast.mockReturnValue(true);
+ mocks.rejectFlowsRead = false;
+ // Default: the failure-insight resolver finds a flow with a webpageUrl.
+ mocks.mockQueryFlow.mockResolvedValue({
+ flow: { id: FLOW_ID, name: 'Daily Flow', webpageUrl: FLOW_WEBPAGE_URL },
+ outputSteps: [],
+ });
+ });
+
+ it('should create a tool instance with correct properties', () => {
+ const tool = getListFlowRunsTool(new WebMcpServer());
+ expect(tool.name).toBe('list-flow-runs');
+ expect(tool.description).toContain('run history');
+ expect(tool.paramsSchema).toMatchObject({ filter: expect.any(Object) });
+ });
+
+ it('should be enabled when flow tools are turned on', async () => {
+ const tool = getListFlowRunsTool(new WebMcpServer());
+ expect(await Provider.from(tool.disabled)).toBe(false);
+ });
+
+ it('should be disabled when flow tools are not enabled', async () => {
+ const { getConfig } = await import('../../../../config.js');
+ vi.mocked(getConfig).mockReturnValueOnce({
+ flowToolsEnabled: false,
+ } as ReturnType);
+ const tool = getListFlowRunsTool(new WebMcpServer());
+ expect(await Provider.from(tool.disabled)).toBe(true);
+ });
+
+ it('lists flow runs with the default completedAt:desc sort and the server filter', async () => {
+ mocks.mockGetFlowRuns.mockResolvedValue(mockFlowRuns);
+ const result = await getToolResult({ filter: `flowId:eq:${FLOW_ID}` });
+ expect(result.isError).toBe(false);
+ invariant(result.content[0].type === 'text');
+ const payload = JSON.parse(result.content[0].text);
+ expect(payload.flowRuns).toHaveLength(mockFlowRuns.length);
+ expect(payload.mcp.resultInfo).toEqual({
+ returnedCount: mockFlowRuns.length,
+ truncated: false,
+ });
+ // Default sort is completedAt:desc — startedAt:desc floats every never-started
+ // run (e.g. Cancelled-before-start) to the front and returns stale results.
+ expect(mocks.mockGetFlowRuns).toHaveBeenCalledWith({
+ siteId: 'test-site-id',
+ filter: `flowId:eq:${FLOW_ID}`,
+ sort: 'completedAt:desc',
+ pageSize: 100,
+ pageNumber: 1,
+ });
+ });
+
+ it('orders the default result newest-first by completedAt, falling back to startedAt, nulls last', async () => {
+ // Server returns rows in the endpoint's empty-key-first order (the anomalous
+ // no-completedAt Success up top, Pending with neither timestamp mixed in).
+ const scrambled: FlowRun[] = [
+ // anomalous: only startedAt, dated long ago → should sink below today's runs
+ {
+ id: 'd0000000-0000-0000-0000-000000000000',
+ status: 'Success',
+ startedAt: '2025-09-27T17:45:19Z',
+ progress: 100,
+ },
+ // Pending: neither timestamp → sorts LAST
+ { id: 'e0000000-0000-0000-0000-000000000000', status: 'Pending', progress: 0 },
+ // completed today (older of the two completions)
+ {
+ id: 'a0000000-0000-0000-0000-000000000000',
+ status: 'Success',
+ startedAt: '2026-06-11T18:54:04Z',
+ completedAt: '2026-06-11T18:54:17Z',
+ progress: 100,
+ },
+ // InProgress: startedAt is the newest activity of all → sorts FIRST
+ {
+ id: 'c0000000-0000-0000-0000-000000000000',
+ status: 'InProgress',
+ startedAt: '2026-06-11T19:30:00Z',
+ progress: 42,
+ },
+ // most recently completed
+ {
+ id: 'b0000000-0000-0000-0000-000000000000',
+ status: 'Cancelled',
+ completedAt: '2026-06-11T19:01:10Z',
+ progress: 100,
+ },
+ ];
+ mocks.mockGetFlowRuns.mockResolvedValue(scrambled);
+
+ const result = await getToolResult({});
+ expect(result.isError).toBe(false);
+ invariant(result.content[0].type === 'text');
+ const payload = JSON.parse(result.content[0].text);
+
+ expect(payload.flowRuns.map((r: FlowRun) => r.id)).toEqual([
+ 'c0000000-0000-0000-0000-000000000000', // 2026-06-11 19:30 (startedAt, InProgress)
+ 'b0000000-0000-0000-0000-000000000000', // 2026-06-11 19:01 (completedAt)
+ 'a0000000-0000-0000-0000-000000000000', // 2026-06-11 18:54 (completedAt)
+ 'd0000000-0000-0000-0000-000000000000', // 2025-09-27 (startedAt only)
+ 'e0000000-0000-0000-0000-000000000000', // Pending — neither timestamp, last
+ ]);
+ });
+
+ it('preserves server order (no recency re-sort) when an explicit sort is supplied', async () => {
+ const serverOrder: FlowRun[] = [
+ {
+ id: 'd0000000-0000-0000-0000-000000000000',
+ status: 'Success',
+ startedAt: '2025-09-27T17:45:19Z',
+ progress: 100,
+ },
+ {
+ id: 'b0000000-0000-0000-0000-000000000000',
+ status: 'Cancelled',
+ completedAt: '2026-06-11T19:01:10Z',
+ progress: 100,
+ },
+ {
+ id: 'a0000000-0000-0000-0000-000000000000',
+ status: 'Success',
+ startedAt: '2026-06-11T18:54:04Z',
+ completedAt: '2026-06-11T18:54:17Z',
+ progress: 100,
+ },
+ ];
+ mocks.mockGetFlowRuns.mockResolvedValue(serverOrder);
+
+ const result = await getToolResult({ sort: 'startedAt:asc' });
+ expect(result.isError).toBe(false);
+ invariant(result.content[0].type === 'text');
+ const payload = JSON.parse(result.content[0].text);
+
+ // Explicit sort is honored as-is: the tool must NOT re-order client-side.
+ expect(payload.flowRuns.map((r: FlowRun) => r.id)).toEqual(serverOrder.map((r) => r.id));
+ expect(mocks.mockGetFlowRuns).toHaveBeenCalledWith(
+ expect.objectContaining({ sort: 'startedAt:asc' }),
+ );
+ });
+
+ it('passes a caller-supplied sort through to the API', async () => {
+ mocks.mockGetFlowRuns.mockResolvedValue(mockFlowRuns);
+ await getToolResult({ sort: 'completedAt:asc' });
+ expect(mocks.mockGetFlowRuns).toHaveBeenCalledWith(
+ expect.objectContaining({ sort: 'completedAt:asc' }),
+ );
+ });
+
+ it('applies the status filter client-side and strips it from the server filter', async () => {
+ mocks.mockGetFlowRuns.mockResolvedValue(mockFlowRuns);
+ const result = await getToolResult({ filter: `flowId:eq:${FLOW_ID},status:eq:Failed` });
+ expect(result.isError).toBe(false);
+ invariant(result.content[0].type === 'text');
+ const payload = JSON.parse(result.content[0].text);
+
+ expect(payload.flowRuns).toHaveLength(1);
+ expect(payload.flowRuns[0].status).toBe('Failed');
+ // status must NOT be sent to the server (it isn't a server-side filter field).
+ expect(mocks.mockGetFlowRuns).toHaveBeenCalledWith(
+ expect.objectContaining({ filter: `flowId:eq:${FLOW_ID}` }),
+ );
+ });
+
+ it('supports status:in:[...] client-side', async () => {
+ mocks.mockGetFlowRuns.mockResolvedValue(mockFlowRuns);
+ const result = await getToolResult({ filter: 'status:in:[Failed,InProgress]' });
+ expect(result.isError).toBe(false);
+ invariant(result.content[0].type === 'text');
+ const payload = JSON.parse(result.content[0].text);
+ expect(payload.flowRuns.map((r: FlowRun) => r.status).sort()).toEqual(['Failed', 'InProgress']);
+ expect(mocks.mockGetFlowRuns).toHaveBeenCalledWith(expect.objectContaining({ filter: '' }));
+ });
+
+ it('rejects an unknown status value with the allowed list', async () => {
+ await expect(getToolResult({ filter: 'status:eq:Borked' })).rejects.toThrow(/Allowed flow-run/);
+ });
+
+ it('rejects an unsupported filter field', async () => {
+ await expect(getToolResult({ filter: 'bogusField:eq:x' })).rejects.toThrow();
+ });
+
+ it('reports requested-limit truncation when the caller limit cuts the result', async () => {
+ mocks.mockGetFlowRuns.mockResolvedValue(buildRuns(3));
+ const result = await getToolResult({ limit: 2 });
+ expect(result.isError).toBe(false);
+ invariant(result.content[0].type === 'text');
+ const payload = JSON.parse(result.content[0].text);
+ expect(payload.flowRuns).toHaveLength(2);
+ expect(payload.mcp.resultInfo).toEqual({
+ returnedCount: 2,
+ truncated: true,
+ truncationReason: 'requested-limit',
+ });
+ });
+
+ it('reports admin-cap truncation when MAX_RESULT_LIMITS caps the result', async () => {
+ vi.stubEnv('MAX_RESULT_LIMITS', 'list-flow-runs:1');
+ try {
+ mocks.mockGetFlowRuns.mockResolvedValue(buildRuns(3));
+ const result = await getToolResult({});
+ expect(result.isError).toBe(false);
+ invariant(result.content[0].type === 'text');
+ const payload = JSON.parse(result.content[0].text);
+ expect(payload.flowRuns).toHaveLength(1);
+ expect(payload.mcp.resultInfo).toEqual({
+ returnedCount: 1,
+ truncated: true,
+ truncationReason: 'admin-cap',
+ });
+ } finally {
+ vi.unstubAllEnvs();
+ stubDefaultEnvVars();
+ }
+ });
+
+ it('reports truncated:false when the full matching set is returned', async () => {
+ mocks.mockGetFlowRuns.mockResolvedValue(buildRuns(3));
+ const result = await getToolResult({});
+ expect(result.isError).toBe(false);
+ invariant(result.content[0].type === 'text');
+ const payload = JSON.parse(result.content[0].text);
+ expect(payload.flowRuns).toHaveLength(3);
+ expect(payload.mcp.resultInfo).toEqual({ returnedCount: 3, truncated: false });
+ });
+
+ it('paginates across pages until a short page (no totalAvailable)', async () => {
+ const page1 = buildRuns(100);
+ const page2 = buildRuns(5, 'Success', 100); // distinct ids (run-100..run-104)
+ mocks.mockGetFlowRuns.mockImplementation(({ pageNumber }: { pageNumber: number }) =>
+ Promise.resolve(pageNumber === 1 ? page1 : page2),
+ );
+
+ // Explicit limit above the default backstop so the page loop (not the
+ // backstop) governs and we still exercise the multi-page path.
+ const result = await getToolResult({ limit: 150 });
+ expect(result.isError).toBe(false);
+ invariant(result.content[0].type === 'text');
+ const payload = JSON.parse(result.content[0].text);
+
+ expect(payload.flowRuns).toHaveLength(105);
+ expect(payload.mcp.resultInfo).toEqual({ returnedCount: 105, truncated: false });
+ expect(mocks.mockGetFlowRuns).toHaveBeenCalledTimes(2);
+ expect(mocks.mockGetFlowRuns).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({ pageNumber: 2 }),
+ );
+ });
+
+ it('applies the default backstop (newest 100) when no limit and no admin cap are set', async () => {
+ // Page 1 fills the 100-run page; the short page 2 ends the walk after the
+ // "+1 probe" has confirmed more than 100 runs exist.
+ const page1 = buildRuns(100);
+ const page2 = buildRuns(1, 'Success', 100); // distinct id (run-100) = the +1 probe
+ mocks.mockGetFlowRuns.mockImplementation(({ pageNumber }: { pageNumber: number }) =>
+ Promise.resolve(pageNumber === 1 ? page1 : page2),
+ );
+
+ const result = await getToolResult({});
+ expect(result.isError).toBe(false);
+ invariant(result.content[0].type === 'text');
+ const payload = JSON.parse(result.content[0].text);
+
+ expect(payload.flowRuns).toHaveLength(100);
+ expect(payload.mcp.resultInfo).toEqual({
+ returnedCount: 100,
+ truncated: true,
+ truncationReason: 'default-cap',
+ });
+ });
+
+ it('de-duplicates runs that repeat across pages (unstable server ordering)', async () => {
+ // The Get Flow Runs endpoint can return the same run on more than one page
+ // when the sort key is missing/tied (e.g. Cancelled runs have no startedAt
+ // and the default sort is startedAt:desc). page2 overlaps page1 by 10 ids;
+ // page3 is short and ends the walk.
+ const page1 = buildRuns(100, 'Success', 0); // run-000..run-099
+ const page2 = buildRuns(100, 'Success', 90); // run-090..run-189 (10 overlap)
+ const page3 = buildRuns(20, 'Success', 190); // run-190..run-209 (short page)
+ mocks.mockGetFlowRuns.mockImplementation(({ pageNumber }: { pageNumber: number }) =>
+ Promise.resolve(pageNumber === 1 ? page1 : pageNumber === 2 ? page2 : page3),
+ );
+
+ const result = await getToolResult({ limit: 1000 });
+ expect(result.isError).toBe(false);
+ invariant(result.content[0].type === 'text');
+ const payload = JSON.parse(result.content[0].text);
+
+ const ids = payload.flowRuns.map((r: { id: string }) => r.id);
+ expect(new Set(ids).size).toBe(ids.length); // no duplicates leaked through
+ expect(payload.flowRuns).toHaveLength(210); // 100 + 90 new + 20 new
+ expect(payload.mcp.resultInfo).toEqual({ returnedCount: 210, truncated: false });
+ });
+
+ it('does NOT apply the default backstop when an admin cap is set (reports admin-cap)', async () => {
+ vi.stubEnv('MAX_RESULT_LIMITS', 'list-flow-runs:2');
+ try {
+ mocks.mockGetFlowRuns.mockResolvedValue(buildRuns(3));
+ const result = await getToolResult({});
+ expect(result.isError).toBe(false);
+ invariant(result.content[0].type === 'text');
+ const payload = JSON.parse(result.content[0].text);
+ expect(payload.flowRuns).toHaveLength(2);
+ expect(payload.mcp.resultInfo).toEqual({
+ returnedCount: 2,
+ truncated: true,
+ truncationReason: 'admin-cap',
+ });
+ } finally {
+ vi.unstubAllEnvs();
+ stubDefaultEnvVars();
+ }
+ });
+
+ it('returns an error and fetches nothing on a server older than REST 3.10', async () => {
+ mocks.mockVersionIsAtLeast.mockReturnValue(false);
+ const result = await getToolResult({});
+ expect(result.isError).toBe(true);
+ invariant(result.content[0].type === 'text');
+ expect(result.content[0].text).toContain('3.10');
+ expect(mocks.mockGetFlowRuns).not.toHaveBeenCalled();
+ });
+
+ it('handles API errors gracefully', async () => {
+ mocks.mockGetFlowRuns.mockRejectedValue(new Error('Runs boom'));
+ const result = await getToolResult({ filter: `flowId:eq:${FLOW_ID}` });
+ expect(result.isError).toBe(true);
+ invariant(result.content[0].type === 'text');
+ expect(result.content[0].text).toContain('Runs boom');
+ });
+
+ it('converts a 404 from a non-UUID flowId into an empty result with a recovery hint', async () => {
+ // The Get Flow Runs endpoint returns 404 (not an empty list) when flowId
+ // does not resolve to a real flow — confirmed live against Tableau Cloud.
+ mocks.mockGetFlowRuns.mockRejectedValue(make404());
+ const result = await getToolResult({ filter: 'flowId:eq:My Daily Flow' });
+ expect(result.isError).toBe(false);
+ invariant(result.content[0].type === 'text');
+ expect(result.content[0].text).toContain('No flow runs were found');
+ expect(result.content[0].text).toContain('not a UUID');
+ expect(result.content[0].text).toContain('list-flows');
+ });
+
+ it('converts a 404 from a nonexistent UUID flowId into the baseline empty message', async () => {
+ mocks.mockGetFlowRuns.mockRejectedValue(make404());
+ const result = await getToolResult({
+ filter: 'flowId:eq:00000000-0000-0000-0000-000000000000',
+ });
+ expect(result.isError).toBe(false);
+ invariant(result.content[0].type === 'text');
+ expect(result.content[0].text).toContain('No flow runs were found');
+ expect(result.content[0].text).not.toContain('not a UUID');
+ });
+
+ it('does NOT swallow a 404 when no flowId filter is present', async () => {
+ // A status-only filter is applied client-side, so serverFilter is empty and
+ // has no flowId clause. A 404 here is unexpected and must surface as an error.
+ mocks.mockGetFlowRuns.mockRejectedValue(make404());
+ const result = await getToolResult({ filter: 'status:eq:Failed' });
+ expect(result.isError).toBe(true);
+ invariant(result.content[0].type === 'text');
+ expect(result.content[0].text).toContain('404');
+ });
+
+ describe('failureInsight', () => {
+ it('surfaces a run-history deep link when the window contains a failure', async () => {
+ mocks.mockGetFlowRuns.mockResolvedValue(mockFlowRuns); // one Failed run on FLOW_ID
+ const result = await getToolResult({ filter: `flowId:eq:${FLOW_ID}` });
+ expect(result.isError).toBe(false);
+ invariant(result.content[0].type === 'text');
+ const payload = JSON.parse(result.content[0].text);
+
+ expect(payload.mcp.failureInsight).toEqual({
+ failedRunCount: 1,
+ failedFlowCount: 1,
+ example: {
+ flowId: FLOW_ID,
+ runHistoryUrl: `${FLOW_WEBPAGE_URL}/runHistory`,
+ },
+ });
+ // Exactly one Query Flow call — only the example flow is resolved.
+ expect(mocks.mockQueryFlow).toHaveBeenCalledTimes(1);
+ expect(mocks.mockQueryFlow).toHaveBeenCalledWith({
+ siteId: 'test-site-id',
+ flowId: FLOW_ID,
+ });
+ });
+
+ it('keeps failed runs when the optional flow scope is denied', async () => {
+ mocks.rejectFlowsRead = true;
+ mocks.mockGetFlowRuns.mockResolvedValue(mockFlowRuns);
+
+ const result = await getToolResult({ filter: `flowId:eq:${FLOW_ID}` });
+ expect(result.isError).toBe(false);
+ invariant(result.content[0].type === 'text');
+ const payload = JSON.parse(result.content[0].text);
+
+ expect(payload.flowRuns).toHaveLength(mockFlowRuns.length);
+ expect(payload.mcp.failureInsight).toEqual({
+ failedRunCount: 1,
+ failedFlowCount: 1,
+ });
+
+ const primaryCall = vi
+ .mocked(useRestApi)
+ .mock.calls.find(([{ jwtScopes }]) => jwtScopes.includes('tableau:flow_runs:read'));
+ expect(primaryCall).toBeDefined();
+ expect(primaryCall?.[0].jwtScopes).not.toContain('tableau:flows:read');
+ });
+
+ it('omits failureInsight (and does not resolve a flow) when there are no failures', async () => {
+ mocks.mockGetFlowRuns.mockResolvedValue(buildRuns(3, 'Success'));
+ const result = await getToolResult({});
+ expect(result.isError).toBe(false);
+ invariant(result.content[0].type === 'text');
+ const payload = JSON.parse(result.content[0].text);
+
+ expect(payload.mcp.failureInsight).toBeUndefined();
+ expect(mocks.mockQueryFlow).not.toHaveBeenCalled();
+ });
+
+ it('counts distinct failed flows but resolves only ONE example (single REST call)', async () => {
+ const runs: FlowRun[] = [
+ {
+ id: 'f1000000-0000-0000-0000-000000000000',
+ flowId: 'flow-aaaa',
+ status: 'Failed',
+ completedAt: '2026-06-11T10:00:00Z',
+ progress: 100,
+ },
+ {
+ id: 'f2000000-0000-0000-0000-000000000000',
+ flowId: 'flow-bbbb',
+ status: 'Failed',
+ completedAt: '2026-06-10T10:00:00Z',
+ progress: 100,
+ },
+ {
+ id: 's1000000-0000-0000-0000-000000000000',
+ flowId: 'flow-aaaa',
+ status: 'Success',
+ completedAt: '2026-06-09T10:00:00Z',
+ progress: 100,
+ },
+ ];
+ mocks.mockGetFlowRuns.mockResolvedValue(runs);
+
+ const result = await getToolResult({});
+ expect(result.isError).toBe(false);
+ invariant(result.content[0].type === 'text');
+ const payload = JSON.parse(result.content[0].text);
+
+ expect(payload.mcp.failureInsight.failedRunCount).toBe(2);
+ expect(payload.mcp.failureInsight.failedFlowCount).toBe(2);
+ // Most-recent failure (newest completedAt) is the example.
+ expect(payload.mcp.failureInsight.example.flowId).toBe('flow-aaaa');
+ expect(mocks.mockQueryFlow).toHaveBeenCalledTimes(1);
+ expect(mocks.mockQueryFlow).toHaveBeenCalledWith({
+ siteId: 'test-site-id',
+ flowId: 'flow-aaaa',
+ });
+ });
+
+ it('still returns runs + failure counts (no example link) when flow resolution fails', async () => {
+ mocks.mockGetFlowRuns.mockResolvedValue(mockFlowRuns);
+ mocks.mockQueryFlow.mockRejectedValue(new Error('Query Flow boom'));
+
+ const result = await getToolResult({ filter: `flowId:eq:${FLOW_ID}` });
+ // The tool must NOT fail just because the link could not be resolved.
+ expect(result.isError).toBe(false);
+ invariant(result.content[0].type === 'text');
+ const payload = JSON.parse(result.content[0].text);
+
+ expect(payload.flowRuns.length).toBeGreaterThan(0);
+ expect(payload.mcp.failureInsight).toEqual({
+ failedRunCount: 1,
+ failedFlowCount: 1,
+ });
+ expect(payload.mcp.failureInsight.example).toBeUndefined();
+ });
+
+ it('omits the example link when the resolved flow has no webpageUrl', async () => {
+ mocks.mockGetFlowRuns.mockResolvedValue(mockFlowRuns);
+ mocks.mockQueryFlow.mockResolvedValue({
+ flow: { id: FLOW_ID, name: 'Daily Flow' }, // no webpageUrl
+ outputSteps: [],
+ });
+
+ const result = await getToolResult({ filter: `flowId:eq:${FLOW_ID}` });
+ expect(result.isError).toBe(false);
+ invariant(result.content[0].type === 'text');
+ const payload = JSON.parse(result.content[0].text);
+
+ expect(payload.mcp.failureInsight.failedRunCount).toBe(1);
+ expect(payload.mcp.failureInsight.example).toBeUndefined();
+ });
+ });
+
+ describe('constrainFlowRuns', () => {
+ it('returns the baseline empty message when no runs are found', () => {
+ const result = constrainFlowRuns({
+ result: { flowRuns: [] },
+ boundedContext: NO_BOUNDED_CONTEXT,
+ });
+ invariant(result.type === 'empty');
+ expect(result.message).toContain('No flow runs were found');
+ });
+
+ it('attaches a flowId recovery hint when flowId is not a UUID and no runs are found', () => {
+ const result = constrainFlowRuns({
+ result: { flowRuns: [] },
+ boundedContext: NO_BOUNDED_CONTEXT,
+ validatedFilter: 'flowId:eq:My Daily Flow',
+ });
+ invariant(result.type === 'empty');
+ expect(result.message).toContain('not a UUID');
+ expect(result.message).toContain('list-flows');
+ });
+
+ it('does NOT attach the flowId hint when flowId IS a valid UUID', () => {
+ const result = constrainFlowRuns({
+ result: { flowRuns: [] },
+ boundedContext: NO_BOUNDED_CONTEXT,
+ validatedFilter: `flowId:eq:${FLOW_ID}`,
+ });
+ invariant(result.type === 'empty');
+ expect(result.message).not.toContain('not a UUID');
+ });
+
+ it('fails closed under a projectIds bounded context', () => {
+ const result = constrainFlowRuns({
+ result: { flowRuns: mockFlowRuns },
+ boundedContext: { ...NO_BOUNDED_CONTEXT, projectIds: new Set(['p1']) },
+ });
+ invariant(result.type === 'empty');
+ expect(result.message).toContain('limited by the server configuration');
+ expect(result.message).toContain('get-flow');
+ });
+
+ it('fails closed under a tags bounded context', () => {
+ const result = constrainFlowRuns({
+ result: { flowRuns: mockFlowRuns },
+ boundedContext: { ...NO_BOUNDED_CONTEXT, tags: new Set(['t1']) },
+ });
+ invariant(result.type === 'empty');
+ expect(result.message).toContain('limited by the server configuration');
+ });
+
+ it('carries the truncation signal through and recomputes returnedCount', () => {
+ const result = constrainFlowRuns({
+ result: {
+ flowRuns: mockFlowRuns,
+ mcp: { resultInfo: { returnedCount: 3, truncated: true, truncationReason: 'admin-cap' } },
+ },
+ boundedContext: NO_BOUNDED_CONTEXT,
+ });
+ invariant(result.type === 'success');
+ expect(result.result.mcp.resultInfo).toEqual({
+ returnedCount: mockFlowRuns.length,
+ truncated: true,
+ truncationReason: 'admin-cap',
+ });
+ });
+
+ it('passes failureInsight through on the success path', () => {
+ const failureInsight = {
+ failedRunCount: 1,
+ failedFlowCount: 1,
+ example: { flowId: 'flow-aaaa', runHistoryUrl: 'https://x/#/flows/1/runHistory' },
+ };
+ const result = constrainFlowRuns({
+ result: {
+ flowRuns: mockFlowRuns,
+ mcp: { resultInfo: { returnedCount: 3, truncated: false }, failureInsight },
+ },
+ boundedContext: NO_BOUNDED_CONTEXT,
+ });
+ invariant(result.type === 'success');
+ expect(result.result.mcp.failureInsight).toEqual(failureInsight);
+ });
+
+ it('drops failureInsight when a bounded context forces an empty result', () => {
+ const result = constrainFlowRuns({
+ result: {
+ flowRuns: mockFlowRuns,
+ mcp: {
+ resultInfo: { returnedCount: 3, truncated: false },
+ failureInsight: { failedRunCount: 1, failedFlowCount: 1 },
+ },
+ },
+ boundedContext: { ...NO_BOUNDED_CONTEXT, projectIds: new Set(['p1']) },
+ });
+ // Bounded context fails closed → empty result, so no link leaks out.
+ invariant(result.type === 'empty');
+ });
+ });
+});
+
+function make404(): Error {
+ const err = new Error('Request failed with status code 404') as Error & {
+ isAxiosError: boolean;
+ response: { status: number };
+ };
+ err.isAxiosError = true;
+ err.response = { status: 404 };
+ return err;
+}
+
+async function getToolResult(params: {
+ filter?: string;
+ sort?: string;
+ limit?: number;
+}): Promise {
+ const tool = getListFlowRunsTool(new WebMcpServer());
+ const callback = await Provider.from(tool.callback);
+ return await callback(
+ { filter: params.filter, sort: params.sort, limit: params.limit },
+ getMockRequestHandlerExtra(),
+ );
+}
diff --git a/src/tools/web/flows/listFlowRuns/listFlowRuns.ts b/src/tools/web/flows/listFlowRuns/listFlowRuns.ts
new file mode 100644
index 000000000..ccb5356ab
--- /dev/null
+++ b/src/tools/web/flows/listFlowRuns/listFlowRuns.ts
@@ -0,0 +1,571 @@
+import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
+import { Ok } from 'ts-results-es';
+import { z } from 'zod';
+
+import { getConfig } from '../../../../config.js';
+import { BoundedContext } from '../../../../overridableConfig.js';
+import { useRestApi } from '../../../../restApiInstance.js';
+import { RestApi } from '../../../../sdks/tableau/restApi.js';
+import { FlowRun } from '../../../../sdks/tableau/types/flow.js';
+import { WebMcpServer } from '../../../../server.web.js';
+import {
+ LIST_FLOW_RUNS_FAILURE_INSIGHT_API_SCOPE,
+ LIST_FLOW_RUNS_PRIMARY_API_SCOPES,
+} from '../../../../server/oauth/scopes.js';
+import { getHttpStatus } from '../../../../utils/getHttpStatus.js';
+import { genericFilterDescription } from '../../genericFilterDescription.js';
+import { ConstrainedResult, WebTool } from '../../tool.js';
+import { TableauWebRequestHandlerExtra } from '../../toolContext.js';
+import { extractEqValue, looksLikeUuid } from '../flowFilterUtils.js';
+import { buildTruncationInfo, ListFlowsTruncationReason } from '../listFlows/listFlows.js';
+import { parseAndValidateFlowRunsFilterString } from './flowRunsFilterUtils.js';
+
+// Server page size for the run-history pagination loop. The "Get Flow Runs"
+// endpoint returns NO pagination block (no `totalAvailable`), so this tool
+// cannot use paginateWithMetadata. Instead it walks pages until a short page
+// (server exhausted) or it has collected one more match than the caller asked
+// for (the "+1 probe", which lets it report `truncated` without a count).
+const FLOW_RUNS_PAGE_SIZE = 100;
+
+// Sentinel for "no caller limit and no admin cap" — pagination still terminates
+// at the server's last (short) page, so this just means "return everything".
+const UNBOUNDED = Number.MAX_SAFE_INTEGER;
+
+// The Get Flow Runs endpoint was introduced in REST API 3.10 (Tableau Server
+// 2020.4). Older servers return 404, so gate the call to give a clear message.
+const MIN_REST_VERSION = '3.10';
+
+// Safety backstop for an otherwise-unbounded call (no caller `limit` AND no admin
+// MAX_RESULT_LIMIT). Flow runs accumulate quickly on active sites, so default to
+// the newest N rather than walking the entire history page by page (which would
+// load the server and could be very slow). Reported via `truncationReason:
+// 'default-cap'` so the caller knows more exist and can request them.
+const DEFAULT_FLOW_RUNS_LIMIT = 100;
+
+/**
+ * Truncation reasons for list-flow-runs. Extends the shared list-flows reasons
+ * with `'default-cap'` — unique to this tool — for when the safety backstop
+ * (rather than a caller `limit` or admin cap) bound the result.
+ */
+export type ListFlowRunsTruncationReason = ListFlowsTruncationReason | 'default-cap';
+
+export type ListFlowRunsResultInfo = {
+ returnedCount: number;
+ truncated: boolean;
+ truncationReason?: ListFlowRunsTruncationReason;
+};
+
+/**
+ * Pointer to investigate flow-run failures in the Tableau UI. The Get Flow Runs
+ * endpoint never returns *why* a run failed, so when the returned window holds
+ * one or more `Failed` runs the tool resolves ONE affected flow's `webpageUrl`
+ * (via Query Flow) into a run-history deep link the caller can open to read the
+ * error. Only one example is resolved (a single extra REST call); `failedFlowCount`
+ * tells the caller how many other flows failed so it can offer to fetch their
+ * links (via get-flow) on request.
+ */
+export type ListFlowRunsFailureInsight = {
+ // Number of `Failed` runs within the returned (post-limit) window.
+ failedRunCount: number;
+ // Number of DISTINCT flows with at least one `Failed` run in that window.
+ failedFlowCount: number;
+ // One example, present only when the flow's `webpageUrl` could be resolved:
+ // the most-recent failure's flow and its run-history page.
+ example?: {
+ flowId: string;
+ runHistoryUrl: string;
+ };
+};
+
+/**
+ * Wrapped result: `flowRuns` is the (possibly truncated) array and
+ * `mcp.resultInfo` (always present) reports whether that array is complete.
+ *
+ * Unlike list-flows there is no `totalAvailable` — the Get Flow Runs endpoint
+ * does not return a server-side count, so completeness is reported via the
+ * `truncated` flag (computed with a "+1 probe") only.
+ *
+ * `mcp.failureInsight` is present ONLY when the returned window contains a
+ * `Failed` run (the API hides failure reasons, so this links to the UI page).
+ */
+export type ListFlowRunsResult = {
+ flowRuns: FlowRun[];
+ mcp: {
+ resultInfo: ListFlowRunsResultInfo;
+ failureInsight?: ListFlowRunsFailureInsight;
+ };
+};
+
+const paramsSchema = {
+ filter: z.string().optional(),
+ sort: z.string().optional(),
+ limit: z.number().int().positive().optional(),
+};
+
+export const getListFlowRunsTool = (server: WebMcpServer): WebTool => {
+ const config = getConfig();
+
+ const listFlowRunsTool = new WebTool({
+ server,
+ name: 'list-flow-runs',
+ disabled: !config.flowToolsEnabled,
+ description: `
+ Retrieves the run history (executions) of Tableau Prep flows on a site. Each flow run records one execution attempt with its \`status\` (Pending, InProgress, Success, Failed, Cancelled), \`startedAt\`/\`completedAt\` timestamps, \`progress\`, the \`flowId\` it belongs to, and the \`backgroundJobId\`. Use this tool to answer questions about flow execution outcomes — e.g. "which flows failed recently", "show the run history for flow X", "what's still running".
+
+ **This vs. get-flow / list-flows**
+ - \`list-flows\` lists flow *definitions* (metadata), not executions.
+ - \`get-flow\` returns recent runs for ONE flow (capped, as a sidecar). \`list-flow-runs\` is the dedicated, filterable, site-wide run history — use it for cross-flow questions ("all failures today") or deeper single-flow history.
+
+ **What a run record does NOT include**
+ - **Failure reason:** a run reports only its \`status\`, never *why* it failed — the run data carries no error message. A failed run's \`backgroundJobId\` identifies the underlying background job that holds the error detail, but reading that detail requires Tableau **site-administrator** access. \`backgroundJobId\` is populated only for recent runs. As a workaround, when the returned window contains \`Failed\` runs the tool resolves a **run-history deep link** for one affected flow into \`mcp.failureInsight\` — open it in Tableau to read the error there (see Response Shape).
+ - **Trigger origin:** a run does not record whether it was started by a *schedule* or *ad-hoc / on-demand*, and there is no field to filter scheduled-only runs. To see which flows have schedules, use \`list-flow-tasks\`.
+
+ **Caller-role visibility**
+ - **Non-admin** callers get runs only for flows they can *run* — flows on which they hold the **Execute** ("Run Flow Now") capability (as owner, via an explicit or group grant, or inherited from the project). A flow the caller can only *view* (Read) but not run returns NO runs here. NOTE: this is stricter than the Tableau web UI's run-history page (which needs only view permission), so a user may see a flow's runs in the browser yet get nothing for that flow from this tool.
+ - **Admin** callers (site or server) get runs for every flow on the site, so \`mcp.resultInfo.truncated\` is more likely \`true\` on a broad call.
+
+ **Response Shape**
+ Returns a JSON object \`{ flowRuns: [...], mcp: { resultInfo: {...} } }\`. \`mcp.resultInfo\` is ALWAYS present and reports completeness:
+ - \`returnedCount\` — number of runs in \`flowRuns\`.
+ - \`truncated\` — \`false\` means \`flowRuns\` is the COMPLETE set matching the request; \`true\` means more matching runs exist on the server than were returned.
+ - \`truncationReason\` (only when \`truncated\` is \`true\`):
+ - \`"requested-limit"\` — your \`limit\` cut the result short; call again with a higher \`limit\` for more.
+ - \`"default-cap"\` — you supplied no \`limit\` and no admin cap is set, so the tool returned the newest ${DEFAULT_FLOW_RUNS_LIMIT} runs as a safety default (flow runs can be very numerous); pass a higher \`limit\` and/or a narrower \`filter\` to get more.
+ - \`"admin-cap"\` — a site-administrator per-call cap (\`MAX_RESULT_LIMIT[S]\`) cut it short; narrow the \`filter\` (e.g. a tighter \`startedAt\` window or a single \`flowId\`) or ask an admin to raise the cap.
+ - There is NO \`totalAvailable\` — the Tableau Flow Runs endpoint does not return a total count. When \`truncated\` is \`true\`, report "at least N" (never invent a total).
+ - \`mcp.failureInsight\` (present ONLY when the returned window contains at least one \`Failed\` run): a pointer to investigate failures in the Tableau UI, since the API does not expose the error message. Fields:
+ - \`failedRunCount\` — number of \`Failed\` runs in the returned window.
+ - \`failedFlowCount\` — number of DISTINCT flows with a failure in that window.
+ - \`example\` (when resolvable) — \`{ flowId, runHistoryUrl }\` for ONE affected flow (the most-recent failure); open \`runHistoryUrl\` in a browser and expand the failed run to read why it failed.
+
+ **Reporting to the user (every call):** translate \`mcp.resultInfo\` into one plain sentence — never say "resultInfo". \`truncated:false\` → "these are all N matching runs". \`"requested-limit"\` → "here are N; more match — say if you want the rest". \`"default-cap"\` → "here are the newest N; more runs exist — say if you want a larger set (or narrow by flow/date)". \`"admin-cap"\` → "here are N; a site limit caps results per call — I can narrow the search, or an admin can raise the cap". When \`mcp.failureInsight\` is present, also note that the API can't return the failure reason but it is viewable in Tableau, and share \`example.runHistoryUrl\`; if \`failedFlowCount\` > 1, add that this link is for one of N affected flows and offer to fetch the others (resolve each via \`get-flow\`). Also surface the **Caller-role visibility** limit when presenting results: these runs cover ONLY flows the user can *run* (the Execute / "Run Flow Now" capability), not flows they can only view — so a flow they can see in the Tableau web UI may be missing here. Call this out especially on empty or unexpectedly short results, or when the user asks for "all" failures. (Site/server administrators are exempt — they get runs for every flow, so do not state this limit to an admin caller.)
+
+ **Response-Size Guidance** — flow runs accumulate quickly on active sites, so favour narrow calls:
+ - Single-flow history: \`filter: "flowId:eq:"\` (+ optional \`limit\`).
+ - Recent failures: \`filter: "status:eq:Failed,startedAt:gt:2025-01-01"\`.
+ - With no \`limit\` and no admin cap, the tool returns the newest ${DEFAULT_FLOW_RUNS_LIMIT} runs (\`truncated:true\`, \`truncationReason:"default-cap"\`) instead of the full history; pass a higher \`limit\` to go deeper.
+
+ **Supported Filter Fields and Operators**
+ | Field | Operators | Notes |
+ |-------------|----------------------|-------|
+ | flowId | eq, in | Flow LUID (UUID). Most common filter — scope runs to one or more flows. |
+ | userId | eq, in | LUID of the user who initiated the run. |
+ | progress | eq, gt, gte, lt, lte | Percent complete (0–100). |
+ | startedAt | eq, gt, gte, lt, lte | ISO 8601 \`YYYY-MM-DDTHH:MM:SSZ\`, OR date-only \`YYYY-MM-DD\` (auto-promoted to midnight UTC). |
+ | completedAt | eq, gt, gte, lt, lte | ISO 8601 \`YYYY-MM-DDTHH:MM:SSZ\`, OR date-only \`YYYY-MM-DD\` (auto-promoted to midnight UTC). |
+ | status | eq, in | One of Pending, InProgress, Success, Failed, Cancelled. **Applied client-side** (the Tableau API does not filter runs by status server-side), so it is matched against the runs fetched for the rest of the filter — pair it with \`flowId\` and/or a \`startedAt\` window for precise results. |
+
+ **Filter value contracts** (mismatches return 0 runs):
+ - \`flowId\` must be the flow UUID; a flow name (or any id that doesn't resolve to a visible flow) returns no runs and the tool hints toward list-flows.
+ - \`status\` values are case-sensitive and must be one of the five exact values above; an unknown value is rejected with the allowed list.
+ - \`in:\` lists use bracket/comma form, e.g. \`flowId:in:[uuid1,uuid2]\` or \`status:in:[Failed,Cancelled]\` (unquoted; commas inside items unsupported).
+
+ ${genericFilterDescription}
+
+ **Sort Expression**
+ - Default (no \`sort\`): newest first by run recency — ordered by \`completedAt\`, falling back to \`startedAt\` for runs that haven't completed; runs with neither timestamp (e.g. \`Pending\`) sort last. This is a best-effort "most recent" within the returned window.
+ - Override with an explicit \`sort\`, e.g. \`completedAt:desc\` or \`startedAt:asc\`. NOTE: the Tableau API orders runs whose chosen field is empty FIRST under \`:desc\`, so an explicit \`startedAt:desc\` surfaces never-started runs (e.g. some Cancelled) ahead of genuinely recent ones — prefer the default for "latest runs".
+
+ **Example Usage:**
+ - Run history for one flow, newest first: filter: "flowId:eq:6f8a2966-e173-11e8-ae74-ffd84c19d7f3"
+ - Recent failures across all flows: filter: "status:eq:Failed,startedAt:gt:2025-01-01"
+ - In-progress runs right now: filter: "status:eq:InProgress"`,
+ paramsSchema,
+ annotations: {
+ title: 'List Flow Runs',
+ readOnlyHint: true,
+ destructiveHint: false,
+ idempotentHint: true,
+ openWorldHint: false,
+ },
+ callback: async ({ filter, sort, limit }, extra): Promise => {
+ const configWithOverrides = await extra.getConfigWithOverrides();
+ const validated = filter ? parseAndValidateFlowRunsFilterString(filter) : undefined;
+ const serverFilter = validated?.serverFilter ?? '';
+ const matchesStatus = validated?.matchesStatus ?? ((): boolean => true);
+
+ return await listFlowRunsTool.logAndExecute({
+ extra,
+ args: { filter, sort, limit },
+ callback: async () => {
+ return new Ok(
+ await useRestApi({
+ ...extra,
+ jwtScopes: LIST_FLOW_RUNS_PRIMARY_API_SCOPES,
+ callback: async (restApi) => {
+ if (!RestApi.versionIsAtLeast(MIN_REST_VERSION)) {
+ throw new Error(
+ `Listing flow runs requires Tableau REST API version ${MIN_REST_VERSION} or later (Tableau Server 2020.4 / Tableau Cloud). The connected Tableau server does not support the Get Flow Runs endpoint.`,
+ );
+ }
+
+ const maxResultLimit = configWithOverrides.getMaxResultLimit(listFlowRunsTool.name);
+ // Resolve the effective cap. An explicit caller `limit` wins (still
+ // capped by any admin MAX_RESULT_LIMIT); else the admin cap; else the
+ // DEFAULT_FLOW_RUNS_LIMIT backstop so a no-argument call never walks
+ // the entire run history.
+ const usedDefaultBackstop = limit === undefined && !maxResultLimit;
+ const effectiveLimit = limit
+ ? maxResultLimit
+ ? Math.min(maxResultLimit, limit)
+ : limit
+ : (maxResultLimit ?? DEFAULT_FLOW_RUNS_LIMIT);
+
+ let collected: { items: FlowRun[]; truncatedByLimit: boolean };
+ try {
+ collected = await collectFlowRuns({
+ effectiveLimit,
+ matchesStatus,
+ // With no caller `sort`, return the newest runs by recency.
+ // The Get Flow Runs endpoint floats rows whose sort key is
+ // EMPTY to the front under `:desc`, and `startedAt` is empty
+ // for every Cancelled-before-start run (live-observed 600+ in
+ // a row), so the old `startedAt:desc` default returned a stale,
+ // unordered block of never-started runs as the "newest N".
+ // `completedAt:desc` has a far smaller empty band (live-observed
+ // a single row) so the genuinely recent runs land inside the
+ // fetched window; `sortByRecency` then re-orders that window by
+ // `completedAt ?? startedAt` (see collectFlowRuns). An explicit
+ // caller `sort` is passed through and honored as-is.
+ sortByRecency: sort === undefined,
+ pageSize: FLOW_RUNS_PAGE_SIZE,
+ getPage: (pageNumber) =>
+ restApi.flowsMethods.getFlowRuns({
+ siteId: restApi.siteId,
+ filter: serverFilter,
+ sort: sort ?? 'completedAt:desc',
+ pageSize: FLOW_RUNS_PAGE_SIZE,
+ pageNumber,
+ }),
+ });
+ } catch (error) {
+ // The Get Flow Runs endpoint returns 404 (not an empty list)
+ // when a `flowId` filter does not resolve to a real flow the
+ // caller can see — confirmed against Tableau Cloud for both a
+ // non-UUID value and a valid-format-but-nonexistent UUID. Turn
+ // that into an empty result so the caller gets the actionable
+ // recovery hint (see buildEmptyMessage) instead of a bare
+ // "Request failed with status code 404". Only swallow the 404
+ // when a flowId clause is present; any other 404 is unexpected
+ // and should surface.
+ if (
+ serverFilter.includes('flowId') &&
+ error instanceof Error &&
+ getHttpStatus(error) === '404'
+ ) {
+ return {
+ flowRuns: [],
+ mcp: { resultInfo: { returnedCount: 0, truncated: false } },
+ } satisfies ListFlowRunsResult;
+ }
+ throw error;
+ }
+
+ const { items: flowRuns, truncatedByLimit } = collected;
+
+ const { truncated, truncationReason } = buildTruncationInfo({
+ truncatedByLimit,
+ maxResultLimit,
+ llmLimit: limit,
+ effectiveLimit,
+ });
+ // The shared helper only knows 'admin-cap' / 'requested-limit'. When
+ // our backstop (not a caller limit or admin cap) was the binding
+ // constraint, label it 'default-cap' so the distinction is clear.
+ const finalTruncationReason: ListFlowRunsTruncationReason | undefined =
+ truncated && usedDefaultBackstop ? 'default-cap' : truncationReason;
+
+ // If the window holds any Failed runs, resolve a UI run-history
+ // link for one of them (the API can't tell the caller WHY a run
+ // failed). Computed unconditionally here; constrainFlowRuns drops
+ // it on the empty / bounded-context paths so the gate stays in one
+ // place. Best-effort — see buildFailureInsight.
+ const failureInsight = await buildFailureInsight({ flowRuns, extra });
+
+ return {
+ flowRuns,
+ mcp: {
+ resultInfo: {
+ returnedCount: flowRuns.length,
+ truncated,
+ ...(finalTruncationReason && { truncationReason: finalTruncationReason }),
+ },
+ ...(failureInsight && { failureInsight }),
+ },
+ } satisfies ListFlowRunsResult;
+ },
+ }),
+ );
+ },
+ constrainSuccessResult: (result) =>
+ constrainFlowRuns({
+ result,
+ boundedContext: configWithOverrides.boundedContext,
+ validatedFilter: validated?.normalizedFilter,
+ }),
+ });
+ },
+ });
+
+ return listFlowRunsTool;
+};
+
+/**
+ * Recency timestamp (epoch ms) for ordering runs newest-first under the default
+ * sort. A run's recency is its `completedAt`, falling back to `startedAt` when it
+ * hasn't completed. Runs with NEITHER timestamp (e.g. `Pending`) have no knowable
+ * recency and sort LAST (`-Infinity`). An unparseable timestamp is treated the
+ * same as missing.
+ */
+function recencyMillis(run: FlowRun): number {
+ const ts = run.completedAt ?? run.startedAt;
+ if (!ts) {
+ return Number.NEGATIVE_INFINITY;
+ }
+ const ms = Date.parse(ts);
+ return Number.isNaN(ms) ? Number.NEGATIVE_INFINITY : ms;
+}
+
+/**
+ * Newest-first comparator on `completedAt ?? startedAt`, nulls last. Equal keys
+ * (including two never-dated runs) compare 0 so the stable sort preserves the
+ * server's relative order for ties.
+ */
+function compareByRecencyDesc(a: FlowRun, b: FlowRun): number {
+ const ka = recencyMillis(a);
+ const kb = recencyMillis(b);
+ return ka === kb ? 0 : kb - ka;
+}
+
+/**
+ * When the returned window contains one or more `Failed` runs, build a pointer
+ * the caller can use to investigate WHY in the Tableau UI — the Get Flow Runs
+ * endpoint never returns an error message. We resolve ONE example failed flow's
+ * `webpageUrl` (via the Query Flow endpoint) and turn it into a run-history deep
+ * link (`webpageUrl + "/runHistory"`); that page lists the flow's runs and lets
+ * the user expand a failed run to read its error. Only one flow is resolved (the
+ * most-recent failure in the window) to keep this to a single extra REST call —
+ * `failedFlowCount` tells the caller how many other flows failed so it can offer
+ * to fetch their links (via get-flow) on request.
+ *
+ * Returns `undefined` when the window has no failures. Best-effort otherwise: any
+ * failure to resolve the example link is swallowed (the runs are the primary
+ * result and must not be lost over a missing sidecar URL), so `example` may be
+ * absent even when failures exist.
+ */
+async function buildFailureInsight({
+ flowRuns,
+ extra,
+}: {
+ flowRuns: FlowRun[];
+ extra: TableauWebRequestHandlerExtra;
+}): Promise {
+ const failedRuns = flowRuns.filter((run) => run.status === 'Failed');
+ if (failedRuns.length === 0) {
+ return undefined;
+ }
+
+ const failedFlowIds = new Set(
+ failedRuns.map((run) => run.flowId).filter((id): id is string => id !== undefined),
+ );
+
+ // The window is ordered newest-first (default sort), so the first failed run
+ // with a flowId is the most-recent failure — the best single example.
+ const exampleFlowId = failedRuns.find((run) => run.flowId !== undefined)?.flowId;
+
+ let example: ListFlowRunsFailureInsight['example'];
+ if (exampleFlowId !== undefined) {
+ try {
+ const { flow } = await useRestApi({
+ ...extra,
+ jwtScopes: [LIST_FLOW_RUNS_FAILURE_INSIGHT_API_SCOPE],
+ callback: async (restApi) => {
+ return await restApi.flowsMethods.queryFlow({
+ siteId: restApi.siteId,
+ flowId: exampleFlowId,
+ });
+ },
+ });
+ if (flow.webpageUrl) {
+ example = {
+ flowId: exampleFlowId,
+ // `webpageUrl` is the flow's UI page in numeric-id form
+ // (e.g. .../#/site//flows/); its run-history tab is that
+ // page + "/runHistory" (matches what the Tableau UI links to).
+ runHistoryUrl: `${flow.webpageUrl.replace(/\/+$/, '')}/runHistory`,
+ };
+ }
+ } catch {
+ // Best-effort: a failed link resolution must not fail the whole call.
+ }
+ }
+
+ return {
+ failedRunCount: failedRuns.length,
+ failedFlowCount: failedFlowIds.size,
+ ...(example && { example }),
+ };
+}
+
+/**
+ * Walk the Get Flow Runs pages, applying the client-side `status` predicate as
+ * it goes, until either the server is exhausted (a short page) or one more match
+ * than `effectiveLimit` has been collected (the "+1 probe"). Returns the
+ * limit-capped items plus whether more matching runs exist server-side.
+ *
+ * When `sortByRecency` is set (the default-sort path), the collected window is
+ * re-ordered newest-first by `completedAt ?? startedAt` BEFORE the limit slice,
+ * which corrects the endpoint's empty-key-first ordering (see callback). For an
+ * explicit caller `sort` the server order is preserved.
+ */
+async function collectFlowRuns({
+ getPage,
+ matchesStatus,
+ effectiveLimit,
+ pageSize,
+ sortByRecency,
+}: {
+ getPage: (pageNumber: number) => Promise;
+ matchesStatus: (run: FlowRun) => boolean;
+ effectiveLimit: number;
+ pageSize: number;
+ sortByRecency: boolean;
+}): Promise<{ items: FlowRun[]; truncatedByLimit: boolean }> {
+ const probeTarget = effectiveLimit === UNBOUNDED ? UNBOUNDED : effectiveLimit + 1;
+ const matched: FlowRun[] = [];
+ // De-duplicate across pages by run id. The Get Flow Runs endpoint paginates
+ // with an UNSTABLE order whenever the sort key is empty or tied — many runs
+ // have no `completedAt` (InProgress/Pending) or no `startedAt`
+ // (Cancelled-before-start) — so the same run can be returned on more than one
+ // page. Without this guard the repeats leak into the result (live-observed:
+ // 150 requested -> 10 duplicate ids) and inflate the +1 probe.
+ const seenIds = new Set();
+ let pageNumber = 1;
+
+ for (;;) {
+ const page = await getPage(pageNumber);
+ let newThisPage = 0;
+ for (const run of page) {
+ if (run.id !== undefined) {
+ if (seenIds.has(run.id)) {
+ continue; // duplicate from an overlapping page — skip
+ }
+ seenIds.add(run.id);
+ }
+ newThisPage++;
+ if (matchesStatus(run)) {
+ matched.push(run);
+ }
+ }
+
+ // Stop when: the server has no more runs (a short page); enough matches were
+ // collected (incl. the +1 probe); or a full page yielded NO new runs at all,
+ // which means the server is only re-returning rows we've already seen (a
+ // degenerate unstable-pagination loop) and further pages cannot make progress.
+ if (page.length < pageSize || matched.length >= probeTarget || newThisPage === 0) {
+ break;
+ }
+ pageNumber++;
+ }
+
+ // Re-order the fetched window newest-first before slicing so the limit keeps
+ // the most-recent runs (not whichever empty-key rows the server floated up).
+ if (sortByRecency) {
+ matched.sort(compareByRecencyDesc);
+ }
+
+ const truncatedByLimit = matched.length > effectiveLimit;
+ const items = effectiveLimit === UNBOUNDED ? matched : matched.slice(0, effectiveLimit);
+ return { items, truncatedByLimit };
+}
+
+export function constrainFlowRuns({
+ result,
+ boundedContext,
+ validatedFilter,
+}: {
+ // Tolerates a missing `mcp.resultInfo` (treated as "complete") so unit tests
+ // can pass a bare `{ flowRuns }`.
+ result: {
+ flowRuns: FlowRun[];
+ mcp?: { resultInfo?: ListFlowRunsResultInfo; failureInsight?: ListFlowRunsFailureInsight };
+ };
+ boundedContext: BoundedContext;
+ validatedFilter?: string;
+}): ConstrainedResult {
+ // Fail closed: a flow run carries no project or tag, so when the server is
+ // restricted to a PROJECT_IDS / TAGS bounded context we cannot prove a run
+ // belongs to the allowed set. Refuse rather than risk leaking runs for flows
+ // outside the allow-list. (Other bounded-context dimensions —
+ // datasource/workbook/view — do not constrain flows, mirroring list-flows.)
+ const { projectIds, tags } = boundedContext;
+ if (projectIds || tags) {
+ return {
+ type: 'empty',
+ message: [
+ 'The set of content that can be queried is limited by the server configuration (an allowed-projects or tags bounded context is active).',
+ 'Flow runs are not associated with a project or tag, so this tool cannot verify that a run belongs to the allowed set and does not return flow runs under this configuration.',
+ 'To inspect a specific allowed flow\u2019s run history, use the get-flow tool with the flow id (it enforces the same bounded context on the flow itself).',
+ ].join(' '),
+ };
+ }
+
+ if (result.flowRuns.length === 0) {
+ return {
+ type: 'empty',
+ message: buildEmptyMessage(validatedFilter),
+ };
+ }
+
+ const truncated = result.mcp?.resultInfo?.truncated ?? false;
+ const truncationReason = result.mcp?.resultInfo?.truncationReason;
+ const failureInsight = result.mcp?.failureInsight;
+
+ return {
+ type: 'success',
+ result: {
+ flowRuns: result.flowRuns,
+ mcp: {
+ resultInfo: {
+ returnedCount: result.flowRuns.length,
+ truncated,
+ ...(truncationReason && { truncationReason }),
+ },
+ ...(failureInsight && { failureInsight }),
+ },
+ },
+ };
+}
+
+/**
+ * Build the empty-result message, optionally including a recovery hint when the
+ * filter contains a `flowId:eq:` clause whose value is not a UUID. The
+ * Get Flow Runs endpoint responds 404 (not an empty list) for a flowId that
+ * isn't a real, visible flow LUID — e.g. when an LLM passes a flow *name*. The
+ * callback converts that 404 into this empty result, so the hint turns an
+ * otherwise-cryptic 404 into a recoverable signal.
+ */
+function buildEmptyMessage(validatedFilter: string | undefined): string {
+ const baseline =
+ 'No flow runs were found. Either none exist, none match the filter, or you lack permission — non-admins only receive runs for flows they can run (the Execute / "Run Flow Now" capability), not flows they can only view.';
+
+ const flowIdValue = extractEqValue(validatedFilter, 'flowId');
+ if (flowIdValue && !looksLikeUuid(flowIdValue)) {
+ return [
+ baseline,
+ '',
+ `Hint: the \`flowId\` filter you supplied (\`${flowIdValue}\`) is not a UUID. The Tableau Flow Runs REST API matches \`flowId\` against the flow's LUID (8-4-4-4-12 hex form); any other value (e.g. a flow name) does not resolve to a flow and returns no runs.`,
+ '',
+ 'To recover:',
+ '1. Look up the flow id by name with the `list-flows` tool (`filter: name:eq:`) and read `id` from the response, then re-run with `flowId:eq:`, OR',
+ '2. Re-run `list-flow-runs` without the `flowId` filter to see recent runs across all flows you can access (each run includes its `flowId`).',
+ ].join('\n');
+ }
+
+ return baseline;
+}
+
+export const exportedForTesting = {
+ listFlowRunsParamsSchema: paramsSchema,
+ collectFlowRuns,
+};
diff --git a/src/tools/web/flows/listFlowRuns/mockFlowRuns.ts b/src/tools/web/flows/listFlowRuns/mockFlowRuns.ts
new file mode 100644
index 000000000..cc319419b
--- /dev/null
+++ b/src/tools/web/flows/listFlowRuns/mockFlowRuns.ts
@@ -0,0 +1,30 @@
+import { FlowRun } from '../../../../sdks/tableau/types/flow.js';
+
+export const mockFlowRuns = [
+ {
+ id: 'a1111111-1111-1111-1111-111111111111',
+ flowId: 'd00700fe-28a0-4ece-a7af-5543ddf38a82',
+ status: 'Success',
+ startedAt: '2025-01-03T10:00:00Z',
+ completedAt: '2025-01-03T10:05:00Z',
+ progress: 100,
+ backgroundJobId: 'job-1111',
+ },
+ {
+ id: 'b2222222-2222-2222-2222-222222222222',
+ flowId: 'd00700fe-28a0-4ece-a7af-5543ddf38a82',
+ status: 'Failed',
+ startedAt: '2025-01-02T10:00:00Z',
+ completedAt: '2025-01-02T10:01:00Z',
+ progress: 100,
+ backgroundJobId: 'job-2222',
+ },
+ {
+ id: 'c3333333-3333-3333-3333-333333333333',
+ flowId: 'c1e82fe3-e7cf-4bd5-afd3-799b1e8aac27',
+ status: 'InProgress',
+ startedAt: '2025-01-01T10:00:00Z',
+ progress: 42,
+ backgroundJobId: 'job-3333',
+ },
+] satisfies Array;
diff --git a/src/tools/web/flows/listFlowTasks/flowTasksFilterUtils.test.ts b/src/tools/web/flows/listFlowTasks/flowTasksFilterUtils.test.ts
new file mode 100644
index 000000000..6f160df53
--- /dev/null
+++ b/src/tools/web/flows/listFlowTasks/flowTasksFilterUtils.test.ts
@@ -0,0 +1,86 @@
+import { FlowRunTask } from '../../../../sdks/tableau/types/flowRunTask.js';
+import {
+ applyFlowTaskFilters,
+ parseAndValidateFlowTasksFilterString,
+} from './flowTasksFilterUtils.js';
+import { mockFlowRunTasks } from './mockFlowRunTasks.js';
+
+describe('parseAndValidateFlowTasksFilterString', () => {
+ it('accepts valid fields and operators', () => {
+ expect(parseAndValidateFlowTasksFilterString('flow.name:eq:Daily')).toBe('flow.name:eq:Daily');
+ expect(parseAndValidateFlowTasksFilterString('consecutiveFailedCount:gt:0')).toBe(
+ 'consecutiveFailedCount:gt:0',
+ );
+ });
+
+ it('rejects unknown fields', () => {
+ expect(() => parseAndValidateFlowTasksFilterString('datasource.id:eq:x')).toThrow();
+ });
+
+ it('rejects disallowed operators', () => {
+ expect(() => parseAndValidateFlowTasksFilterString('flow.name:gt:x')).toThrow();
+ });
+});
+
+describe('applyFlowTaskFilters', () => {
+ it('returns all tasks when no filter is supplied', () => {
+ expect(applyFlowTaskFilters(mockFlowRunTasks, undefined)).toEqual(mockFlowRunTasks);
+ });
+
+ it('filters by flow.id (eq)', () => {
+ const result = applyFlowTaskFilters(
+ mockFlowRunTasks,
+ 'flow.id:eq:8a320dca-9151-41ea-8474-a0bb71961cc0',
+ );
+ expect(result).toHaveLength(1);
+ expect(result[0].flow?.name).toBe('allUseCaseTFLX2');
+ });
+
+ it('filters by schedule.state (in: bracket/comma form, repo-canonical)', () => {
+ expect(applyFlowTaskFilters(mockFlowRunTasks, 'schedule.state:in:[Active]')).toHaveLength(1);
+ expect(
+ applyFlowTaskFilters(mockFlowRunTasks, 'schedule.state:in:[Active,Suspended]'),
+ ).toHaveLength(2);
+ });
+
+ it('filters by schedule.state (in: legacy pipe form, still accepted)', () => {
+ expect(applyFlowTaskFilters(mockFlowRunTasks, 'schedule.state:in:Active')).toHaveLength(1);
+ expect(
+ applyFlowTaskFilters(mockFlowRunTasks, 'schedule.state:in:Active|Suspended'),
+ ).toHaveLength(2);
+ });
+
+ it('keeps a bracketed in: list intact when combined with another filter (AND)', () => {
+ // The comma inside [Active,Suspended] must NOT be treated as a clause
+ // separator; the bracket and pipe forms must yield identical results.
+ const bracket = applyFlowTaskFilters(
+ mockFlowRunTasks,
+ 'schedule.state:in:[Active,Suspended],consecutiveFailedCount:gte:0',
+ );
+ const pipe = applyFlowTaskFilters(
+ mockFlowRunTasks,
+ 'schedule.state:in:Active|Suspended,consecutiveFailedCount:gte:0',
+ );
+ expect(bracket).toEqual(pipe);
+ });
+
+ it('filters by numeric consecutiveFailedCount (gt)', () => {
+ const result = applyFlowTaskFilters(mockFlowRunTasks, 'consecutiveFailedCount:gt:0');
+ expect(result).toHaveLength(1);
+ expect(result[0].consecutiveFailedCount).toBe(2);
+ });
+
+ it('combines multiple filters with AND', () => {
+ const result = applyFlowTaskFilters(
+ mockFlowRunTasks,
+ 'schedule.frequency:eq:Daily,schedule.state:eq:Suspended',
+ );
+ expect(result).toHaveLength(1);
+ expect(result[0].schedule?.frequency).toBe('Daily');
+ });
+
+ it('returns no tasks when a field is missing on every task', () => {
+ const tasksWithoutFlow: FlowRunTask[] = [{ id: 't1' }];
+ expect(applyFlowTaskFilters(tasksWithoutFlow, 'flow.id:eq:anything')).toHaveLength(0);
+ });
+});
diff --git a/src/tools/web/flows/listFlowTasks/flowTasksFilterUtils.ts b/src/tools/web/flows/listFlowTasks/flowTasksFilterUtils.ts
new file mode 100644
index 000000000..8a1f09a98
--- /dev/null
+++ b/src/tools/web/flows/listFlowTasks/flowTasksFilterUtils.ts
@@ -0,0 +1,125 @@
+import { z } from 'zod';
+
+import { FlowRunTask } from '../../../../sdks/tableau/types/flowRunTask.js';
+import {
+ applyClientSideFilters,
+ matchesClientSideFilter,
+} from '../../../../utils/clientSideFilter.js';
+import {
+ FilterOperator,
+ FilterOperatorSchema,
+ parseAndValidateFilterString,
+} from '../../../../utils/parseAndValidateFilterString.js';
+
+// Client-side filtering for flow run tasks. The Tableau "Get Flow Run Tasks"
+// endpoint (GET /sites/:siteId/tasks/runFlow) does not support server-side
+// filtering or pagination, so all tasks are fetched and filtered here.
+
+const FilterFieldSchema = z.enum([
+ 'id',
+ 'type',
+ 'priority',
+ 'consecutiveFailedCount',
+ 'flow.id',
+ 'flow.name',
+ 'schedule.id',
+ 'schedule.name',
+ 'schedule.state',
+ 'schedule.frequency',
+ 'schedule.nextRunAt',
+ 'schedule.createdAt',
+ 'schedule.updatedAt',
+]);
+
+type FilterField = z.infer;
+
+const allowedOperatorsByField: Record = {
+ id: ['eq', 'in'],
+ type: ['eq', 'in'],
+ priority: ['eq', 'gt', 'gte', 'lt', 'lte'],
+ consecutiveFailedCount: ['eq', 'gt', 'gte', 'lt', 'lte'],
+ 'flow.id': ['eq', 'in'],
+ 'flow.name': ['eq', 'in'],
+ 'schedule.id': ['eq', 'in'],
+ 'schedule.name': ['eq', 'in'],
+ 'schedule.state': ['eq', 'in'],
+ 'schedule.frequency': ['eq', 'in'],
+ 'schedule.nextRunAt': ['eq', 'gt', 'gte', 'lt', 'lte'],
+ 'schedule.createdAt': ['eq', 'gt', 'gte', 'lt', 'lte'],
+ 'schedule.updatedAt': ['eq', 'gt', 'gte', 'lt', 'lte'],
+};
+
+const _FilterExpressionSchema = z.object({
+ field: FilterFieldSchema,
+ operator: FilterOperatorSchema,
+ value: z.string(),
+});
+
+type FilterExpression = z.infer;
+
+export function parseAndValidateFlowTasksFilterString(filterString: string): string {
+ return parseAndValidateFilterString({
+ filterString,
+ allowedOperatorsByField,
+ filterFieldSchema: FilterFieldSchema,
+ });
+}
+
+/**
+ * Apply client-side filtering to flow run tasks based on filter expressions.
+ * Supports field:operator:value syntax (e.g., "schedule.frequency:eq:Daily").
+ * The `in` operator accepts the repo-canonical bracket/comma list
+ * (e.g. "schedule.state:in:[Active,Suspended]") as well as the legacy
+ * pipe-delimited form ("schedule.state:in:Active|Suspended").
+ */
+export function applyFlowTaskFilters(
+ tasks: FlowRunTask[],
+ filterString: string | undefined,
+): FlowRunTask[] {
+ return applyClientSideFilters({
+ items: tasks,
+ filterString,
+ validateFilterString: parseAndValidateFlowTasksFilterString,
+ getFieldValue,
+ });
+}
+
+function getFieldValue(task: FlowRunTask, field: FilterField): string | number | undefined {
+ switch (field) {
+ case 'id':
+ return task.id;
+ case 'type':
+ return task.type;
+ case 'priority':
+ return task.priority;
+ case 'consecutiveFailedCount':
+ return task.consecutiveFailedCount;
+ case 'flow.id':
+ return task.flow?.id;
+ case 'flow.name':
+ return task.flow?.name;
+ case 'schedule.id':
+ return task.schedule?.id;
+ case 'schedule.name':
+ return task.schedule?.name;
+ case 'schedule.state':
+ return task.schedule?.state;
+ case 'schedule.frequency':
+ return task.schedule?.frequency;
+ case 'schedule.nextRunAt':
+ return task.schedule?.nextRunAt;
+ case 'schedule.createdAt':
+ return task.schedule?.createdAt;
+ case 'schedule.updatedAt':
+ return task.schedule?.updatedAt;
+ default:
+ return undefined;
+ }
+}
+
+export const exportedForTesting = {
+ FilterFieldSchema,
+ applyFlowTaskFilters,
+ getFieldValue,
+ matchesFilter: matchesClientSideFilter,
+};
diff --git a/src/tools/web/flows/listFlowTasks/listFlowTasks.test.ts b/src/tools/web/flows/listFlowTasks/listFlowTasks.test.ts
new file mode 100644
index 000000000..b73920997
--- /dev/null
+++ b/src/tools/web/flows/listFlowTasks/listFlowTasks.test.ts
@@ -0,0 +1,244 @@
+import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
+
+import { WebMcpServer } from '../../../../server.web.js';
+import { stubDefaultEnvVars } from '../../../../testShared.js';
+import invariant from '../../../../utils/invariant.js';
+import { Provider } from '../../../../utils/provider.js';
+import { getMockRequestHandlerExtra } from '../../toolContext.mock.js';
+import { constrainFlowTasks, getListFlowTasksTool } from './listFlowTasks.js';
+import { mockFlowRunTasks } from './mockFlowRunTasks.js';
+
+const mocks = vi.hoisted(() => ({
+ mockGetFlowRunTasks: vi.fn(),
+}));
+
+vi.mock('../../../../restApiInstance.js', () => ({
+ useRestApi: vi.fn().mockImplementation(async ({ callback }) =>
+ callback({
+ tasksMethods: {
+ getFlowRunTasks: mocks.mockGetFlowRunTasks,
+ },
+ siteId: 'test-site-id',
+ }),
+ ),
+}));
+
+vi.mock('../../../../config.js', () => ({
+ getConfig: vi.fn(() => ({
+ flowToolsEnabled: true,
+ productTelemetryEnabled: false,
+ productTelemetryEndpoint: 'https://test.com',
+ server: 'https://test.tableau.com',
+ })),
+}));
+
+const NO_BOUNDED_CONTEXT = {
+ projectIds: null,
+ datasourceIds: null,
+ workbookIds: null,
+ viewIds: null,
+ tags: null,
+} as const;
+
+describe('listFlowTasksTool', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should create a tool instance with correct properties', () => {
+ const tool = getListFlowTasksTool(new WebMcpServer());
+ expect(tool.name).toBe('list-flow-tasks');
+ expect(tool.description).toContain('scheduled flow run tasks');
+ expect(tool.paramsSchema).toHaveProperty('filter');
+ expect(tool.paramsSchema).toHaveProperty('pageSize');
+ expect(tool.paramsSchema).toHaveProperty('limit');
+ });
+
+ it('is not admin-gated (callable without ADMIN_TOOLS_ENABLED)', () => {
+ const tool = getListFlowTasksTool(new WebMcpServer());
+ expect(tool.disabled).toBeFalsy();
+ });
+
+ it('should be disabled when flow tools are not enabled', async () => {
+ const { getConfig } = await import('../../../../config.js');
+ vi.mocked(getConfig).mockReturnValueOnce({
+ flowToolsEnabled: false,
+ } as ReturnType);
+ const tool = getListFlowTasksTool(new WebMcpServer());
+ expect(await Provider.from(tool.disabled)).toBe(true);
+ });
+
+ it('successfully lists flow run tasks with a resultInfo signal', async () => {
+ mocks.mockGetFlowRunTasks.mockResolvedValue(mockFlowRunTasks);
+ const result = await getToolResult({});
+ expect(result.isError).toBe(false);
+ invariant(result.content[0].type === 'text');
+ const payload = JSON.parse(result.content[0].text);
+ expect(payload.flowTasks).toEqual(mockFlowRunTasks);
+ expect(payload.mcp.resultInfo).toEqual({
+ returnedCount: 2,
+ truncated: false,
+ totalAvailable: 2,
+ });
+ expect(mocks.mockGetFlowRunTasks).toHaveBeenCalledWith({ siteId: 'test-site-id' });
+ });
+
+ it('returns an empty message when no tasks are found', async () => {
+ mocks.mockGetFlowRunTasks.mockResolvedValue([]);
+ const result = await getToolResult({});
+ expect(result.isError).toBe(false);
+ invariant(result.content[0].type === 'text');
+ expect(result.content[0].text).toContain('No flow run tasks were found');
+ });
+
+ it('handles API errors gracefully', async () => {
+ mocks.mockGetFlowRunTasks.mockRejectedValue(new Error('Tasks boom'));
+ const result = await getToolResult({});
+ expect(result.isError).toBe(true);
+ invariant(result.content[0].type === 'text');
+ expect(result.content[0].text).toContain('Tasks boom');
+ });
+
+ it('filters client-side by flow.name', async () => {
+ mocks.mockGetFlowRunTasks.mockResolvedValue(mockFlowRunTasks);
+ const result = await getToolResult({ filter: 'flow.name:eq:allUseCaseTFLX2' });
+ expect(result.isError).toBe(false);
+ invariant(result.content[0].type === 'text');
+ const payload = JSON.parse(result.content[0].text);
+ expect(payload.flowTasks).toHaveLength(1);
+ expect(payload.flowTasks[0].flow.name).toBe('allUseCaseTFLX2');
+ expect(payload.mcp.resultInfo).toEqual({
+ returnedCount: 1,
+ truncated: false,
+ totalAvailable: 1,
+ });
+ });
+
+ it('filters client-side by schedule.state with the in operator', async () => {
+ mocks.mockGetFlowRunTasks.mockResolvedValue(mockFlowRunTasks);
+ const result = await getToolResult({ filter: 'schedule.state:in:Active|Suspended' });
+ expect(result.isError).toBe(false);
+ invariant(result.content[0].type === 'text');
+ const payload = JSON.parse(result.content[0].text);
+ expect(payload.flowTasks).toHaveLength(2);
+ });
+
+ it('rejects an unsupported filter field', async () => {
+ await expect(getToolResult({ filter: 'bogus:eq:x' })).rejects.toThrow();
+ });
+
+ it('respects the limit parameter and reports requested-limit truncation', async () => {
+ mocks.mockGetFlowRunTasks.mockResolvedValue(mockFlowRunTasks);
+ const result = await getToolResult({ limit: 1 });
+ expect(result.isError).toBe(false);
+ invariant(result.content[0].type === 'text');
+ const payload = JSON.parse(result.content[0].text);
+ expect(payload.flowTasks).toHaveLength(1);
+ expect(payload.mcp.resultInfo).toEqual({
+ returnedCount: 1,
+ truncated: true,
+ truncationReason: 'requested-limit',
+ totalAvailable: 2,
+ });
+ });
+
+ it('honors an admin MAX_RESULT_LIMIT as a client-side cap and reports admin-cap', async () => {
+ vi.stubEnv('MAX_RESULT_LIMITS', 'list-flow-tasks:1');
+ try {
+ mocks.mockGetFlowRunTasks.mockResolvedValue(mockFlowRunTasks);
+ const result = await getToolResult({});
+ expect(result.isError).toBe(false);
+ invariant(result.content[0].type === 'text');
+ const payload = JSON.parse(result.content[0].text);
+ expect(payload.flowTasks).toHaveLength(1);
+ expect(payload.mcp.resultInfo).toEqual({
+ returnedCount: 1,
+ truncated: true,
+ truncationReason: 'admin-cap',
+ totalAvailable: 2,
+ });
+ } finally {
+ vi.unstubAllEnvs();
+ stubDefaultEnvVars();
+ }
+ });
+
+ describe('constrainFlowTasks', () => {
+ it('returns empty when no tasks are found', () => {
+ const result = constrainFlowTasks({
+ result: { flowTasks: [] },
+ boundedContext: NO_BOUNDED_CONTEXT,
+ });
+ invariant(result.type === 'empty');
+ expect(result.message).toContain('No flow run tasks were found');
+ });
+
+ it('returns success and carries resultInfo through when no bounded context is configured', () => {
+ const result = constrainFlowTasks({
+ result: {
+ flowTasks: mockFlowRunTasks,
+ mcp: { resultInfo: { returnedCount: 2, truncated: false, totalAvailable: 2 } },
+ },
+ boundedContext: NO_BOUNDED_CONTEXT,
+ });
+ invariant(result.type === 'success');
+ expect(result.result.flowTasks).toEqual(mockFlowRunTasks);
+ expect(result.result.mcp.resultInfo).toEqual({
+ returnedCount: 2,
+ truncated: false,
+ totalAvailable: 2,
+ });
+ });
+
+ it('preserves the truncation signal through the success path', () => {
+ const result = constrainFlowTasks({
+ result: {
+ flowTasks: [mockFlowRunTasks[0]],
+ mcp: {
+ resultInfo: {
+ returnedCount: 1,
+ truncated: true,
+ truncationReason: 'requested-limit',
+ totalAvailable: 2,
+ },
+ },
+ },
+ boundedContext: NO_BOUNDED_CONTEXT,
+ });
+ invariant(result.type === 'success');
+ expect(result.result.mcp.resultInfo).toEqual({
+ returnedCount: 1,
+ truncated: true,
+ truncationReason: 'requested-limit',
+ totalAvailable: 2,
+ });
+ });
+
+ it.each([
+ ['projectIds', { ...NO_BOUNDED_CONTEXT, projectIds: new Set(['p1']) }],
+ ['tags', { ...NO_BOUNDED_CONTEXT, tags: new Set(['t1']) }],
+ ])('fails closed under a %s bounded context', (_label, boundedContext) => {
+ const result = constrainFlowTasks({
+ result: { flowTasks: mockFlowRunTasks },
+ boundedContext: boundedContext as Parameters<
+ typeof constrainFlowTasks
+ >[0]['boundedContext'],
+ });
+ invariant(result.type === 'empty');
+ expect(result.message).toContain('limited by the server configuration');
+ });
+ });
+});
+
+async function getToolResult(args: {
+ filter?: string;
+ pageSize?: number;
+ limit?: number;
+}): Promise {
+ const tool = getListFlowTasksTool(new WebMcpServer());
+ const callback = await Provider.from(tool.callback);
+ return await callback(
+ { filter: args.filter, pageSize: args.pageSize, limit: args.limit },
+ getMockRequestHandlerExtra(),
+ );
+}
diff --git a/src/tools/web/flows/listFlowTasks/listFlowTasks.ts b/src/tools/web/flows/listFlowTasks/listFlowTasks.ts
new file mode 100644
index 000000000..7f06de9af
--- /dev/null
+++ b/src/tools/web/flows/listFlowTasks/listFlowTasks.ts
@@ -0,0 +1,254 @@
+import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
+import { Ok } from 'ts-results-es';
+import { z } from 'zod';
+
+import { getConfig } from '../../../../config.js';
+import { BoundedContext } from '../../../../overridableConfig.js';
+import { useRestApi } from '../../../../restApiInstance.js';
+import { FlowRunTask } from '../../../../sdks/tableau/types/flowRunTask.js';
+import { WebMcpServer } from '../../../../server.web.js';
+import { ConstrainedResult, WebTool } from '../../tool.js';
+import { buildTruncationInfo, ListFlowsTruncationReason } from '../listFlows/listFlows.js';
+import {
+ applyFlowTaskFilters,
+ parseAndValidateFlowTasksFilterString,
+} from './flowTasksFilterUtils.js';
+
+const paramsSchema = {
+ filter: z.string().optional(),
+ pageSize: z.number().int().positive().optional(),
+ limit: z.number().int().positive().optional(),
+};
+
+/**
+ * Truncation reasons for list-flow-tasks. There is no `default-cap` (unlike
+ * list-flow-runs): the Get Flow Run Tasks endpoint has no server-side paging,
+ * so the whole set is always fetched and an unbounded call returns everything.
+ */
+export type ListFlowTasksResultInfo = {
+ returnedCount: number;
+ truncated: boolean;
+ truncationReason?: ListFlowsTruncationReason;
+ // Always known — every matching task is fetched before the limit is applied.
+ totalAvailable: number;
+};
+
+/**
+ * Wrapped result: `flowTasks` is the (possibly truncated) array and
+ * `mcp.resultInfo` (always present) reports whether that array is complete.
+ */
+export type ListFlowTasksResult = {
+ flowTasks: FlowRunTask[];
+ mcp: {
+ resultInfo: ListFlowTasksResultInfo;
+ };
+};
+
+export const getListFlowTasksTool = (server: WebMcpServer): WebTool => {
+ const config = getConfig();
+
+ const listFlowTasksTool = new WebTool({
+ server,
+ name: 'list-flow-tasks',
+ disabled: !config.flowToolsEnabled,
+ description: `
+ Retrieves the scheduled flow run tasks on a Tableau site. A flow run task is the **schedule** for a Tableau Prep flow — when and how often it is configured to run — NOT a record of past executions (for run history use the \`list-flow-runs\` tool). Each task includes the target flow (\`flow.id\`, \`flow.name\`), the schedule (frequency, next run time, state), and the task \`id\` used to trigger an on-demand run.
+
+ Use this tool to answer questions like:
+ - "Which flows are scheduled, and how often do they run?"
+ - "When does flow X run next?"
+ - "Are any flow schedules suspended / failing repeatedly?"
+
+ **Caller-role visibility**
+ - **Non-admin** callers get the scheduled tasks only for flows they own.
+ - **Admin** callers get every scheduled flow task on the site.
+
+ **Parameters:**
+ - \`filter\` (optional) – Client-side filter string \`field:operator:value\`. Multiple filters are comma-separated (AND logic). The Tableau REST API does not support server-side filtering for this endpoint, so all tasks are fetched and filtered client-side.
+ - \`pageSize\` (optional) – Maximum results per page (client-side, applied after filtering).
+ - \`limit\` (optional) – Maximum total results to return (client-side, applied after filtering).
+
+ **Response-Size Guidance** — this endpoint has no server-side filtering or pagination, so the tool fetches **every** scheduled task on the site before applying \`filter\`/\`limit\`. On large sites that is a big, slow response, so favour narrow calls:
+ - One flow's schedule: \`filter: "flow.id:eq:"\`.
+ - Only failing schedules: \`filter: "consecutiveFailedCount:gt:0"\`.
+ - Only active daily schedules: \`filter: "schedule.frequency:eq:Daily,schedule.state:eq:Active"\`.
+ - For a quick existence check ("are any flows scheduled?"), pass a small \`limit\` (e.g. 10).
+
+ **Filterable Fields:**
+
+ | Field | Type | Operators | Example |
+ |-------|------|-----------|---------|
+ | \`id\` | string | \`eq\`, \`in\` | \`id:eq:1bff10bb-57ae-43df-8774-a86d14aef432\` |
+ | \`type\` | string | \`eq\`, \`in\` | \`type:eq:RunFlowTask\` |
+ | \`priority\` | number | \`eq\`, \`gt\`, \`gte\`, \`lt\`, \`lte\` | \`priority:gte:5\` |
+ | \`consecutiveFailedCount\` | number | \`eq\`, \`gt\`, \`gte\`, \`lt\`, \`lte\` | \`consecutiveFailedCount:gt:0\` |
+ | \`flow.id\` | string | \`eq\`, \`in\` | \`flow.id:eq:8a320dca-9151-41ea-8474-a0bb71961cc0\` |
+ | \`flow.name\` | string | \`eq\`, \`in\` | \`flow.name:eq:Daily Sales\` |
+ | \`schedule.id\` | string | \`eq\`, \`in\` | \`schedule.id:eq:36d6fab2-2a0a-432e-b464-9fe4229a9937\` |
+ | \`schedule.name\` | string | \`eq\`, \`in\` | \`schedule.name:eq:Daily Refresh\` |
+ | \`schedule.state\` | string | \`eq\`, \`in\` | \`schedule.state:eq:Active\` |
+ | \`schedule.frequency\` | string | \`eq\`, \`in\` | \`schedule.frequency:eq:Daily\` |
+ | \`schedule.nextRunAt\` | string (ISO 8601) | \`eq\`, \`gt\`, \`gte\`, \`lt\`, \`lte\` | \`schedule.nextRunAt:lt:2026-05-25T00:00:00Z\` |
+ | \`schedule.createdAt\` | string (ISO 8601) | \`eq\`, \`gt\`, \`gte\`, \`lt\`, \`lte\` | \`schedule.createdAt:gte:2026-01-01T00:00:00Z\` |
+ | \`schedule.updatedAt\` | string (ISO 8601) | \`eq\`, \`gt\`, \`gte\`, \`lt\`, \`lte\` | \`schedule.updatedAt:gte:2026-05-01T00:00:00Z\` |
+
+ **Filter Examples:**
+ - Single filter: \`schedule.frequency:eq:Daily\`
+ - Multiple filters (AND): \`schedule.frequency:eq:Daily,schedule.state:eq:Active\`
+ - IN operator (bracket/comma list): \`schedule.state:in:[Active,Suspended]\` (pipe form \`Active|Suspended\` is also accepted)
+ - Failing schedules: \`consecutiveFailedCount:gt:0\`
+
+ **Response:** Returns \`{ flowTasks: [...], mcp: { resultInfo } }\`. Each task in \`flowTasks\` includes:
+ - \`id\` – flow run task ID (use this as the task id to run the flow on demand)
+ - \`flow.id\`, \`flow.name\` – the target flow
+ - \`schedule\` – frequency, nextRunAt, state, name, and timestamps
+ - \`priority\`, \`consecutiveFailedCount\`, \`type\`
+
+ \`mcp.resultInfo\` is ALWAYS present and reports completeness: \`returnedCount\`, \`totalAvailable\` (the full count matching the filter — always known here because every task is fetched), \`truncated\`, and \`truncationReason\` (\`requested-limit\` | \`admin-cap\`).
+
+ **Reporting to the user (every call):** translate \`mcp.resultInfo\` into one plain sentence — never say "resultInfo". \`truncated:false\` → "these are all N scheduled tasks". \`requested-limit\` → "showing the first N of M — say if you want the rest". \`admin-cap\` → "showing the first N of M; a site limit caps results per call — I can narrow the filter, or an admin can raise the cap". Also surface the **Caller-role visibility** limit when presenting results: non-admins see scheduled tasks ONLY for flows they **own** (not flows merely shared with them), so a scheduled flow they can see in the Tableau web UI may be missing here. Call this out especially on empty or unexpectedly short results. (Site/server administrators are exempt — they get every scheduled flow task, so do not state this limit to an admin caller.)
+
+ **Note:** Requires Tableau REST API access scope \`tableau:flow_tasks:read\`. Date-time filter values must be full ISO 8601 (e.g. \`2026-05-25T00:00:00Z\`). The Tableau REST API does not support server-side filtering or pagination for this endpoint — all tasks are retrieved and filtered/limited client-side by this tool.
+ `,
+ paramsSchema,
+ annotations: {
+ title: 'List Flow Tasks',
+ readOnlyHint: true,
+ destructiveHint: false,
+ idempotentHint: true,
+ openWorldHint: false,
+ },
+ callback: async (args, extra): Promise => {
+ const configWithOverrides = await extra.getConfigWithOverrides();
+
+ // Validate the filter string early so a malformed filter fails fast with a
+ // clear error before any network call.
+ if (args.filter) {
+ parseAndValidateFlowTasksFilterString(args.filter);
+ }
+
+ return await listFlowTasksTool.logAndExecute({
+ extra,
+ args,
+ callback: async () => {
+ const tasks = await useRestApi({
+ ...extra,
+ jwtScopes: listFlowTasksTool.requiredApiScopes,
+ callback: async (restApi) =>
+ restApi.tasksMethods.getFlowRunTasks({
+ siteId: restApi.siteId,
+ }),
+ });
+
+ const filteredTasks = applyFlowTaskFilters(tasks, args.filter);
+ // The whole matching set is in hand (the endpoint has no server-side
+ // paging or filtering), so `totalAvailable` is always known exactly.
+ const totalAvailable = filteredTasks.length;
+
+ // Honor an admin MAX_RESULT_LIMIT alongside the caller's pageSize/limit
+ // (consistent with list-flows / list-flow-runs). NOTE: because the whole
+ // set is always fetched first, this cap only bounds the payload handed
+ // back to the caller — it does not reduce server work or latency.
+ const maxResultLimit = configWithOverrides.getMaxResultLimit(listFlowTasksTool.name);
+ const callerLimit =
+ args.limit !== undefined || args.pageSize !== undefined
+ ? Math.min(
+ args.pageSize ?? Number.MAX_SAFE_INTEGER,
+ args.limit ?? Number.MAX_SAFE_INTEGER,
+ )
+ : undefined;
+ const effectiveLimit = Math.min(
+ callerLimit ?? Number.MAX_SAFE_INTEGER,
+ maxResultLimit ?? Number.MAX_SAFE_INTEGER,
+ );
+
+ const limitedTasks =
+ effectiveLimit < Number.MAX_SAFE_INTEGER
+ ? filteredTasks.slice(0, effectiveLimit)
+ : filteredTasks;
+
+ // Signal completeness so the LLM never reports a capped list as the
+ // full set (mirrors list-flows / list-flow-runs resultInfo).
+ const { truncated, truncationReason } = buildTruncationInfo({
+ truncatedByLimit: totalAvailable > limitedTasks.length,
+ maxResultLimit,
+ llmLimit: callerLimit,
+ effectiveLimit,
+ });
+
+ return new Ok({
+ flowTasks: limitedTasks,
+ mcp: {
+ resultInfo: {
+ returnedCount: limitedTasks.length,
+ truncated,
+ ...(truncationReason && { truncationReason }),
+ totalAvailable,
+ },
+ },
+ } satisfies ListFlowTasksResult);
+ },
+ constrainSuccessResult: (result) =>
+ constrainFlowTasks({
+ result,
+ boundedContext: configWithOverrides.boundedContext,
+ }),
+ });
+ },
+ });
+
+ return listFlowTasksTool;
+};
+
+export function constrainFlowTasks({
+ result,
+ boundedContext,
+}: {
+ // Tolerates a missing `mcp.resultInfo` (treated as "complete") so unit tests
+ // can pass a bare `{ flowTasks }`.
+ result: { flowTasks: FlowRunTask[]; mcp?: { resultInfo?: ListFlowTasksResultInfo } };
+ boundedContext: BoundedContext;
+}): ConstrainedResult {
+ // Fail closed: a flow run task carries the flow's id/name but no project or
+ // tag, so when the server is restricted to a PROJECT_IDS / TAGS bounded
+ // context we cannot prove a task's flow belongs to the allowed set. Refuse
+ // rather than risk leaking schedules for flows outside the allow-list.
+ // (datasource/workbook/view bounded contexts do not constrain flows.)
+ const { projectIds, tags } = boundedContext;
+ if (projectIds || tags) {
+ return {
+ type: 'empty',
+ message: [
+ 'The set of content that can be queried is limited by the server configuration (an allowed-projects or tags bounded context is active).',
+ 'Flow run tasks are not associated with a project or tag, so this tool cannot verify that a task belongs to the allowed set and does not return flow tasks under this configuration.',
+ ].join(' '),
+ };
+ }
+
+ if (result.flowTasks.length === 0) {
+ return {
+ type: 'empty',
+ message:
+ 'No flow run tasks were found. Either none are scheduled, none match the filter, or you do not have permission to view them.',
+ };
+ }
+
+ const truncated = result.mcp?.resultInfo?.truncated ?? false;
+ const truncationReason = result.mcp?.resultInfo?.truncationReason;
+ const totalAvailable = result.mcp?.resultInfo?.totalAvailable ?? result.flowTasks.length;
+
+ return {
+ type: 'success',
+ result: {
+ flowTasks: result.flowTasks,
+ mcp: {
+ resultInfo: {
+ returnedCount: result.flowTasks.length,
+ truncated,
+ ...(truncationReason && { truncationReason }),
+ totalAvailable,
+ },
+ },
+ },
+ };
+}
diff --git a/src/tools/web/flows/listFlowTasks/mockFlowRunTasks.ts b/src/tools/web/flows/listFlowTasks/mockFlowRunTasks.ts
new file mode 100644
index 000000000..a0290cf05
--- /dev/null
+++ b/src/tools/web/flows/listFlowTasks/mockFlowRunTasks.ts
@@ -0,0 +1,46 @@
+import { FlowRunTask } from '../../../../sdks/tableau/types/flowRunTask.js';
+
+export const mockFlowRunTasks = [
+ {
+ id: '1bff10bb-57ae-43df-8774-a86d14aef432',
+ priority: 50,
+ consecutiveFailedCount: 2,
+ type: 'RunFlowTask',
+ schedule: {
+ id: '36d6fab2-2a0a-432e-b464-9fe4229a9937',
+ name: 'Every 2 Minutes',
+ state: 'Active',
+ priority: 50,
+ createdAt: '2018-11-08T21:57:49Z',
+ updatedAt: '2018-11-09T17:30:08Z',
+ type: 'Flow',
+ frequency: 'Hourly',
+ nextRunAt: '2018-11-09T17:32:00Z',
+ },
+ flow: {
+ id: '8a320dca-9151-41ea-8474-a0bb71961cc0',
+ name: 'allUseCaseTFLX2',
+ },
+ },
+ {
+ id: '357aaf2b-758d-4feb-a447-822528635a67',
+ priority: 44,
+ consecutiveFailedCount: 0,
+ type: 'RunFlowTask',
+ schedule: {
+ id: '6d4e61d0-2e2f-4f48-abc8-0695b95a5287',
+ name: 'Every 15 mins',
+ state: 'Suspended',
+ priority: 50,
+ createdAt: '2018-11-08T20:45:47Z',
+ updatedAt: '2018-11-09T17:30:08Z',
+ type: 'Flow',
+ frequency: 'Daily',
+ nextRunAt: '2018-11-09T17:45:00Z',
+ },
+ flow: {
+ id: 'd00700fe-28a0-4ece-a7af-5543ddf38a82',
+ name: 'SQLServerUserNamePassword Good',
+ },
+ },
+] satisfies Array;
diff --git a/src/tools/web/toolName.ts b/src/tools/web/toolName.ts
index 2b42ae1bb..94bab34a3 100644
--- a/src/tools/web/toolName.ts
+++ b/src/tools/web/toolName.ts
@@ -18,6 +18,8 @@ export const webToolNames = [
'get-workbook',
'get-view',
'get-flow',
+ 'list-flow-runs',
+ 'list-flow-tasks',
'get-view-data',
'get-view-image',
'get-custom-view-data',
@@ -77,7 +79,7 @@ export const webToolGroups = {
'get-custom-view-data',
'get-custom-view-image',
],
- flow: ['list-flows', 'get-flow'],
+ flow: ['list-flows', 'get-flow', 'list-flow-runs', 'list-flow-tasks'],
pulse: [
'list-all-pulse-metric-definitions',
'list-pulse-metric-definitions-from-definition-ids',
diff --git a/src/tools/web/tools.ts b/src/tools/web/tools.ts
index ac839a76f..56844b0cb 100644
--- a/src/tools/web/tools.ts
+++ b/src/tools/web/tools.ts
@@ -8,7 +8,9 @@ import { getConfirmUpdateCloudExtractRefreshTaskTool } from './extractRefreshTas
import { getListExtractRefreshTasksTool } from './extractRefreshTasks/listExtractRefreshTasks.js';
import { getUpdateCloudExtractRefreshTaskTool } from './extractRefreshTasks/updateCloudExtractRefreshTask.js';
import { getGetFlowTool } from './flows/getFlow/getFlow.js';
+import { getListFlowRunsTool } from './flows/listFlowRuns/listFlowRuns.js';
import { getListFlowsTool } from './flows/listFlows/listFlows.js';
+import { getListFlowTasksTool } from './flows/listFlowTasks/listFlowTasks.js';
import { getGetDatasourceMetadataTool } from './getDatasourceMetadata/getDatasourceMetadata.js';
import { getEmbedTokenTool } from './getEmbedToken/getEmbedToken.js';
import { getListJobsTool } from './jobs/listJobs.js';
@@ -52,6 +54,8 @@ export const webToolFactories = [
getQueryDatasourceTool,
getListFlowsTool,
getGetFlowTool,
+ getListFlowRunsTool,
+ getListFlowTasksTool,
getListAllPulseMetricDefinitionsTool,
getListPulseMetricDefinitionsFromDefinitionIdsTool,
getListPulseMetricsFromMetricDefinitionIdTool,
diff --git a/src/utils/clientSideFilter.ts b/src/utils/clientSideFilter.ts
new file mode 100644
index 000000000..2772b5c22
--- /dev/null
+++ b/src/utils/clientSideFilter.ts
@@ -0,0 +1,89 @@
+import { FilterOperator, splitTopLevel } from './parseAndValidateFilterString.js';
+
+export type ClientSideFilterValue = string | number | null | undefined;
+
+type FilterExpression = {
+ field: TField;
+ operator: FilterOperator;
+ value: string;
+};
+
+export function applyClientSideFilters({
+ items,
+ filterString,
+ validateFilterString,
+ getFieldValue,
+}: {
+ items: TItem[];
+ filterString: string | undefined;
+ validateFilterString: (filterString: string) => string;
+ getFieldValue: (item: TItem, field: TField) => ClientSideFilterValue;
+}): TItem[] {
+ if (!filterString) {
+ return items;
+ }
+
+ const validatedFilter = validateFilterString(filterString);
+ const filters: FilterExpression[] = splitTopLevel(validatedFilter, ',')
+ .map((expression) => expression.trim())
+ .filter(Boolean)
+ .map((expression) => {
+ const [field, operator, ...valueParts] = expression.split(':');
+ return {
+ field: field as TField,
+ operator: operator as FilterOperator,
+ value: valueParts.join(':'),
+ };
+ });
+
+ return items.filter((item) =>
+ filters.every(({ field, operator, value }) =>
+ matchesClientSideFilter(getFieldValue(item, field), operator, value),
+ ),
+ );
+}
+
+export function matchesClientSideFilter(
+ fieldValue: ClientSideFilterValue,
+ operator: FilterOperator,
+ filterValue: string,
+): boolean {
+ if (fieldValue === undefined || fieldValue === null) {
+ return false;
+ }
+
+ const fieldString = String(fieldValue);
+
+ switch (operator) {
+ case 'eq':
+ return fieldString === filterValue;
+ case 'in':
+ return parseInValues(filterValue).includes(fieldString);
+ case 'gt':
+ return typeof fieldValue === 'number'
+ ? fieldValue > Number(filterValue)
+ : fieldString > filterValue;
+ case 'gte':
+ return typeof fieldValue === 'number'
+ ? fieldValue >= Number(filterValue)
+ : fieldString >= filterValue;
+ case 'lt':
+ return typeof fieldValue === 'number'
+ ? fieldValue < Number(filterValue)
+ : fieldString < filterValue;
+ case 'lte':
+ return typeof fieldValue === 'number'
+ ? fieldValue <= Number(filterValue)
+ : fieldString <= filterValue;
+ default:
+ return false;
+ }
+}
+
+function parseInValues(value: string): string[] {
+ const inner = value.startsWith('[') && value.endsWith(']') ? value.slice(1, -1) : value;
+ return inner
+ .split(/[,|]/)
+ .map((item) => item.trim())
+ .filter(Boolean);
+}
diff --git a/src/utils/parseAndValidateFilterString.ts b/src/utils/parseAndValidateFilterString.ts
index f3a0a199b..12d97af77 100644
--- a/src/utils/parseAndValidateFilterString.ts
+++ b/src/utils/parseAndValidateFilterString.ts
@@ -44,7 +44,12 @@ function normalizeDateTimeValue(field: string, value: string): string {
);
}
-const dateTimeFields = ['createdAt', 'updatedAt'];
+// Filter fields whose values are date-times and therefore get ISO-8601
+// validation + date-only auto-promotion (see normalizeDateTimeValue). Only
+// fields with these exact names are affected, so adding flow-run date fields
+// (startedAt/completedAt) here is inert for every other tool, none of which
+// filters on a bare field of that name.
+const dateTimeFields = ['createdAt', 'updatedAt', 'startedAt', 'completedAt'];
/**
* Reject filter strings whose `[...]` brackets are unbalanced before any
@@ -94,7 +99,7 @@ function validateBracketBalance(filterString: string): void {
* `depth === 0`. Leading/trailing whitespace and empty segments are stripped
* by the caller's `.map(trim).filter(Boolean)` chain.
*/
-function splitTopLevel(s: string, sep: string): string[] {
+export function splitTopLevel(s: string, sep: string): string[] {
const out: string[] = [];
let cur = '';
let depth = 0;
diff --git a/tests/eval/flows.test.ts b/tests/eval/flows.test.ts
index 99612db65..339f483fe 100644
--- a/tests/eval/flows.test.ts
+++ b/tests/eval/flows.test.ts
@@ -99,4 +99,40 @@ describe('flows tool descriptions (eval)', () => {
expect(getFlow.arguments.includeFlowRuns).not.toBe(false);
expect(Number(getFlow.arguments.flowRunLimit ?? 10)).toBeLessThanOrEqual(3);
});
+
+ it('list-flow-runs: derives a status=Failed filter for a cross-flow failure question', async () => {
+ const prompt =
+ 'Across all my Tableau Prep flows, which runs have failed? Just list the failed runs; no analysis.';
+
+ const stream = await runAgentWithTools(mcpServer, getModel(), prompt);
+ const toolExecutions = await getToolExecutions(stream);
+
+ const listFlowRuns = toolExecutions.find(
+ (toolExecution) => toolExecution.name === 'list_flow_runs',
+ );
+ invariant(listFlowRuns, 'list_flow_runs tool execution not found');
+
+ // A cross-flow "which runs failed" question is the dedicated run-history
+ // tool's job (not get-flow, which targets one flow), and `status` is the
+ // discriminating filter. It is client-side, but the model should still pass
+ // it so the tool can apply it.
+ const filter = String(listFlowRuns.arguments.filter ?? '');
+ expect(filter).toContain('status:');
+ expect(filter).toContain('Failed');
+ });
+
+ it('list-flow-tasks: selects the schedule tool for a "how often / next run" question', async () => {
+ const prompt =
+ 'How often is each of my Tableau Prep flows scheduled to run, and when do they run next? Just list the schedules.';
+
+ const stream = await runAgentWithTools(mcpServer, getModel(), prompt);
+ const toolExecutions = await getToolExecutions(stream);
+
+ // "Scheduled to run / next run" is the flow-tasks (schedule) tool, NOT
+ // list-flow-runs (past executions) or get-flow (single-flow metadata).
+ const listFlowTasks = toolExecutions.find(
+ (toolExecution) => toolExecution.name === 'list_flow_tasks',
+ );
+ invariant(listFlowTasks, 'list_flow_tasks tool execution not found');
+ });
});