# Semaphor MCP

> Everything an agent needs to connect to the Semaphor MCP server, discover governed data, query it, read dashboards and make previewed changes. Generated from https://docs.semaphor.cloud/docs/mcp.

# MCP Overview

Connect Claude, Codex, Cursor and your own agents to Semaphor analytics using the Model Context Protocol.

Source: https://docs.semaphor.cloud/docs/mcp/overview

Semaphor runs a [Model Context Protocol](https://modelcontextprotocol.io/) server that lets agents discover and query your analytics. Agents ground answers in your semantic model, run governed queries under the caller's permissions, read your dashboards, and, with write access, make changes you preview first.

Building an agent? [Build an Agent](/docs/mcp/build-an-agent) is one page with everything it needs, and [`/llms-full.txt`](/llms-full.txt) holds all of these pages as one Markdown document to hand to Claude or any other agent. Every page also has **Copy Markdown**.

## One endpoint, one caller profile, one analytics spine

Every request to `https://semaphor.cloud/api/mcp` carries a credential. The server verifies it, builds a caller profile (who you are, which projects you can reach, your scopes, and whether you get SQL), and registers only the tools that profile may use. Tool calls then run through the same governed path as your dashboards. On a self-hosted install, use your Semaphor URL followed by `/api/mcp`.

**Diagram:** A request goes from the MCP client to Semaphor, where the credential is verified and turned into a caller profile. The profile decides the tool list. A tool call has its arguments checked, is grounded in the semantic model, compiles into a query under your security policies, and returns a structured result or an error with a code.

_The caller profile is built once per request from the verified credential. The tool list, the project a session is pinned to, and whether SQL and changes are allowed all come from the profile, never from how the caller logged in._

## Who's calling decides what they see

There are three ways in. Pick one to see how it logs in, what it can do, and what a failure looks like.

### Interactive: A person in an AI client

You connect your AI client and log in with your Semaphor account.

1. Add `https://semaphor.cloud/api/mcp` to Claude, ChatGPT, Codex or Cursor, or install the Semaphor plugin.
2. Your client opens the Semaphor login.
3. Approve read access, and write access if the agent should make changes.
4. Ask a question. With one project, the agent doesn’t need to ask which.

- **Reach:** Every project you belong to
- **Tools:** 20 with write · 19 read only · 13 as a viewer
- **SQL:** Author role or higher
- **Changes:** Dashboards, generated dashboards and relationships; applying needs write
- **Tool families:** Discovery, Analyze, Matrix, Dashboards, SQL, Changes, Project list

Several agents can share one login. When it expires, your client asks you to log in again.

### Headless: Automation

CI jobs, schedulers and scripts connect with a token your backend mints.

1. Mint a token with `POST /api/v1/token`, adding the `mcp` option and an `orgUserId`.
2. Send it as `Authorization: Bearer <token>`. No browser involved.
3. Mint a new token before it expires: one hour by default, 24 at most.

- **Reach:** One project, fixed by the token
- **Tools:** 19 with write · 18 read only · 12 as a viewer
- **SQL:** Author role or higher, unless the token turns it off
- **Changes:** Dashboards and generated dashboards; applying needs write
- **Tool families:** Discovery, Analyze, Matrix, Dashboards, SQL, Changes

An expired token gets a 401 with no login link, because a script can’t log in.

### Embedded: An end user in your product

An agent inside your product acts for one signed-in end user.

1. Mint a token for the end user with their `endUserId` and `tenantId`.
2. The agent in your product calls the MCP with that token.
3. Answers include only the rows and fields that user may see.

- **Reach:** One project and one tenant
- **Tools:** 17 with SQL · 12 without; a Power User adds the change tools
- **SQL:** On by default; turn it off per token
- **Changes:** Power Users, on dashboards they can edit; applying needs write
- **Tool families:** Discovery, Analyze, Matrix, Dashboards, SQL, Changes

Dashboard embed tokens are refused. Mint the token with the `mcp` option.

| Caller | Credential | Tools | What's different |
| --- | --- | --- | --- |
| Org admin in an AI client, write | OAuth login | 20 | Everything, including the change tools |
| Org admin in an AI client, read only | OAuth login | 19 | Can propose changes, not apply them |
| Org viewer in an AI client | OAuth login | 13 | No SQL, physical tables or change tools |
| Org admin automation, write | MCP project token | 19 | No project list; pinned to one project |
| Org admin automation, read only | MCP project token | 18 | No project list; can propose, not apply |
| End user, SQL on | MCP project token | 17 | Read, analyze, SQL and physical discovery under their policies |
| End user, SQL off | MCP project token | 12 | Semantic model only |
| End user with the Power User role, write | MCP project token | 19 | Also changes the dashboards they can edit |

Hiding a tool is for clarity, not the security control: the server still authorizes every call. SQL-shaped arguments inside shared tools (a physical source in [`semaphor_matrix`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_matrix), for example) return `capability_required` for callers without SQL. See [Tools Reference](/docs/mcp/tools-reference#who-sees-which-tools).

## What agents can do

- **[Discover the data](/docs/mcp/discover-and-query)** ([`semaphor_get_analysis_context`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_get_analysis_context)): List projects, semantic domains, datasets, fields and relationships. Callers with SQL access can also browse connections, schemas and tables.
- **[Ask governed questions](/docs/mcp/discover-and-query)** (`semaphor_analyze · semaphor_matrix`): Run analytics over the semantic model, or SQL with semaphor_query_sql_advanced when a question needs it. Two people can get different numbers for the same question.
- **[Work from your dashboards](/docs/mcp/tools-reference#dashboards)** ([`semaphor_list_dashboards`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_list_dashboards)): List dashboards, see who can actually open them, and read a dashboard's cards, filters and saved queries.
- **[Make changes, with a preview](/docs/mcp/changes)** ([`semaphor_propose_change`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_propose_change)): Add, change and remove dashboard cards and filters, arrange a layout, generate a dashboard, or add a relationship to a semantic model. Every change is previewed first; saving it needs write access.

## Every answer is grounded, then governed

An agent answering a question starts with [`semaphor_get_analysis_context`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_get_analysis_context), finds the exact domain, dataset and fields, and runs [`semaphor_analyze`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_analyze). Nothing is guessed, and every query runs under the caller's security policies. See [Discover and Query](/docs/mcp/discover-and-query).

**Diagram:** A question in plain words goes to semaphor_get_analysis_context, which recommends the path. The agent grounds the fields through the semantic model's domains, datasets and dataset schema, then runs semaphor_analyze, which compiles a governed query under the caller's security policies and returns the rows with what it applied. A question about a dashboard card skips discovery: semaphor_analyze with fromCard runs the card as users see it. Without a semantic model, a caller with SQL access browses tables and uses semaphor_query_sql_advanced.

_The agent never guesses a name: every domain, dataset and field comes from discovery, and a wrong one fails with the valid choices. Each answer says whether it was answered in full, and which filters and date windows it applied or skipped. Two people can get different numbers for the same question, because each sees only their own data._

## Every change is a preview, then an apply

[`semaphor_propose_change`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_propose_change) saves nothing and returns one sentence per change, with the new numbers. [`semaphor_apply_change`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_apply_change) applies exactly that preview by its `proposalId`. New capabilities arrive as new changes, never new tools.

**Diagram:** An agent reads the dashboard's outline, then calls semaphor_propose_change, which saves nothing and returns one sentence per change, a preview and a risk. The user reads the sentences and confirms. The agent calls semaphor_apply_change, which saves exactly what was previewed, or fails and writes nothing. A retry of an applied proposal returns the same result. If someone saved the dashboard after the preview, apply returns proposal_stale and the agent reads the outline again.

_Proposing saves nothing and needs no write access; applying needs mcp:write and the user's yes. A proposal expires after 30 minutes. If the person can view the dashboard but not edit it, the agent proposes with saveAs: "copy" and the change is saved as their private copy._

### Change kinds

**Dashboard changes**: `card.add`, `card.update`, `card.remove`, the `filter.*` changes and `layout.arrange`, on a dashboard you can edit, or as your own private copy. Organization authors and end users with the Power User role.
**`dashboard.generate`**: plans a new private dashboard from a prompt over one semantic domain. Organization authors, logged in or with a token.
**`relationship.add`**: adds a relationship between two datasets. Needs an interactive login, because every query on the domain can use it.

### Permission is not approval

The server enforces permission: write scope, an Author role, the credential rule for each kind, and project access. Approval is the client's job: the apply tool is marked destructive, so clients that honor annotations ask you before running it. For automation tokens, minting write scope is the approval.

## What building against it looks like

### Connect an agent with a token

Mint a token on your server:

```json title="POST /api/v1/token"
{
  "projectId": "p_...",
  "projectSecret": "...",
  "orgUserId": "...",
  "mcp": { "scopes": ["mcp:read"], "sql": true },
  "tokenExpiry": 3600
}
```

Connect any client that takes a header:

```json title=".mcp.json"
{
  "mcpServers": {
    "semaphor": {
      "type": "http",
      "url": "https://semaphor.cloud/api/mcp",
      "headers": { "Authorization": "Bearer ${SEMAPHOR_MCP_TOKEN}" }
    }
  }
}
```

### What to expect

[`semaphor_get_access_context`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_get_access_context) answers "why is this tool missing": mode, credential, scopes and whether SQL is on.
Results are JSON by default and arrive as structured content, checked against each tool's output schema.
Errors carry a stable `code` your code can branch on.
A misspelled argument fails with its name instead of being silently dropped.

Error codes worth knowing

| | |
| --- | --- |
| `write_scope_required` | Log in with write, or mint with `mcp:write` |
| `capability_required` | The argument needs SQL; use the semantic form |
| `missing_measure` | Analyze needs a measure; `__count` counts rows |
| `tool_timeout` | The call ran too long; ask a narrower question |
| `proposal_stale` | The target changed; propose again |
| `apply_outcome_unknown` | Check the target before proposing again |

## Get started

- [Build an Agent](/docs/mcp/build-an-agent): Everything an agent needs in one page: connecting, the rules, four complete call sequences.

* [Connect an AI Client](/docs/mcp/connect-ai-client): Claude Code, Codex, claude.ai, ChatGPT or Cursor: log in and ask a question.

- [Connect with a Token](/docs/mcp/connect-with-token): Mint an MCP project token for automation or an agent in your product.

* [Discover and Query](/docs/mcp/discover-and-query): How agents find the right data and run governed queries.

- [Making Changes](/docs/mcp/changes): Preview and apply dashboard and semantic model changes.

* [Authentication and Security](/docs/mcp/authentication-and-security): Logins, scopes, isolation and how your policies apply.

- [Tools Reference](/docs/mcp/tools-reference): Every tool, its arguments, and who can use it.

# Build an Agent

Everything an agent needs to work with Semaphor MCP, in one page you can hand to Claude or any other agent.

Source: https://docs.semaphor.cloud/docs/mcp/build-an-agent

This page is written for two readers: you, and the agent you're building. Use **Copy Markdown** above to give it to Claude, Codex or your own agent as instructions, or point the agent at [`/llms-full.txt`](/llms-full.txt), which holds every MCP page as one Markdown document.

It covers how to connect, the rules the server expects an agent to follow, the four common jobs as complete call sequences, and what to do when a call fails. The [Tools Reference](/docs/mcp/tools-reference) has every argument of every tool.

## Connect

| | |
| --- | --- |
| Endpoint | `https://semaphor.cloud/api/mcp` (self-hosted: your Semaphor URL followed by `/api/mcp`) |
| Transport | MCP over Streamable HTTP |
| Interactive clients | OAuth: the client opens a Semaphor login. See [Connect an AI Client](/docs/mcp/connect-ai-client). |
| Your own agent or backend | A bearer token: `Authorization: Bearer <MCP project token>`. See [Connect with a Token](/docs/mcp/connect-with-token). |

Mint a token on your server, never in a browser:

```json title="POST /api/v1/token"
{
  "projectId": "p_123",
  "projectSecret": "ps_...",
  "orgUserId": "ou_123",
  "mcp": { "scopes": ["mcp:read"], "sql": true },
  "tokenExpiry": 3600
}
```

Add `"mcp:write"` to `scopes` only for an agent that should save changes. Pass `endUserId` (and its tenant) instead of `orgUserId` for an agent that acts as one of your end users, under their security policies.

```json title=".mcp.json for Claude Code and other clients that take a header"
{
  "mcpServers": {
    "semaphor": {
      "type": "http",
      "url": "https://semaphor.cloud/api/mcp",
      "headers": { "Authorization": "Bearer ${SEMAPHOR_MCP_TOKEN}" }
    }
  }
}
```

The server lists only the tools your credential can use: a token pinned to one project has no project list, a session without SQL has no SQL or physical tools, and a session without `mcp:write` has no apply tool.

### What a call looks like on the wire

If you're writing your own MCP client, every tool call is a JSON-RPC `tools/call`:

```json title="Request"
{
  "jsonrpc": "2.0",
  "id": 7,
  "method": "tools/call",
  "params": {
    "name": "semaphor_get_analysis_context",
    "arguments": {}
  }
}
```

```json title="Response"
{
  "jsonrpc": "2.0",
  "id": 7,
  "result": {
    "structuredContent": { "recommendedPath": "semantic", "recommendedNextTool": "semaphor_list_semantic_domains", "...": "..." },
    "content": [{ "type": "text", "text": "{ ...the same object as JSON... }" }]
  }
}
```

Read `structuredContent` when it's there; `content[0].text` carries the same result as text. A failed call has `"isError": true` and a result with a `code`, a `message` and usually a `fix`. Arguments are strict: an unknown argument name fails with an input validation error naming it.

## Rules for the agent

Give your agent these rules. The server's own instructions say the same, and every tool description repeats the part that applies to it.

1. **Start every data question with [`semaphor_get_analysis_context`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_get_analysis_context)** and follow its `recommendedNextTool`. It says whether the project has a semantic model (`recommendedPath: "semantic"`) or only physical connections.
2. **Never guess an ID or a name.** Projects, domains, datasets, fields, dashboards, cards and filters all come from discovery tools. A guessed name fails, and the error lists the valid ones.
3. **Use exact field names from [`semaphor_get_dataset_schema`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_get_dataset_schema).** A field of a related dataset is `{ "name": "region", "datasetName": "customers" }`.
4. **For a question about a dashboard card, run the card as it is:** [`semaphor_analyze`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_analyze) with only `fromCard`. Build your own query only to ask something the card doesn't show.
5. **Check what the query applied before you answer.** `executionResult.status` is `answered`, `partial` or `failed`; `executionResult.constraints` lists the filters and date windows applied, and any skipped with the reason. Mention a skipped filter to the user.
6. **When a call fails, read `code`, `message` and `fix`,** and change the call. Repeating the same call gets the same error.
7. **Never save a change without the user.** Read the outline, call [`semaphor_propose_change`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_propose_change), show the user its `summary` sentences, and call [`semaphor_apply_change`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_apply_change) only after they say yes.
8. **Place cards by intent, never by coordinates.** Leave out `width` and `position` unless the user names a place or size; the card is then placed by its kind. To tidy a sheet, propose `layout.arrange`.
9. **Interactive sessions name the project:** call [`semaphor_list_projects`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_list_projects) and pass `projectId` to project-scoped tools. A token session is pinned to its project and doesn't need it.

## Job 1: Answer a data question

"What was revenue by region over the last 30 days?"

**1. Get the analysis context.**

```json title="semaphor_get_analysis_context"
{}
```

```json title="Result (shortened)"
{
  "projectId": "p_123",
  "semanticDomainCount": 3,
  "recommendedPath": "semantic",
  "recommendedNextTool": "semaphor_list_semantic_domains",
  "defaultAnalysisTool": "semaphor_analyze",
  "fallbackAnalysisTool": "semaphor_query_sql_advanced",
  "semanticDomains": [{ "id": "dom_sales", "name": "sales", "label": "Sales" }]
}
```

**2. Find the dataset.** [`semaphor_list_semantic_domains`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_list_semantic_domains) lists domains when there are several; [`semaphor_list_datasets`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_list_datasets) lists a domain's datasets.

```json title="semaphor_list_datasets"
{ "domainId": "dom_sales" }
```

```json title="Result (shortened)"
{
  "datasets": [
    { "name": "orders", "label": "Orders", "analyticsRole": "fact", "domainId": "dom_sales" },
    { "name": "customers", "label": "Customers", "analyticsRole": "dimension", "domainId": "dom_sales" }
  ]
}
```

**3. Read the fields.**

```json title="semaphor_get_dataset_schema"
{ "domainId": "dom_sales", "datasetName": "orders", "responseDetail": "compact" }
```

```json title="Result (shortened)"
{
  "dataset": { "source": { "kind": "semantic", "domainId": "dom_sales", "datasetName": "orders" } },
  "fields": [
    { "name": "revenue", "label": "Revenue", "dataType": "number", "modelRole": "fact", "fieldKind": "physical" },
    { "name": "order_date", "label": "Order Date", "dataType": "date", "modelRole": "dimension", "fieldKind": "physical" }
  ],
  "recommendedNextTool": "semaphor_analyze"
}
```

**4. Run the query.**

```json title="semaphor_analyze"
{
  "domainId": "dom_sales",
  "datasetName": "orders",
  "measures": ["revenue"],
  "dimensions": [{ "name": "region", "datasetName": "customers" }],
  "dateField": "order_date",
  "timeWindow": { "unit": "day", "value": 30, "anchor": "latest_available" },
  "limit": 10
}
```

```json title="Result (shortened)"
{
  "answerSummary": "Successfully executed query spec (4 rows).",
  "data": {
    "records": [
      { "Region": "West", "Revenue": 1758699.06 },
      { "Region": "East", "Revenue": 1670049.2 }
    ],
    "rowCount": 4,
    "rowLimitExceeded": false
  },
  "executionResult": {
    "status": "answered",
    "resultShape": "records",
    "fieldsUsed": [
      { "name": "revenue", "label": "Revenue", "role": "measure", "aggregate": "SUM" },
      { "name": "region", "label": "Region", "role": "dimension" }
    ],
    "constraints": {
      "applied": [
        { "kind": "filter", "field": { "name": "orders.order_date", "label": "Order Date" },
          "operation": "between", "values": ["2026-09-17", "2026-10-17"], "valueType": "date", "timezone": "America/Chicago" }
      ],
      "skipped": []
    }
  }
}
```

`latest_available` counts back from the newest date in the data; `now` counts back from today. The applied filter shows the dates the window resolved to. Add `"responseDetail": "compact"` for a smaller result that leaves out the full SQL and duplicate metadata.

## Job 2: Explain a dashboard card

"Why is the Shipments card down this month?"

**1. Find the dashboard and the card.**

```json title="semaphor_list_dashboards"
{ "search": "operations" }
```

```json title="semaphor_get_dashboard_details"
{ "dashboardId": "d_ops", "fields": ["outline"] }
```

The outline lists each card's `cardId`, title, type and query.

**2. Run the card as it runs on its dashboard.**

```json title="semaphor_analyze"
{ "fromCard": { "dashboardId": "d_ops", "cardId": "c_shipments" } }
```

```json title="Result (shortened)"
{
  "answerSummary": "Successfully executed query spec (3 rows).",
  "data": { "records": [{ "Facility": "North", "Net Tons": 175079.6 }, { "Facility": "South", "Net Tons": 129574.9 }] },
  "executionResult": {
    "status": "answered",
    "metadata": { "queryApproach": "dashboard_card_replay" },
    "constraints": {
      "applied": [
        { "kind": "filter", "field": { "name": "shipped_at", "label": "Shipped date" }, "operation": "between",
          "values": ["2026-07-21T05:00:00.000Z", "2026-10-19T04:59:59.999Z"], "valueType": "date", "timezone": "America/Chicago" }
      ],
      "skipped": []
    }
  }
}
```

The card runs with its own filters and the dashboard's default inputs, as a new viewer sees it, under your security policies. To dig further (by week, by another dimension, against last month), build a query from the card's fields with [`semaphor_analyze`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_analyze). A Matrix card, a text card and a card in a Document section return `card_not_supported`; build the query from the card's fields instead.

## Job 3: Change a dashboard

"Add a bar chart of revenue by region to the Sales dashboard."

**1. Read the outline.**

```json title="semaphor_get_dashboard_details"
{ "dashboardId": "d_sales", "fields": ["outline"] }
```

Check `outline.access`: `canEdit: true` means you can change it in place; `canCopy: true` without `canEdit` means you propose with `"saveAs": "copy"`.

**2. Propose.**

```json title="semaphor_propose_change"
{
  "dashboardId": "d_sales",
  "changes": [{
    "op": "card.add",
    "type": "bar",
    "title": "Revenue by region",
    "query": {
      "domainId": "dom_sales",
      "datasetName": "orders",
      "measures": ["revenue"],
      "dimensions": [{ "name": "region", "datasetName": "customers" }]
    }
  }],
  "rationale": "The user asked for revenue by region."
}
```

```json title="Result (shortened)"
{
  "proposalId": "chg_xoK9M...",
  "expiresAt": "2026-10-18T21:01:17.322Z",
  "risk": "low",
  "target": { "kind": "dashboard", "dashboardId": "d_sales", "title": "Sales", "saveAs": "original" },
  "summary": ["Adds the card \"Revenue by region\" (bar) to Overview: Revenue by Region. The dashboard's \"Order date\" filter applies to it."],
  "warnings": [],
  "preview": {
    "cards": [{ "ref": "$card_1", "title": "Revenue by region",
      "columns": [{ "name": "region", "label": "Region" }, { "name": "revenue", "label": "Revenue" }],
      "rows": [["West", 5065026.7], ["East", 4795563.07]], "rowCount": 4 }]
  },
  "next": "Show the summary to the user. After they confirm, call semaphor_apply_change with proposalId \"chg_xoK9M...\"."
}
```

**3. Show the user the `summary` sentences and any `warnings`,** and wait for a yes.

**4. Apply.**

```json title="semaphor_apply_change"
{ "proposalId": "chg_xoK9M..." }
```

```json title="Result"
{
  "applied": true,
  "replayed": false,
  "target": { "kind": "dashboard", "dashboardId": "d_sales", "title": "Sales", "updatedAt": "2026-10-18T20:44:03.114Z" },
  "created": [{ "handle": "$card_1", "kind": "card", "id": "c_9f2a", "title": "Revenue by region" }],
  "summary": ["Adds the card \"Revenue by region\" (bar) to Overview: Revenue by Region. The dashboard's \"Order date\" filter applies to it."],
  "warnings": []
}
```

- **If apply returns `proposal_stale`,** someone saved the dashboard after your preview. Read the outline again and propose again.
- **If the user changes their mind before applying,** propose again with the new changes; an unapplied proposal simply expires after 30 minutes.
- **A lost response is safe to retry:** call apply again with the same `proposalId` and you get the recorded result with `replayed: true`, never a second copy.

Several changes go in one proposal and apply all or nothing. Give a new card or filter a `handle` such as `"$region"` to refer to it later in the same list. See [Making Changes](/docs/mcp/changes) for every change and its fields.

## Job 4: Pivot data

"Show revenue by region down the side and month across the top."

Use [`semaphor_matrix`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_matrix) for a pivot with a field across the top, totals and pages of rows and columns. It takes the same domain, datasets and fields as [`semaphor_analyze`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_analyze), grouped into rows, columns and measures, and returns one window of the matrix with cursors for the next page in each direction. Its arguments are in the [Tools Reference](/docs/mcp/tools-reference#semaphor_matrix). For a simple breakdown, [`semaphor_analyze`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_analyze) with two dimensions is enough.

## When a Call Fails

| You get | It means | Do this |
| --- | --- | --- |
| An input validation error naming an argument | The argument name or shape is wrong | Fix it from the tool's schema; don't add arguments the schema doesn't list |
| `validation` with `missing_measure`, `invalid_dimension` or another field code | A field name is wrong | Use one of the candidates the error lists, or read [`semaphor_get_dataset_schema`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_get_dataset_schema) |
| `executionResult.status: "partial"` | Part of the question was answered | Read `coverage.missingObligations` and tell the user what's missing |
| `capability_required` | The call needs SQL or physical access the session doesn't have | Use the semantic model, or ask an admin for SQL access |
| `card_not_supported` | `fromCard` can't run that card | Build the query from the card's fields |
| `copy_required` | You can view the dashboard but not edit it | Propose again with `"saveAs": "copy"` |
| `write_scope_required` | The session can propose but not apply | Tell the user to apply in the editor, or connect with `mcp:write` |
| `proposal_stale` | The dashboard changed after the preview | Read the outline again and propose again |
| `apply_outcome_unknown` | An apply never recorded a result | Follow `howToCheck` before proposing again |

When an agent is stuck, [`semaphor_plan_analytics_recovery`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_plan_analytics_recovery) plans the next calls from what it was trying to do and the schemas it has already read.

## The Tools

| Tool | Use it to |
| --- | --- |
| [`semaphor_get_analysis_context`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_get_analysis_context) | Start a data question: the recommended path and next tool |
| [`semaphor_list_projects`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_list_projects) | List projects (interactive sessions only) |
| [`semaphor_get_access_context`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_get_access_context) | See who the session acts as and what it can do |
| [`semaphor_list_semantic_domains`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_list_semantic_domains) | List semantic domains |
| [`semaphor_list_datasets`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_list_datasets) | List a domain's datasets |
| [`semaphor_get_dataset_schema`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_get_dataset_schema) | Read a dataset's fields, or a physical table's columns |
| [`semaphor_get_domain_relationships`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_get_domain_relationships) | Read how a domain's datasets join |
| [`semaphor_analyze`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_analyze) | Run governed analytics, or a dashboard card with `fromCard` |
| [`semaphor_matrix`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_matrix) | Run a pivot window |
| [`semaphor_plan_analytics_recovery`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_plan_analytics_recovery) | Get suggested next calls when stuck |
| [`semaphor_list_connections`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_list_connections), [`semaphor_list_databases`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_list_databases), [`semaphor_list_schemas`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_list_schemas), [`semaphor_list_tables`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_list_tables) | Browse physical sources (sessions with SQL) |
| [`semaphor_query_sql_advanced`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_query_sql_advanced) | Run SQL when the semantic model can't express the question (sessions with SQL) |
| [`semaphor_list_dashboards`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_list_dashboards) | Find dashboards |
| [`semaphor_get_dashboard_details`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_get_dashboard_details) | Read a dashboard: who can open it, its cards, and its outline for changes |
| [`semaphor_get_dashboard_analysis_context`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_get_dashboard_analysis_context) | Read a known dashboard compactly for analysis: its cards, filters, fields and query inputs |
| [`semaphor_propose_change`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_propose_change) | Preview a change; saves nothing |
| [`semaphor_apply_change`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_apply_change) | Save a previewed change (sessions with `mcp:write`) |

# Connect an AI Client

Log in from Claude Code, Codex, claude.ai, ChatGPT or Cursor and ask questions about your data.

Source: https://docs.semaphor.cloud/docs/mcp/connect-ai-client

Connect your AI client to Semaphor and ask questions about your analytics. You log in once with your Semaphor account, and the agent works across every project you belong to, with your role and permissions.

You add Semaphor to your AI client, log in with your Semaphor account, ask a question, and get an answer governed by your access.:

1. **Add Semaphor**: to your AI client
2. **Log in**: with your Semaphor account
3. **Ask**: the agent discovers and queries
4. **Answer**: governed by your access

You need a Semaphor account (organization user) with access to at least one project. For automation or an agent inside your own product, use a token instead: see [Connect with a Token](/docs/mcp/connect-with-token).

## Pick Your Client

- **[Claude Code](#claude-code)** (`plugin`): The Semaphor plugin adds the MCP server and an analytics skill that teaches the agent the discovery and query workflow.
- **[Codex](#codex)** (`plugin`): The same plugin for Codex, with the same skill.
- **[claude.ai](#claude-ai)** (`custom connector`): Add Semaphor as a custom connector in Claude settings.
- **[ChatGPT](#chatgpt)** (`developer mode`): Create an app for Semaphor in ChatGPT developer mode.
- **[Cursor](#cursor)** (`mcp.json`): Add the server URL to your Cursor configuration.

## Claude Code \[#claude-code]

### Install the plugin

```bash
claude plugin marketplace add semaphor-analytics/agent-plugin
claude plugin install semaphor@semaphor-analytics
```

Or, inside a Claude Code session, run `/plugin marketplace add semaphor-analytics/agent-plugin`, then `/plugin install semaphor@semaphor-analytics`. Restart Claude Code, or run `/reload-plugins`, so it picks up the plugin.

### Log in

Run `/mcp`, choose `semaphor`, and complete the login in your browser.

### Check the connection

In a session, `/mcp` shows the `semaphor` server and its login state. From the terminal, `claude plugin list` shows the plugin.

**Upgrade**

```bash
claude plugin marketplace update semaphor-analytics
```

If Claude Code still shows an older plugin, reinstall it:

```bash
claude plugin uninstall semaphor@semaphor-analytics
claude plugin install semaphor@semaphor-analytics
```

**Remove**

```bash
claude plugin uninstall semaphor@semaphor-analytics
claude plugin marketplace remove semaphor-analytics
```

## Codex \[#codex]

### Install the plugin

```bash
codex plugin marketplace add semaphor-analytics/agent-plugin
codex plugin add semaphor@semaphor-analytics
```

### Log in

```bash
codex mcp login semaphor
```

### Check the connection

```bash
codex plugin list --marketplace semaphor-analytics
codex mcp list
```

**Upgrade**

```bash
codex plugin marketplace upgrade semaphor-analytics
```

If Codex still shows an older plugin, reinstall it:

```bash
codex plugin remove semaphor@semaphor-analytics
codex plugin add semaphor@semaphor-analytics
```

**Remove**

```bash
codex plugin remove semaphor@semaphor-analytics
codex plugin marketplace remove semaphor-analytics
```

## claude.ai \[#claude-ai]

Custom connectors need a Claude plan that supports them.

### Add a custom connector

In Claude, open **Settings**, go to **Connectors**, and click **Add custom connector**.

### Enter the server

Name it `Semaphor` and enter the server URL, then click **Add**:

```
https://semaphor.cloud/api/mcp
```

### Sign in

When redirected, sign in with your Semaphor account and authorize the connection. claude.ai registers itself with Semaphor's login server, so you don't need a client ID.

## ChatGPT \[#chatgpt]

ChatGPT needs developer mode for custom MCP servers.

### Turn on developer mode

In ChatGPT, open **Settings**, then **Apps**, then **Advanced**, and turn on **Developer mode**.

### Create an app

Click **Create app** and paste the server URL:

```
https://semaphor.cloud/api/mcp
```

### Sign in

Sign in with your Semaphor account when ChatGPT asks.

## Cursor \[#cursor]

### Add the server

```json title="~/.cursor/mcp.json"
{
  "mcpServers": {
    "semaphor": {
      "url": "https://semaphor.cloud/api/mcp"
    }
  }
}
```

### Sign in

When Cursor connects, complete the Semaphor login in your browser. If the tools don't show up, restart Cursor and check that the file is valid JSON (no trailing commas).

To try what an automation or embedded agent will see, connect Cursor with a token instead: see [Connect with a Token](/docs/mcp/connect-with-token#point-a-client-at-the-token).

## Ask Your First Question

Start a new conversation and ask:

```text
What data can I analyze in Semaphor?
```

If you belong to one project, the agent uses it automatically. If you belong to several, it lists them and asks which one you mean, then passes that `projectId` on each call. To switch, name the other project.

```markdown title="Example session"
You: "What data can I analyze in Semaphor?"

Agent calls: semaphor_list_projects
Agent: "You have 3 projects: Sales, Marketing and Operations. Which one should I use?"

You: "Sales. How many orders did we get last month?"

Agent calls: semaphor_get_analysis_context with projectId "p_abc123"
Agent calls: semaphor_list_semantic_domains, semaphor_list_datasets
Agent calls: semaphor_get_dataset_schema with datasetName "orders"
Agent calls: semaphor_analyze with a previous-month timeWindow
Agent: "You received 4,328 orders last month, up 12% from the month before."

You: "Break that down by region"

Agent calls: semaphor_analyze with region as a dimension
Agent: "West: 1,456 (34%), East: 1,198 (28%), Central: 892 (21%), South: 782 (18%)"
```

Other questions to try:

```text
How did revenue change last quarter, and what drove it?
Which regions are behind target this month?
Check whether my sales dashboard's numbers are right.
```

See [Discover and Query](/docs/mcp/discover-and-query) for how the agent finds the right data.

## Read and Write Access

A login grants read access by default: discovery, queries and dashboards. With an organization role of Author or higher, the agent can also propose changes (editing dashboard cards, filters and layout, generating a dashboard, or adding a relationship to a semantic model), which shows you a preview and saves nothing.

To let the agent save a change, you also need the `mcp:write` scope, which your client asks for when it requests `mcp:read mcp:write` at login. Without it, the agent can propose but not apply, and it tells you to make the change in the dashboard editor. See [Making Changes](/docs/mcp/changes).

## Self-Hosted Semaphor

On a self-hosted install, use your Semaphor URL followed by `/api/mcp` wherever this page uses `https://semaphor.cloud/api/mcp`.

## If Something's Missing

- **The tools don't appear:** restart the client (`/reload-plugins` in Claude Code), then check `/mcp` or `codex mcp list` for the `semaphor` server.
- **The server needs a login:** complete it with `/mcp` in Claude Code or `codex mcp login semaphor` in Codex.
- **A tool you expected is missing:** ask the agent to call [`semaphor_get_access_context`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_get_access_context). It reports your access mode, scopes and whether SQL is available.

See [Troubleshooting](/docs/mcp/troubleshooting) for more.

# Connect with a Token

Connect automation, an agent in your product, or your own code to Semaphor with an MCP project token.

Source: https://docs.semaphor.cloud/docs/mcp/connect-with-token

Automation (CI jobs, schedulers, scripts) and agents inside your product can't open a browser to log in. They connect with an **MCP project token**: a short-lived token your backend mints with your project secret, sent on every request as `Authorization: Bearer <token>`.

Your backend mints an MCP project token. The agent sends it to Semaphor, which pins the session to the token's project and answers under that user's policies.:

1. **Your backend**: mints a token with the project secret
2. **Agent**: sends it as a bearer token
3. **Semaphor MCP**: pins the session to one project
4. **Your data**: that user's policies applied

- **Automation** (`orgUserId`): The token acts as an organization user. Use it for CI jobs, schedulers and scripts, with write scope if the job saves changes to dashboards.
- **An end user in your product** (`endUserId`): The token acts as one of your end users, with the same security policies as their embedded dashboards. With write scope, an end user with the Power User role can change the dashboards they can edit.

### Mint a token

Call `POST https://semaphor.cloud/api/v1/token` from your server, the same call you use for embedded dashboards, and add the `mcp` option.

**Automation**

```bash title="A nightly job that may change dashboards"
curl -X POST https://semaphor.cloud/api/v1/token \
  -H "Content-Type: application/json" \
  -d '{
    "projectId": "p_...",
    "projectSecret": "<project-secret>",
    "orgUserId": "<org-user-id>",
    "mcp": { "scopes": ["mcp:read", "mcp:write"] },
    "tokenExpiry": 1800
  }'
```

**End user**

```bash title="A read-only agent for one end user"
curl -X POST https://semaphor.cloud/api/v1/token \
  -H "Content-Type: application/json" \
  -d '{
    "projectId": "p_...",
    "projectSecret": "<project-secret>",
    "tenantId": "tenant_123",
    "endUserEmail": "maria@acme.com",
    "mcp": { "scopes": ["mcp:read"], "sql": false },
    "semanticDomainAccess": { "mode": "include", "domains": ["sales"] },
    "tokenExpiry": 900
  }'
```

This token also turns off SQL and limits the agent to one semantic domain.

The response is `{ "accessToken": "..." }`. Keep it out of source control and logs; the examples below read it from `SEMAPHOR_MCP_TOKEN`.

> **Keep the project secret on your server:** Never expose your `projectSecret` in client-side code. Mint tokens on your backend and pass only the short-lived `accessToken` to the agent.

### Point a client at the token \[#point-a-client-at-the-token]

Send the token in the `Authorization` header to `https://semaphor.cloud/api/mcp` over Streamable HTTP. If you also use the Semaphor plugin, add the token server next to it under another name, such as `semaphor-token`.

**TypeScript SDK**

```typescript title="agent.ts"
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';

const transport = new StreamableHTTPClientTransport(
  new URL('https://semaphor.cloud/api/mcp'),
  {
    requestInit: {
      headers: { Authorization: `Bearer ${process.env.SEMAPHOR_MCP_TOKEN}` },
    },
  }
);

const client = new Client({ name: 'acme-insights-agent', version: '1.0.0' });
await client.connect(transport);

// The tool list follows the token's identity and settings
const { tools } = await client.listTools();

const context = await client.callTool({
  name: 'semaphor_get_analysis_context',
  arguments: {},
});
console.log(context.structuredContent);
```

**Claude Code**

Claude Code expands `${SEMAPHOR_MCP_TOKEN}` from the environment:

```json title=".mcp.json"
{
  "mcpServers": {
    "semaphor-token": {
      "type": "http",
      "url": "https://semaphor.cloud/api/mcp",
      "headers": { "Authorization": "Bearer ${SEMAPHOR_MCP_TOKEN}" }
    }
  }
}
```

Or from the command line:

```bash
claude mcp add --transport http semaphor-token https://semaphor.cloud/api/mcp --header "Authorization: Bearer ${SEMAPHOR_MCP_TOKEN}"
```

**Codex**

Codex reads the token from the named variable:

```toml title="~/.codex/config.toml"
[mcp_servers.semaphor-token]
url = "https://semaphor.cloud/api/mcp"
bearer_token_env_var = "SEMAPHOR_MCP_TOKEN"
```

**Cursor**

```json title="~/.cursor/mcp.json"
{
  "mcpServers": {
    "semaphor-token": {
      "url": "https://semaphor.cloud/api/mcp",
      "headers": { "Authorization": "Bearer ${env:SEMAPHOR_MCP_TOKEN}" }
    }
  }
}
```

Restart Cursor, then ask it to call [`semaphor_get_analysis_context`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_get_analysis_context). You should see the token's project.

**HTTP**

Without an MCP SDK, send JSON-RPC over HTTP. Include both content types in `Accept`; the server answers with JSON.

```bash title="List tools"
curl -X POST https://semaphor.cloud/api/mcp \
  -H "Authorization: Bearer $SEMAPHOR_MCP_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","method":"tools/list","id":1}'
```

```bash title="Run a governed query"
curl -X POST https://semaphor.cloud/api/mcp \
  -H "Authorization: Bearer $SEMAPHOR_MCP_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{
    "jsonrpc": "2.0",
    "method": "tools/call",
    "params": {
      "name": "semaphor_analyze",
      "arguments": {
        "domainId": "dom_abc",
        "datasetName": "orders",
        "measures": [{ "datasetName": "orders", "name": "order_count" }],
        "dimensions": [{ "datasetName": "orders", "name": "region" }],
        "limit": 10
      }
    },
    "id": 2
  }'
```

**Python**

```python title="mcp_query.py"
import os
import requests

response = requests.post(
    "https://semaphor.cloud/api/mcp",
    headers={
        "Authorization": f"Bearer {os.environ['SEMAPHOR_MCP_TOKEN']}",
        "Content-Type": "application/json",
        "Accept": "application/json, text/event-stream",
    },
    json={
        "jsonrpc": "2.0",
        "method": "tools/call",
        "params": {"name": "semaphor_list_dashboards", "arguments": {"search": "sales"}},
        "id": 1,
    },
)
print(response.json())
```

### Discover and query

The token fixes the project, so the agent never lists or picks projects. It starts with the analysis context and follows the semantic path.

```markdown title="Example conversation"
User: "How many orders did we get last month by region?"

Agent calls: semaphor_get_analysis_context
Agent calls: semaphor_list_datasets, semaphor_get_dataset_schema with datasetName "orders"
Agent calls: semaphor_analyze with:
  measures: [{ "datasetName": "orders", "name": "order_count" }]
  dimensions: [{ "datasetName": "orders", "name": "region" }]
  dateField: { "datasetName": "orders", "name": "order_date" }
  timeWindow: { "kind": "calendar", "unit": "month", "period": "previous" }

Agent: "Last month's orders by region: West 1,247, East 1,103, Central 892, South 786"
```

Tokens with SQL on can also use [`semaphor_query_sql_advanced`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_query_sql_advanced) for questions the semantic model can't express. See [Discover and Query](/docs/mcp/discover-and-query).

### Refresh before it expires

Tokens last from 60 seconds to 24 hours (`tokenExpiry`, default one hour). An expired token gets `401` with "MCP project token is invalid or expired." Mint a new one on your backend and reconnect.

## Request Fields \[#request-fields]

| Field | Default | Description |
| --- | --- | --- |
| `orgUserId` | | The organization user the token acts as. Use for automation. |
| `endUserId` | | The tenant user the token acts as. Use for an end user in your product. |
| `tenantId` + `endUserEmail` | | Alternative to `endUserId`: identify the end user by tenant and email. |
| `mcp.scopes` | `["mcp:read"]` | `["mcp:read"]` or `["mcp:read", "mcp:write"]`. Write needs an organization user with an Author role or higher, or an end user; an end user also needs the Power User role to get the change tools. |
| `mcp.sql` | `true` | `false` turns off SQL and physical table discovery for this token. |
| `tokenExpiry` | `3600` | Lifetime in whole seconds, from `60` to `86400`. |

The usual security fields (`cls`, `rcls`, `params`, `semanticDomainAccess`) apply exactly as they do for embedded dashboards. See the [Token API](/docs/api/tokens/createToken) for every field.

A request is never silently downgraded. If you ask for something the identity can't have, such as write scope for an organization user with the Viewer role, the mint fails.

## How Token Sessions Behave

### Pinned to one project

The agent never lists or switches projects, and passing a different `projectId` returns an error. Mint a token for each project the agent needs.

### The token's identity

Queries run with that user's role and permissions, plus the token's security settings. Two tokens can get different numbers for the same question.

### Write scope is approval

Minting a token with `mcp:write` approves that job's writes in advance. There's no per-change human approval in a headless session.

### Responses

Results arrive as `structuredContent` and as JSON text in `content[0].text`. A tool error is a result with `isError: true` and a `code`, not an HTTP error.

> **Unattended Claude Code runs:** In a non-interactive `claude -p` run, a tool call that would ask for permission is denied unless `--allowedTools` or an allow rule covers it. List the Semaphor tools the job needs, such as `--allowedTools "mcp__semaphor-token__semaphor_get_analysis_context,mcp__semaphor-token__semaphor_analyze"`. If the job should never write, mint its token without `mcp:write`.

## Mint Errors \[#mint-errors]

The mint returns `400` with `{ "error": "<code>", "message": "<text>" }`:

| `error` | Fix |
| --- | --- |
| `mcp_invalid_scope` | `mcp.scopes` must be `["mcp:read"]` or `["mcp:read", "mcp:write"]`, and `mcp.sql` must be `true` or `false`. |
| `mcp_write_not_allowed` | Write access needs a user to act as: an organization user (`orgUserId`) with an Author role or higher, or an end user (`endUserId` with `tenantId`). An end user also needs the Power User role to see the change tools. |
| `mcp_token_lifetime_invalid` | `tokenExpiry` must be a whole number of seconds from `60` to `86400`. Strings such as `"2h"` aren't accepted. |
| `mcp_identity_required` | An MCP token acts as one user. Pass `orgUserId`, or `endUserId` with `tenantId`; a `tenantId` alone isn't enough. |

## Other Tokens \[#other-tokens]

The MCP accepts only MCP logins and MCP project tokens. Project tokens minted without the `mcp` option, dashboard embed tokens and API tokens get `401` with "This token was not minted for the MCP." Add the `mcp` option to the mint call you already make.

## Connection Parameters

| Parameter | Value |
| --- | --- |
| MCP endpoint | `https://semaphor.cloud/api/mcp` (self-hosted: your Semaphor URL followed by `/api/mcp`) |
| Transport | Streamable HTTP (JSON responses) |
| Auth header | `Authorization: Bearer <token>` |
| Token | MCP project token from `POST /api/v1/token` with the `mcp` option |

# Discover and Query

How agents find the right data in your semantic model, then run governed analytics, Matrix windows or SQL.

Source: https://docs.semaphor.cloud/docs/mcp/discover-and-query

Before it queries, an agent finds out what's available. Semaphor is **semantic first**: the agent starts from your business-friendly semantic model and falls back to raw database tables only when it has to. Every data question starts with [`semaphor_get_analysis_context`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_get_analysis_context), which recommends the path.

**Diagram:** The analysis context recommends a path. The semantic path goes from domains to datasets to a dataset schema, then a governed query. The physical path, for callers with SQL access, goes from connections to schemas to tables, then a SQL query.

_semaphor_get_analysis_context recommends the path and the next tool. Callers without SQL access never see the physical path._

## The Analysis Context

Call [`semaphor_get_analysis_context`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_get_analysis_context) first. Interactive sessions pass a `projectId`; token sessions use the token's project. It returns:

| Field | What it tells the agent |
| --- | --- |
| `recommendedPath` | `semantic`, `physical`, `restricted` (the token's domain access excludes every domain) or `unavailable` |
| `recommendedNextTool` | The tool to call next |
| `semanticDomains` | The domains this caller can see |
| `fallbackConnections` | Connections for physical discovery, when there are no domains and the caller has SQL access |

## Semantic Path

When semantic domains exist, the agent works down from domain to field:

1. [`semaphor_list_semantic_domains`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_list_semantic_domains): the domains this caller can see
2. [`semaphor_list_datasets`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_list_datasets): the datasets in a domain
3. [`semaphor_get_dataset_schema`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_get_dataset_schema): measures, dimensions and date fields, plus a preview of related datasets with example field refs
4. [`semaphor_get_domain_relationships`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_get_domain_relationships): how datasets join (optional, useful for multi-dataset questions)

Then it queries with [`semaphor_analyze`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_analyze) or [`semaphor_matrix`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_matrix).

**Asking about a dashboard card?** Run the card as it is: [`semaphor_analyze`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_analyze) with only `fromCard: { "dashboardId", "cardId" }` returns the card's own rows, with its filters and the dashboard's default inputs, and `executionResult.constraints` says which ones applied. See [Run a dashboard card](/docs/mcp/tools-reference#run-a-dashboard-card).

**Starting from a dashboard?** Call [`semaphor_get_dashboard_analysis_context`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_get_dashboard_analysis_context) before broad discovery. It returns the dashboard's cards, filters, authored measures, dimensions and date fields, and query inputs the agent can reuse with [`semaphor_analyze`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_analyze).

## Physical Path

When there are no semantic domains, callers with SQL access browse physical tables:

1. [`semaphor_list_connections`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_list_connections): connections and their discovery capabilities
2. [`semaphor_list_databases`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_list_databases) and [`semaphor_list_schemas`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_list_schemas): the levels each connection calls for
3. [`semaphor_list_tables`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_list_tables): tables, or exact coordinates from `nameCandidates`
4. [`semaphor_get_dataset_schema`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_get_dataset_schema) with connection coordinates (`connectionId`, `tableName`, and `databaseName` and `schemaName` as needed)

Then it queries with [`semaphor_query_sql_advanced`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_query_sql_advanced). Viewers and tokens minted with `"sql": false` don't see the physical tools and stay on the semantic path.

| | Semantic path | Physical path |
| --- | --- | --- |
| **Joins** | From the semantic model, automatically | Written by hand in SQL |
| **Field labels** | Business-friendly names and descriptions | Raw column names |
| **Who can use it** | Every caller | Callers with SQL access |
| **Best for** | Business questions and governed analytics | SQL analysis and tables no domain represents |

## Choose a Query Tool

- **[Governed analytics](#governed-analytics)** ([`semaphor_analyze`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_analyze)): The default. Measure by dimension, top-N, time series, filters, period comparisons and driver analysis, with joins and aggregates from the semantic model.
- **[Matrix windows](#matrix-windows)** ([`semaphor_matrix`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_matrix)): Pivot-style row and column hierarchies with totals, subtotals, scoped sorting, and Top or Bottom N with Others.
- **[Advanced SQL](#advanced-sql)** ([`semaphor_query_sql_advanced`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_query_sql_advanced)): For questions the semantic tools can't express. Needs SQL access, and runs under the same security policies.
- **[Recovery planning](/docs/mcp/tools-reference#semaphor_plan_analytics_recovery)** ([`semaphor_plan_analytics_recovery`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_plan_analytics_recovery)): When a question still has unanswered parts, plans the next governed tool calls from the schema evidence. It plans; it does not run queries.

| | [`semaphor_analyze`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_analyze) | [`semaphor_matrix`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_matrix) | [`semaphor_query_sql_advanced`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_query_sql_advanced) |
| --- | --- | --- | --- |
| **Query format** | Semantic fields and analytics intent | Matrix definition and window | SQL |
| **Joins** | Automatic and governed | Automatic and governed | Written by hand |
| **Aggregates** | From the semantic model, with overrides | From the Matrix definition | Whatever the SQL says |
| **Who can use it** | Every caller | Every caller | Callers with SQL access |

## Governed Analytics \[#governed-analytics]

[`semaphor_analyze`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_analyze) resolves safe joins through semantic relationships, refuses known fan-out shapes instead of returning inflated numbers, and applies the caller's security policies. It returns the rows in `data.records`, and a typed `executionResult` with the columns, the fields used, the filters and windows it applied, relationship diagnostics, and a `status` that says whether the question was answered. The generated SQL comes with it.

```json title="Top suppliers by modeled purchase value"
{
  "domainId": "dom_abc",
  "datasetName": "fact_purchase_line",
  "measures": [{ "datasetName": "fact_purchase_line", "name": "net_purchase_value" }],
  "primaryMeasure": { "datasetName": "fact_purchase_line", "name": "net_purchase_value" },
  "dimensions": [{ "datasetName": "dim_supplier", "name": "supplier_name" }],
  "limit": 10
}
```

If `net_purchase_value` is modeled with `aggregate: "SUM"`, Semaphor generates a summed top-N query: the semantic model owns that default. To count rows when the model has no count measure, use the `"__count"` measure.

For a comparison, add `{ "kind": "previous_period" }`, `{ "kind": "previous_year" }` or `{ "kind": "target", "targetValue": 100 }`. It uses the query's own date field and time window. See [Metric Comparisons](/docs/concepts/metric-comparisons).

### Time Windows

Pass a `timeWindow` with a `dateField` to bound the query:

```json title="Revenue by week, this quarter"
{
  "domainId": "dom_abc",
  "datasetName": "orders",
  "measures": [{ "datasetName": "orders", "name": "revenue" }],
  "dateField": { "datasetName": "orders", "name": "order_date" },
  "timeGrain": "week",
  "timeWindow": { "kind": "calendar", "unit": "quarter", "period": "this" }
}
```

Rolling windows such as `{ "unit": "month", "value": 6 }` count back from the latest date in the data by default (`anchor: "latest_available"`). Use `anchor: "now"` to count back from today. See [Tools Reference](/docs/mcp/tools-reference#semaphor_analyze) for every window shape.

### Aggregate Overrides

The same measure can be asked about with a different rollup: total margin and average margin are both valid questions. Add `aggregate` to the measure for one call:

```json title="Average margin by material"
{
  "domainId": "dom_abc",
  "datasetName": "fact_sales_line",
  "measures": [{ "datasetName": "fact_sales_line", "name": "gross_margin_value", "aggregate": "AVG" }],
  "primaryMeasure": { "datasetName": "fact_sales_line", "name": "gross_margin_value", "aggregate": "AVG" },
  "dimensions": [{ "datasetName": "dim_material", "name": "material_family" }],
  "limit": 10
}
```

The response marks the override in `fieldsUsed` with a `measure_aggregate_override` warning, so you can audit it. Omit `aggregate` to use the semantic model.

## Matrix Windows \[#matrix-windows]

[`semaphor_matrix`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_matrix) runs a governed Matrix: multi-level row and column hierarchies, totals and subtotals, scoped sorting, and Top or Bottom N with Others. You ask for a bounded window, and page cursors load more. A window is never a complete export. See [Tools Reference](/docs/mcp/tools-reference#semaphor_matrix).

## Advanced SQL \[#advanced-sql]

[`semaphor_query_sql_advanced`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_query_sql_advanced) runs SQL against a connection, under the same security policies. Use it for custom CTEs, window functions, relationships the semantic model doesn't define, fields no domain exposes, raw-row inspection, or SQL debugging. It's available to organization users with an Author role or higher, and to end-user tokens unless SQL is turned off.

Every call states why [`semaphor_analyze`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_analyze) can't answer, needs a `connectionId` from [`semaphor_list_connections`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_list_connections), and can post-process rows with optional `pythonCode`.

```json title="Month-over-month delta"
{
  "connectionId": "conn_abc123",
  "analyzeFallbackReason": "unsupported_sql_construct",
  "analyzeFallbackExplanation": "Month-over-month delta needs a LAG window function over a CTE.",
  "sql": "WITH monthly AS (SELECT date_trunc('month', order_date) AS month, SUM(total_amount) AS revenue FROM orders GROUP BY 1) SELECT month, revenue, revenue - LAG(revenue) OVER (ORDER BY month) AS delta FROM monthly ORDER BY month LIMIT 100"
}
```

## Response Format

[`semaphor_analyze`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_analyze) accepts `responseFormat`: `"json"` (the default) returns the rows, metadata, diagnostics and generated SQL, also as structured content; `"markdown"` returns a readable summary. Its `responseDetail: "compact"` trims duplicate arrays, full field metadata and the SQL text. [`semaphor_query_sql_advanced`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_query_sql_advanced) and [`semaphor_matrix`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_matrix) always return JSON.

## Tips

- **List before you query.** Call [`semaphor_list_datasets`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_list_datasets) before [`semaphor_get_dataset_schema`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_get_dataset_schema), so the agent never guesses dataset names.
- **Use source-bearing refs.** Include `datasetName` on each field ref when selecting fields from joined datasets.
- **Let the model choose aggregates.** Omit `aggregate` unless the question asks for a different rollup.
- **Check `executionResult`.** Its `status` and `coverage` say whether the question was answered, not the surrounding text.
- **Use SQL as the escape hatch.** It's fully governed, but it shouldn't be the first choice for ordinary analytics.

For more on modeling, see [Semantic Domains](/docs/concepts/semantic-domains/overview).

# Making Changes

Preview and apply changes to dashboards and semantic models through Semaphor MCP.

Source: https://docs.semaphor.cloud/docs/mcp/changes

Agents can add, change and remove dashboard cards and report filters, arrange a dashboard's layout, generate a new dashboard from a prompt, and add a relationship to a semantic model. Every change goes through two tools: [`semaphor_propose_change`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_propose_change) previews it and saves nothing, and [`semaphor_apply_change`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_apply_change) saves exactly what was previewed.

To change the dashboard a user is looking at in your product instead, unsaved and with Undo, use a live token: see [Live Changes in Embedded Dashboards](/docs/mcp/live-changes).

**Diagram:** semaphor_propose_change creates a proposal. Applying it moves to applying, then to applied or failed. If the target changed after the preview, apply returns proposal_stale and the agent proposes again.

_A proposal saves nothing and expires after 30 minutes. A retry of an applied proposal returns the recorded result instead of writing twice._

## Quick Start

### Read the dashboard

The agent calls [`semaphor_get_dashboard_details`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_get_dashboard_details) with `fields: ["outline"]`. The outline lists every sheet, card and filter by ID and name, each card's query in the same fields [`semaphor_analyze`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_analyze) uses, the layout of each sheet, which cards and filters can be changed, and whether you can edit the dashboard or need to save a copy. See [The outline](#the-outline).

```json title="Read the outline"
{ "dashboardId": "d_8f2k", "fields": ["outline"] }
```

### Propose

The agent calls [`semaphor_propose_change`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_propose_change) with the dashboard and a list of changes. It gets back a `proposalId`, one plain sentence per change, any warnings, and the new numbers for the cards it changed. Nothing is saved.

```json title="Propose: add a bar chart"
{
  "dashboardId": "d_8f2k",
  "changes": [{
    "op": "card.add",
    "type": "bar",
    "title": "Net weight by yard",
    "query": {
      "domainId": "dom_yard",
      "datasetName": "tickets",
      "measures": ["net_weight"],
      "dimensions": ["yard"],
      "dateField": "ticket_date",
      "timeWindow": { "unit": "day", "value": 30, "anchor": "now" }
    }
  }],
  "rationale": "Yard managers asked for a 30-day view."
}
```

### Review

The agent shows you the sentences and the preview, and waits for you to confirm. Your client may also ask before the apply runs: see [Permission and Approval](#permission-and-approval).

### Apply

The agent calls [`semaphor_apply_change`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_apply_change) with the `proposalId`, and the changes are saved exactly as previewed.

```json title="Apply"
{ "proposalId": "chg_..." }
```

## Who Can Make Changes

[`semaphor_propose_change`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_propose_change) appears for every session whose user can author dashboards: an organization user with an Author role or higher, or an end user with the Power User role. Proposing saves nothing, so a read-only session can propose. [`semaphor_apply_change`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_apply_change) is listed only for sessions with the `mcp:write` scope. Which dashboards you can change is decided the same way as in the app: you can edit the dashboards you own or were given edit access to, and save a private copy of any dashboard you can open, if you can create dashboards in its project.

| Change | Logged in (OAuth) | Automation token | End-user token (Power User) |
| --- | --- | --- | --- |
| Dashboard cards, filters and layout | Yes | Yes | Yes, on dashboards they can edit in their own tenant |
| `dashboard.generate` | Yes | Yes | No |
| `relationship.add` | Yes | No | No |

Semantic model changes affect every query on a domain, so they need an interactive login: a token session's tool doesn't list `relationship.add`. End users with the Viewer role never get change tools. The SQL query form appears only for sessions that can use SQL.

To apply changes, a session needs write scope: log in with a client that requests `mcp:read mcp:write`, or mint a token with `"mcp": { "scopes": ["mcp:read", "mcp:write"] }`. See [Authentication and Security](/docs/mcp/authentication-and-security#scopes) and [Connect with a Token](/docs/mcp/connect-with-token).

## Proposals

[`semaphor_propose_change`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_propose_change) takes one target and a list of changes:

| Argument | Required | Description |
| --- | --- | --- |
| `dashboardId` | For dashboard changes | The dashboard to change |
| `domainId` | For `relationship.add` | The semantic domain to change. With dashboard changes it is ignored: each card query and filter names its own domain. |
| `changes` | Yes | 1 to 20 changes, applied in order, all or nothing. One proposal holds one kind of target, and a `relationship.add` or `dashboard.generate` is alone in its proposal. |
| `saveAs` | No | `"original"` (default) or `"copy"`: save the changes as your own private copy |
| `preview` | No | `"sample"` (default, up to 5 rows per changed card), `"rows"` (up to 50) or `"none"` |
| `include` | No | `["outline"]` also returns the dashboard's outline as it will be after the changes, as `proposedOutline` |
| `rationale` | No | Why the change is needed, in up to 500 characters. It's kept with the proposal. |
| `projectId` | No | Interactive sessions pass the project for `dashboard.generate` and `relationship.add`; dashboard changes use the dashboard's own project |

It returns:

```json title="Propose response"
{
  "proposalId": "chg_...",
  "expiresAt": "2026-10-16T15:12:00Z",
  "risk": "low",
  "target": { "kind": "dashboard", "dashboardId": "d_8f2k", "title": "Weights", "saveAs": "original" },
  "summary": ["Adds the card \"Net weight by yard\" (bar) to Overview: net weight by yard, last 30 days. The dashboard's \"Yard\" filter applies to it."],
  "changes": [
    { "index": 0, "op": "card.add", "ref": "$card_1", "title": "Net weight by yard",
      "sentence": "Adds the card \"Net weight by yard\" (bar) to Overview: net weight by yard, last 30 days. The dashboard's \"Yard\" filter applies to it." }
  ],
  "warnings": [],
  "preview": {
    "cards": [
      { "ref": "$card_1", "title": "Net weight by yard",
        "columns": [{ "name": "yard", "label": "Yard" }, { "name": "net_weight", "label": "Net weight" }],
        "rows": [["CMR", 412880], ["NWY", 288104], ["EST", 190455]], "rowCount": 3 }
    ]
  },
  "next": "Show the summary to the user. After they confirm, call semaphor_apply_change with proposalId \"chg_...\"."
}
```

| Field | What it holds |
| --- | --- |
| `proposalId` | The ID to apply. Proposals expire at `expiresAt`, 30 minutes after proposing. |
| `risk` | `low` when the changes add, update or arrange; `medium` when they remove or exclude anything, make a library visual's card local to the dashboard, or add a relationship |
| `target` | `{ "kind": "dashboard", "dashboardId", "title", "saveAs" }`, `{ "kind": "new_dashboard" }` for `dashboard.generate`, or `{ "kind": "domain", "domainId", "name" }` for `relationship.add` |
| `summary` | One sentence per change, in order. Show these to the user. |
| `changes` | The same sentences with each change's `index`, `op`, `ref` (its handle or ID) and `title` |
| `warnings` | Things the proposal does that the user should know about. See [Warnings](#warnings). |
| `preview.cards` | Up to 5 changed cards run with their new query: `columns`, `rows`, `rowCount`, and `truncated: true` when there were more rows than the preview shows |
| `preview.skipped` | Changed cards beyond the first 5, not run |
| `preview.failed` | Cards that didn't run: `{ ref, title, code, message }`. The proposal still stands, so fix the query or apply anyway. |
| `proposedOutline` | With `include: ["outline"]`: the outline as it will be after the changes |
| `next` | What to do now, in a sentence. A session without `mcp:write` is told to have the user make the change in the dashboard editor, or ask again from a connection that can apply. |

- **Only you can apply your proposal,** in the same organization and project, with access to the project at the time you apply. Anyone else gets `proposal_not_found`.
- **Handles name new cards and filters.** Give a `card.add` or `filter.add` a `handle` such as `"$yard"` and use it in later changes of the same list wherever a card or filter ID goes, except in `card.remove` and `filter.remove`. A `card.add` without one gets `$card_1`, `$card_2` and so on.

## Change a Dashboard

Take `cardId`, `filterId` and `sheetId` values from the outline. On a dashboard with one grid sheet, `card.add` and `layout.arrange` use it when you leave out `sheetId`.

| Change | Fields |
| --- | --- |
| `card.add` | `type`, `title`, `query`; optional `handle`, `sheetId`, `description`, `matrix`, `width`, `position` |
| `card.update` | `cardId`; send only what changes, from `type`, `title`, `description`, `query` and `matrix` |
| `card.remove` | `cardId` |
| `filter.add` | `label`, `domainId`, `datasetName`, `field`; optional `handle`, `selection`, `defaultValues`, `defaultPeriod`, `sheetIds` |
| `filter.update` | `filterId`; optional `label`, `selection`, `defaultValues`, `defaultPeriod`, `sheetIds` (`null` clears the last three) |
| `filter.remove` | `filterId` |
| `filter.bind` | `filterId`, `cardId`, `field`: the field of that card's dataset the filter applies through |
| `filter.exclude` | `filterId`, `cardId`: stops the filter applying to that card |
| `layout.arrange` | optional `sheetId`, `keepOrder` |

### Cards

`type` is one of `kpi`, `bar`, `line`, `area`, `pie`, `table`, `detail_table` or `matrix`. A Matrix also takes `matrix: { "columns": [...] }`: the query fields that run across the top; the rest run down the side.

A card's `query` comes in one of three forms:

| Form | Fields |
| --- | --- |
| Metric query (every type but `detail_table`) | `domainId`, `datasetName`, `measures` (at least one); optional `dimensions`, `dateField`, `timeGrain`, `filters`, `timeWindow`, `orderBy`, `limit` |
| Records query (`detail_table`) | `domainId`, `datasetName`, `columns` (at least one); optional `filters`, `dateField`, `timeWindow`, `orderBy`, `limit` |
| SQL query (sessions that can use SQL) | `connectionId`, `sql` |

- **Fields** are names from [`semaphor_get_dataset_schema`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_get_dataset_schema), or `{ "name", "datasetName" }` for a field of a related dataset.
- **Measures** may add an `aggregate`: `SUM`, `COUNT`, `AVG`, `MIN`, `MAX`, `MEDIAN` or `DISTINCT`. `"__count"` counts rows.
- **`timeGrain`** is `minute`, `hour`, `day`, `week`, `month`, `quarter` or `year`. `timeWindow` takes the same windows as [`semaphor_analyze`](/docs/mcp/tools-reference#semaphor_analyze).
- **`filters`** are `{ "field", "operator", "values" }` with `operator` one of `=`, `!=`, `in`, `not_in`, `contains`, `not_contains`, `between`, `not_between`, `>`, `>=`, `<`, `<=`, `is_null` or `is_not_null`. Add `"scope": "aggregate"` to filter a measure after it's aggregated.
- **`orderBy`** is `{ "field", "direction": "asc" | "desc" }`. **`limit`** is up to 1,000 rows.

Card filters saved in the editor read in the same vocabulary: one that matches text starting with "V" reads as `contains` with the value `V%`, which runs the same match.

`card.update`'s `query` is a patch: keys you send replace those keys, keys you leave out stay, `null` clears an optional key, and arrays replace whole. A change from one query form to another needs the complete query of the new form; otherwise it's refused with `query_form_mismatch`.

```json title="Make a card a weekly line chart and rename it"
{
  "dashboardId": "d_8f2k",
  "changes": [
    { "op": "card.update", "cardId": "c_trend", "type": "line", "title": "Weekly weight", "query": { "timeGrain": "week" } }
  ]
}
```

A type change works the way the type picker does in the editor: settings the new type can use stay, the others go, and the warning `type_change_drops` says what went. When you change a field's aggregate or time grain, the column keeps its label and format, and a custom label that may no longer fit is reported with `label_kept_after_change`.

A change touches only what it names. A new row limit leaves the card's measures, groupings and filters exactly as they were, adding a measure keeps every existing column as it is, and a new time grain changes the date where it sits, so a chart grouped by segment and then month stays that way. This holds for cards built in the editor too. Renaming a column alone, such as `{ "dateField": { "name": "order_date", "label": "Order day" } }`, renames it in place and changes nothing else, and the editor keeps the new name. A row limit you set comes with a sort, so a limited card shows its top rows, not arbitrary ones; otherwise a card without a sort keeps none, as in the editor. Clearing the limit (`"limit": null`) leaves the 100 rows a new card shows.

The sentence for an added card says which dashboard filters reach it. A card that shows a library visual becomes local to the dashboard when you change it, as Apply Local does in the editor; the proposal says so with `detaches_linked_visual`.

### Where a card goes

Without `width` or `position`, a new card is [placed by its kind](/docs/concepts/dashboard-layouts): a KPI joins the KPI row on top, a chart pairs with a lone chart in the last row, and a table or text card goes to the end at full width. Give a place only when the user names one:

| Field | Values |
| --- | --- |
| `width` | `full`, `half`, `third` or `quarter`. With no `position`, the card goes to the end at this width. |
| `position` | `"top"`, `"end"`, or `{ "after": "<cardId or handle>" }`: right after that card, on that card's sheet |

```json title="A KPI at the top, full width"
{
  "dashboardId": "d_8f2k",
  "changes": [{
    "op": "card.add", "type": "kpi", "title": "Net weight, last 30 days",
    "query": { "domainId": "dom_yard", "datasetName": "tickets", "measures": ["net_weight"],
               "dateField": "ticket_date", "timeWindow": { "unit": "day", "value": 30, "anchor": "now" } },
    "position": "top", "width": "full"
  }]
}
```

Layout is always stated in these words, never as coordinates. Every other screen size follows the desktop layout.

### Arrange a sheet

`layout.arrange` lays a grid sheet out on every screen size: an introductory text card on top, then KPIs in a row, charts in pairs, tables full width and any other text. `"keepOrder": true` keeps the cards in their current order and only fixes sizes and gaps. Arranging an empty sheet changes nothing and says so.

```json title="Tidy the only grid sheet"
{ "dashboardId": "d_8f2k", "changes": [{ "op": "layout.arrange" }] }
```

### Filters

A filter applies to the cards on its sheets that it can reach; the outline's `reaches` lists them. `selection` is `single` or `multiple` (default). `sheetIds` defaults to every grid sheet. `defaultPeriod` is a time window for a date filter.

```json title="A filter that also reaches a card on another dataset"
{
  "dashboardId": "d_8f2k",
  "changes": [
    { "op": "filter.add", "handle": "$yard", "label": "Yard", "domainId": "dom_yard", "datasetName": "tickets",
      "field": "yard", "selection": "single", "defaultValues": ["CMR"] },
    { "op": "filter.bind", "filterId": "$yard", "cardId": "c_scale", "field": "yard_code" }
  ]
}
```

- A new or changed filter that doesn't reach some cards on its sheets comes back with the warning `filter_not_reaching_cards`, listing those cards with `suggestedFields` from the domain's relationships. Add a `filter.bind` with one of them, or leave the cards unfiltered.
- Binding a filter to a card the filter leaves out by name brings the card back in.
- A filter default loads as you ask for it. The outline says which date picker a date filter uses (`datePicker`: `day`, `month` or `range`). A single-day picker takes a one-day default, such as today or yesterday, and a default the picker would narrow is refused with `filter_not_editable` and a `fix`. A relative default runs to now, so one anchored to the latest available data is refused.
- Anything that wouldn't take effect is refused rather than saved: a filter not shown on the card's sheet, a binding to a field of another type, or a card change that would leave a filter with nothing to filter. The `fix` says what to do. A proposal never reports a change that didn't happen.

### What can be changed

The outline marks each card and filter `editable`. A card that isn't carries `notEditable.reason`:

| Reason | The card |
| --- | --- |
| `text` | A text card |
| `document_section` | A card in a Document section |
| `custom` | A custom visual |
| `needs_sql_access` | A SQL card, for a session that can't use SQL |
| `type_not_supported` | A chart type beyond the eight above |
| `not_expressible` | Uses something the public query can't state (a database table instead of a dataset, a Python card, a join set up in the editor); `detail` names it |

These cards can be read and removed, and are changed in the editor. A filter that isn't editable has `kind` and a reason: `global_date` (the report's date filter), `not_semantic` (a filter on a database table) or `scope_not_expressible` (a filter scoped to chosen cards). Those can be removed or excluded through the MCP.

## The Outline

[`semaphor_get_dashboard_details`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_get_dashboard_details) with `fields: ["outline"]` returns the dashboard in the change vocabulary. Narrow it with `sheetIds` or `cardIds`.

```json title="An outline (shortened)"
{
  "outline": {
    "dashboardId": "d_8f2k",
    "title": "Weights",
    "updatedAt": "2026-10-16T14:02:11.420Z",
    "access": { "canEdit": true, "canCopy": true },
    "datasets": [{ "domainId": "dom_yard", "datasetName": "tickets" }],
    "sheets": [
      {
        "sheetId": "s_overview", "title": "Overview", "kind": "dashboard",
        "cards": [
          { "cardId": "c_total", "title": "Net weight", "type": "kpi", "editable": true,
            "source": { "kind": "semantic", "domainId": "dom_yard", "datasetName": "tickets" },
            "query": { "domainId": "dom_yard", "datasetName": "tickets", "measures": ["net_weight"] } },
          { "cardId": "c_trend", "title": "Weight by day", "type": "line", "editable": true,
            "source": { "kind": "semantic", "domainId": "dom_yard", "datasetName": "tickets" },
            "query": { "domainId": "dom_yard", "datasetName": "tickets", "measures": ["net_weight"],
                       "dateField": "ticket_date", "timeGrain": "day" } },
          { "cardId": "c_notes", "title": "Notes", "type": "text", "editable": false,
            "notEditable": { "reason": "text", "message": "Text cards can't be changed through the MCP yet. Change them in the editor." } }
        ],
        "layout": [
          { "tiles": [{ "cardIds": ["c_total"], "width": "full" }] },
          { "tiles": [{ "cardIds": ["c_trend"], "width": "half" }, { "cardIds": ["c_notes"], "width": "half" }] }
        ]
      }
    ],
    "filters": [
      { "filterId": "f_yard", "label": "Yard", "editable": true, "domainId": "dom_yard", "datasetName": "tickets",
        "field": "yard", "selection": "multiple", "sheetIds": "all",
        "reaches": ["c_total", "c_trend"], "excluded": [], "notReached": [] }
    ]
  }
}
```

| Field | What it holds |
| --- | --- |
| `updatedAt` | When the dashboard was last saved. An apply checks it: a newer save means `proposal_stale`. |
| `access` | `canEdit`: change it in place. `canCopy`: you can't edit it but can save a private copy with `"saveAs": "copy"`. |
| `datasets` | Every dataset a card or filter uses |
| `sheets[].kind` | `dashboard` (a grid sheet, which takes cards) or `document` (its section cards are listed, never editable) |
| `sheets[].cards[]` | `cardId`, `title`, `type`, `source`, `editable`, the `query` (and `matrix`) for editable cards, `linkedVisualId` for a card showing a library visual, `notEditable` otherwise |
| `sheets[].layout` | Grid sheets only: the desktop layout as rows from the top. Each tile is one frame with its width (`full`, `half`, `third`, `quarter`) and its cards, the shown card first (a frame can hold cards as tabs). It always covers the whole sheet, even when `cardIds` narrows the cards. |
| `filters[]` | Each filter with its field, `selection`, defaults, `datePicker`, `sheetIds` (`"all"`, or a list), `exceptSheetIds`, `includesDocumentSheets`, and its reach: `reaches` (card IDs it applies to), `excluded`, `notReached` (in scope, not matched) and `blocked` |
| `truncated` | Present when the dashboard has more than 50 cards: queries are left out. Narrow with `sheetIds` or `cardIds` to get them. |

## Save, Copy and Apply

**Saving a copy.** When the outline says `"access": { "canEdit": false, "canCopy": true }`, propose with `"saveAs": "copy"`. The copy is private to you, keeps the original's card and filter IDs, and is named "_title_ (copy)". The apply response names the copy in `target` and the original in `target.copiedFrom`.

[`semaphor_apply_change`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_apply_change) returns:

```json title="Apply response"
{
  "applied": true,
  "replayed": false,
  "target": { "kind": "dashboard", "dashboardId": "d_8f2k", "title": "Weights", "updatedAt": "2026-10-16T14:20:03.114Z",
              "url": "https://semaphor.cloud/project/p_.../dashboard/d_8f2k" },
  "created": [{ "handle": "$yard", "kind": "filter", "id": "f_91c2", "title": "Yard" }],
  "summary": ["Adds the filter \"Yard\" on every sheet, single select, default CMR."],
  "warnings": []
}
```

| Field | What it holds |
| --- | --- |
| `applied` | `true` when the change was saved |
| `replayed` | `true` when this is the recorded result of an earlier apply of the same proposal |
| `target` | The dashboard (or domain) changed, with its new `updatedAt`. `url` opens it in Semaphor, for organization users. A copy adds `copiedFrom`. |
| `created` | What the apply created, with the IDs to use from now on: cards and filters (with their `handle`), and for a copy or a generated dashboard, `{ "kind": "dashboard", "id", "title" }` |
| `summary` | The sentences that were applied |
| `warnings` | Anything to tell the user, such as `commit_failed` |

**What apply checks.** Apply saves only if the dashboard is still the one previewed: if anyone saved it, or a library visual it shows changed, it returns `proposal_stale` and saves nothing. Read the outline again and propose again. Apply also checks your access again as it is now, so `copy_required`, `not_permitted` or `capability_required` can come from apply too.

**Commits.** In projects that require a commit message, an organization user's apply commits with the proposal's sentences followed by "(through the MCP)". End users' changes never commit. If the commit fails after the save, the change stays saved and the response carries the warning `commit_failed`.

## Generate a Dashboard

`dashboard.generate` plans a new private dashboard from a prompt over one semantic domain. It takes no `dashboardId` target, and its own `domainId` is required:

```json title="Propose: generate a dashboard"
{
  "changes": [{
    "op": "dashboard.generate",
    "prompt": "Weekly revenue by region, with top customers",
    "domainId": "dom_abc",
    "datasetNames": ["orders"]
  }]
}
```

Semaphor plans the dashboard once, when you propose, and stores that plan; the target is `{ "kind": "new_dashboard" }`. Apply creates exactly that plan and never plans again, so the dashboard always matches the preview, and it checks that you can still create dashboards in the project. The apply response's `target` is the new dashboard with its `url`, and `created` lists it. If the plan fails validation, propose returns `invalid_change`: name the measures and dimensions to show, or pick datasets with `datasetNames`, then propose again. A plan the planner had doubts about comes back with the warning `dashboard_plan_warning`.

## Add a Relationship

`relationship.add` adds a relationship between two datasets of a domain, so queries can join them:

```json title="Propose: add a relationship"
{
  "domainId": "dom_abc",
  "changes": [{
    "op": "relationship.add",
    "from": { "dataset": "orders", "fields": ["customer_id"] },
    "to": { "dataset": "customers", "fields": ["id"] },
    "cardinality": "many_to_one",
    "defaultJoinType": "LEFT"
  }],
  "rationale": "Break revenue down by customer segment."
}
```

| Field | Values |
| --- | --- |
| `from` | The many side: a dataset name and one or more of its fields |
| `to` | The other side, usually a key of that dataset |
| `cardinality` | `many_to_one` or `one_to_one` |
| `defaultJoinType` | `LEFT` or `INNER` |
| `description` | Optional |

The relationship is validated when you propose. If a field doesn't exist, or the domain isn't one you can change, propose returns `invalid_change` and stores nothing. If someone edits the domain between your preview and your apply, apply returns `proposal_stale`. A relationship apply returns `target: { "kind": "domain", "domainId" }` and an empty `created`.

## Retries

[`semaphor_apply_change`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_apply_change) is safe to retry with the same `proposalId`. A change is never applied twice.

| You call apply again and the first apply... | You get |
| --- | --- |
| Succeeded | The recorded result again, with `replayed: true` |
| Failed | The recorded error. Propose again if you still want the change. |
| Is still running | `apply_in_progress`. Retry the same call shortly. |
| Started over 5 minutes ago and never recorded a result | `apply_outcome_unknown`, with a `howToCheck` message that says how to check whether the change landed |

If your client loses the response to an apply, retry the same call. You get the same result back, not a second copy or dashboard. Records are kept for 24 hours, so a retry still answers after the proposal itself has expired. After `apply_outcome_unknown`, follow `howToCheck` before proposing again.

## Errors

Change tools return errors as `isError: true` results with a `code`, a `message` and, for most codes, a `fix` that says what to do next. Problems with individual changes are listed in `problems`, each with a `path` such as `changes[2].query.measures[0]`, and the top-level `code` is the first problem's. A list with any problem is refused whole, and nothing is stored.

| Code | Meaning | What to do |
| --- | --- | --- |
| `write_scope_required` | The session doesn't have `mcp:write`; the error lists `requiredScope` and `grantedScopes` | Log in with write access, or mint a token with `mcp:write` |
| `invalid_change` | A change doesn't match the tool's fields, more than 20 changes, or a `relationship.add` or `dashboard.generate` that isn't alone in its proposal | Fix the fields named in `problems` |
| `invalid_change_target` | The target doesn't fit the changes | Dashboard changes take `dashboardId`; `relationship.add` takes `domainId`; `dashboard.generate` takes neither |
| `copy_required` | You can view the dashboard but not edit it | Propose again with `"saveAs": "copy"` |
| `not_permitted` | You can't make this change at all, for example create a dashboard in this project, or commit where the project requires it | Ask an admin for access |
| `project_not_found` | The `projectId` for `dashboard.generate` or `relationship.add` isn't one you can reach | Use a project from [`semaphor_list_projects`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_list_projects) |
| `dashboard_not_found` | The dashboard doesn't exist, or you can't open it | Find it with [`semaphor_list_dashboards`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_list_dashboards) |
| `card_not_found`, `filter_not_found`, `sheet_not_found` | An ID isn't on this dashboard; `position.after` names a card that isn't on a grid sheet | Read the outline again and use its IDs |
| `handle_unknown` | A handle is used before a change defines it | Define the handle in an earlier change, or use an ID |
| `sheet_required` | The dashboard has several grid sheets and `card.add` or `layout.arrange` named none | Pick one of the sheets the error lists |
| `sheet_not_a_grid` | The sheet is a Document, or the dashboard has no grid sheet, so it takes no card and can't be arranged | Use a grid sheet from the outline |
| `field_not_found` | A field isn't in the dataset | Use one of the fields the error suggests, or read [`semaphor_get_dataset_schema`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_get_dataset_schema) |
| `card_not_editable`, `filter_not_editable` | The outline marks it read only, or the change wouldn't take effect | Follow the `fix`; change it in the editor; removing it still works |
| `type_not_supported` | The query doesn't fit the card type | Use a type the query fits, or send a complete query with the type change |
| `query_form_mismatch` | A query patch changes form (metric, records, SQL) without a complete query, or mixes keys of two forms | Send the complete query of the new form |
| `capability_required` | The change needs SQL and the session can't use SQL | Use a dataset query, or ask an admin for SQL access |
| `document_invalid` | The changed dashboard wouldn't save, for example a filter mapped to a field you can no longer use | Change the proposal, or read the outline and propose again |
| `proposal_not_found` | Unknown or expired `proposalId`, or another user's proposal | Propose again |
| `proposal_stale` | The dashboard, a library visual it shows, or the domain changed after the preview | Read it again and propose again |
| `apply_in_progress` | The first apply is still running | Retry the same call |
| `apply_outcome_unknown` | The first apply never recorded a result | Check with `howToCheck` before proposing again |
| `apply_failed` | The apply failed for another reason, and nothing was saved | Read the message; propose again |

## Warnings

Warnings come back in `warnings` on a proposal or an apply. They don't stop anything; show them to the user.

| Warning | When |
| --- | --- |
| `type_change_drops` | A type change dropped settings the new type can't use; it lists them |
| `label_kept_after_change` | A custom column label was kept after its field's aggregate or grain changed, and may no longer fit |
| `filter_not_reaching_cards` | A filter doesn't reach some cards on its sheets; it lists them with `suggestedFields` to bind |
| `detaches_linked_visual` | Changing the card makes a library visual's card local to the dashboard |
| `control_binding_dropped` | Controls bound to levels or measures the change removes stop changing the card; `details.controlIds` lists them |
| `window_saved_as_fixed_dates` | The card stores the date window as fixed dates, so it won't move forward on later loads. Use a rolling window anchored to now, or a calendar window, to keep it moving. |
| `dashboard_plan_warning` | `dashboard.generate`: the plan has something to review |
| `relationship_warning` | `relationship.add`: something the domain reported when the relationship was saved |
| `commit_failed` | Apply: the change was saved, but the commit the project requires failed |
| `apply_not_recorded` | Apply: the change was saved, but its result couldn't be recorded. A retry reports `apply_outcome_unknown`; don't propose the change again. |

## Permission and Approval

These are two different things.

- **Permission** is enforced by Semaphor on every call: the `mcp:write` scope, your role, the per-change rule above, and your permissions on the dashboard or project.
- **Approval** of a particular change happens in your client, before it calls [`semaphor_apply_change`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_apply_change).

[`semaphor_apply_change`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_apply_change) is marked as destructive and [`semaphor_propose_change`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_propose_change) as read only. These MCP annotations are advisory hints: they don't force a confirmation dialog, and Semaphor can't prove that a person saw the preview. What happens before apply runs depends on the client:

| Client | Before [`semaphor_apply_change`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_apply_change) runs |
| --- | --- |
| Claude Code | Asks before an MCP tool runs, unless the tool is already allowed. You can choose "Yes, and don't ask again", or add the tool to `permissions.allow` (for a server you configured yourself, the rule is `mcp__<server>__<tool>`). Claude Code doesn't use the annotations to decide. Running with permissions bypassed skips these prompts. In non-interactive `claude -p` runs, a call that would ask is denied unless `--allowedTools` or an allow rule covers it. |
| claude.ai | Uses the annotations for default permissions: read-only tools can run without a per-call confirmation, and destructive tools prompt. |
| Codex | Follows your Codex approval settings for MCP tool calls. |
| Headless token | No per-change approval. Minting a token with `mcp:write` is delegated authority: whoever minted it approved writes in advance. |

For an unattended Claude Code run that should read but never write, allow only the read tools, for example `--allowedTools "mcp__semaphor-token__semaphor_analyze,mcp__semaphor-token__semaphor_get_analysis_context"`, or mint the token without `mcp:write`. The second is the stronger control, because the server then refuses writes whatever the client does.

See [Authentication and Security](/docs/mcp/authentication-and-security#permission-and-approval) for more.

# Live Changes in Embedded Dashboards

Let your own agent change the dashboard your user is looking at, unsaved, with Undo, using one token for the iframe and the MCP.

Source: https://docs.semaphor.cloud/docs/mcp/live-changes

Your product already shows a Semaphor dashboard in an iframe, and it has its own agent. With live changes, your user asks your agent for a change ("add revenue by region as a bar chart", "make that weekly", "rename the Region filter to Area") and it appears in the embedded dashboard a moment later, without a refresh.

The change lands **unsaved**. Your user sees it, can undo it, can keep editing by hand, and keeps everything with **Save** or discards it with **Cancel**, as with any edit they make themselves. Your agent reads what is on screen, unsaved cards and filters included, and is told what happened to each change. You add no front-end code: your agent keeps calling the Semaphor MCP from your server, and your page keeps showing the iframe.

Your backend mints one live token. The iframe opens with it and stays connected; your agent calls the MCP with the same token; each change is sent to that iframe and lands unsaved.:

1. **Your backend**: mints one live token per user session
2. **Your page**: shows the iframe with that token
3. **Your agent**: calls the MCP with the same token
4. **The dashboard**: shows the change, unsaved, with Undo

## Set It Up

### Mint one live token per user session

Call `POST https://semaphor.cloud/api/v1/token` from your server with the `mcp` option and `live: true`. The same token opens the embedded dashboard and calls the MCP.

```bash title="A live token for one end user"
curl -X POST https://semaphor.cloud/api/v1/token \
  -H "Content-Type: application/json" \
  -d '{
    "projectId": "p_...",
    "projectSecret": "<project-secret>",
    "tenantId": "tenant_123",
    "endUserId": "maria",
    "endUserEmail": "maria@acme.com",
    "targetDashboardId": "d_8f2k",
    "parentOrigin": "https://app.acme.com",
    "mcp": { "live": true },
    "tokenExpiry": 28800
  }'
```

| Field | What it does |
| --- | --- |
| `mcp: { "live": true }` | Makes the token live: the iframe it opens becomes a live screen, and the MCP changes that screen instead of saving. |
| `mcp.scopes` | Read scope (`mcp:read`), the default and the only scope a live token takes. Your agent changes the dashboard on screen, and your user's Save keeps the change; the token itself never saves. If your agent should save directly, use a separate write token kept on your server; `live` with `mcp:write` is refused with `mcp_live_write_not_allowed`. |
| `mcp.sql` | On by default. `false` turns off the MCP's SQL tools and the SQL form of a card in changes. The dashboard's own cards, SQL cards included, still run as in any embed. |
| Identity fields | `orgUserId`, or `endUserId` with `tenantId`, as for any MCP token. Changes reach the screen only when that user can edit the dashboard. |
| `targetDashboardId` | The dashboard the iframe opens. |
| `parentOrigin` | Your page's exact origin, if you want the optional host events below. |
| `tokenExpiry` | Seconds, 60 to 86,400 (24 hours). Mint it for a working session: an open embed's token isn't refreshed. |

> **Keep the project secret on your server:** Mint tokens on your backend. Pass the `accessToken` to your page for the iframe and to your agent for the MCP; never send the project secret to a browser.

### Show the iframe with the toolbar

Put the token in the iframe and show the toolbar, so your user has Save and Cancel:

```html
<iframe src="https://semaphor.cloud/embed/<accessToken>?showControls=true" width="100%" height="100%"></iframe>
```

The iframe connects for live changes on its own while it shows a dashboard your user may edit, with the toolbar shown. Nothing else is needed on your page.

The connection is a long-lived streaming response, so a proxy or firewall that holds responses back until they finish will stop it; if your users are on a corporate network, check that it passes streaming responses through. On a self-hosted deployment with more than one API container, point them all at the same Redis, so your agent's call reaches the tab whichever container it lands on.

### Give the same token to your agent

Your agent calls `https://semaphor.cloud/api/mcp` with `Authorization: Bearer <accessToken>`, as in [Connect with a Token](/docs/mcp/connect-with-token#point-a-client-at-the-token). With a live token, the dashboard tools read the screen and [`semaphor_propose_change`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_propose_change) sends its change there.

## What Your Agent Does

A turn is two calls:

1. **Read the screen.** [`semaphor_get_dashboard_details`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_get_dashboard_details) with `fields: ["outline"]` describes the dashboard as it is on screen right now, unsaved edits included. The cards, bindings, SQL and template fields, and [`semaphor_get_dashboard_analysis_context`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_get_dashboard_analysis_context), read the screen the same way. A call that reads contents names one dashboard with `dashboardId`.
2. **Propose the change.** [`semaphor_propose_change`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_propose_change) builds the change from what is on screen, sends it there, and waits a few seconds for the result. With a live token it changes the open dashboard in place: it takes no `saveAs`, `preview` or `domainId`, and it never saves.

```json title="Propose: add a bar chart to the dashboard on screen"
{
  "dashboardId": "d_8f2k",
  "changes": [{
    "op": "card.add",
    "type": "bar",
    "title": "Revenue by region",
    "query": {
      "domainId": "dom_sales",
      "datasetName": "orders",
      "measures": ["revenue"],
      "dimensions": ["region"]
    }
  }]
}
```

The result carries `delivery`, which says what became of the change:

```json title="The change landed"
{
  "proposalId": "chg_...",
  "summary": ["Adds the bar chart \"Revenue by region\"."],
  "delivery": { "deliveryId": "8a1f...", "outcome": { "status": "applied" } },
  "next": "The change is on the user's screen, unsaved; they keep it with Save or remove it with Undo. ..."
}
```

| `delivery.outcome.status` | What it means | What the agent does |
| --- | --- | --- |
| `applied` | The change is on screen, unsaved. | Tells the user what changed. They keep it with Save or remove it with Undo. |
| `not_applied` | The screen didn't apply it. `reason` and `message` say why: `dashboard_changed` (the user changed the dashboard, or saved or cancelled, while the agent worked), `not_editable` (the editor is open, or Save isn't shown), `refused` (the change no longer fits) or `expired` (it arrived too late to land). | For `dashboard_changed` and `expired`, reads the outline again and proposes again. Otherwise tells the user. |
| `not_confirmed` | The screen didn't confirm it within a few seconds: it closed or reconnected after receiving the change, or its answer was lost. The change may or may not be on screen. | Reads the outline, which shows the screen as it is, and proposes only what is missing. |

The `deliveryId` matches the one in the [host events](#mirror-changes-in-your-page-optional), so your page and your agent can refer to the same change.

### When the screen can't be read

| Code | Situation |
| --- | --- |
| `screen_unavailable` | The dashboard isn't open on a connected live screen with this token: the tab is closed or reconnecting, the user can't edit it, or the toolbar isn't shown. Propose returns it too when the screen closed between the read and the change; nothing was sent, so proposing again is safe. |
| `multiple_screens` | The dashboard is open in more than one tab or window with this token. Ask the user to close the others. |
| `screen_copy_too_large` | The dashboard on screen is larger than 2 MiB. |
| `one_dashboard_per_read` | A read of contents named several dashboards. Call again with `dashboardId` for each. |
| `live_channel_unavailable` | Live changes couldn't be reached for a moment. Try again shortly. |

[`semaphor_analyze`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_analyze) with `fromCard` runs the card as saved. A card that exists only on screen is described in the outline; build its query from the outline's fields.

## What Your User Sees

The change appears in place, and its data loads like any card's. The dashboard is in editing, with Save and Cancel in the toolbar, and a one-line note under the toolbar reads **Changed by your assistant · Undo**. The note stays until Save, Cancel or Undo. Your user can undo the change, keep editing by hand, ask for more, or save.

## Mirror Changes in Your Page (optional)

With `parentOrigin` in the token, the iframe posts two more messages to your page beside `dashboard:dirty-state` (see [Warn before leaving with unsaved changes](/docs/embedding/iframe#warn-before-leaving-with-unsaved-changes)), so your chat can show what landed:

```ts
{
  source: 'semaphor-embed',
  version: 1,
  type: 'dashboard:change-applied', // or 'dashboard:change-undone' after the note's Undo
  dashboardId: 'd_8f2k',
  payload: { deliveryId: '8a1f...', summary: ['Adds the bar chart "Revenue by region".'] },
}
```

Save and Cancel are reported by `dashboard:dirty-state`, as today.

## Token Lifetime

A live token works for the iframe and the MCP until its `tokenExpiry`. When it expires while the dashboard is open, the live connection closes and the dashboard shows its session-expired state, as embeds do. Mint one token per working session, with a `tokenExpiry` that covers the session and no more, and mint a new one for the next session.

# Authentication and Security

How Semaphor MCP identifies each caller, and how your security policies, SQL access and write permissions apply to every call.

Source: https://docs.semaphor.cloud/docs/mcp/authentication-and-security

Every MCP query passes through the same security enforcement as your dashboards. Semaphor builds a caller profile from the credential on each request, then applies your existing policies, with no MCP-specific configuration.

Each request's credential becomes a caller profile. Then connection-level, schema-level and row-level security apply, as they do for dashboards.:

1. **Caller profile**: organization, project and tenant reach
2. **CLS**: which connections the caller can use
3. **SLS**: which schemas, tables and columns
4. **RLS**: which rows come back

## Credentials

Semaphor MCP accepts two credentials, sent on every request as `Authorization: Bearer <token>`. Which one you use depends on who is calling.

| Caller | Credential | Reach |
| --- | --- | --- |
| You, in Claude Code, Codex, claude.ai, ChatGPT or Cursor | [OAuth login](#oauth-login) with your Semaphor account | Every project you belong to |
| Automation (CI, schedulers, scripts) | [MCP project token](/docs/mcp/connect-with-token) for an organization user | One project, fixed by the token |
| An agent in your product, acting for an end user | [MCP project token](/docs/mcp/connect-with-token) for a tenant user | One project and that user's tenant |

Hiding a tool from a caller is for clarity, not the security control. The server still authorizes every call against the caller's role, scopes and permissions.

## OAuth Login \[#oauth-login]

People in an MCP client sign in with their Semaphor account in the browser, and the client stores and refreshes the login. OAuth login is for organization users. See [Connect an AI Client](/docs/mcp/connect-ai-client) for each client's steps.

### Scopes

| Scope | Grants |
| --- | --- |
| `mcp:read` | Every read tool: discovery, queries and dashboards, and proposing a [change](/docs/mcp/changes), which saves nothing. Required for every request. |
| `mcp:write` | Applying a change: [`semaphor_apply_change`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_apply_change), which saves dashboard changes, generated dashboards and relationships. |

A client that doesn't ask for a scope gets `mcp:read` only. To apply changes, the client must request `mcp:read mcp:write` at login. Write scope is never enough on its own: the change tools also need a role that can author dashboards (an organization Author or higher, or an end user with the Power User role), and your permissions still decide what you can change.

### OAuth Endpoints

Most clients discover these from the server's metadata. They're listed for client developers; on a self-hosted install, replace `https://semaphor.cloud` with your Semaphor URL.

| Parameter | Value |
| --- | --- |
| MCP endpoint | `https://semaphor.cloud/api/mcp` |
| Protected resource metadata | `https://semaphor.cloud/.well-known/oauth-protected-resource` |
| Authorization server metadata | `https://semaphor.cloud/.well-known/oauth-authorization-server` |
| Authorization endpoint | `https://semaphor.cloud/api/mcp/oauth/authorize` |
| Token endpoint | `https://semaphor.cloud/api/mcp/oauth/token` |
| Client registration | `https://semaphor.cloud/api/mcp/oauth/register` (dynamic client registration) |
| Grant types | `authorization_code`, `refresh_token` |
| PKCE | `S256` |
| Scopes | `mcp:read`, `mcp:write` |

## MCP Project Tokens

Automation and agents in your product use a short-lived token your backend mints with the project secret. Minting, request fields and mint errors are on [Connect with a Token](/docs/mcp/connect-with-token#request-fields).

## Isolation

### Organization

Every caller is scoped to its organization. Cross-organization access isn't possible.
Interactive callers reach only projects they belong to, checked on each call that passes a `projectId`.
Token callers are pinned to the token's project. Another `projectId` returns an error.

### Tenant

An end-user token acts for one end user in one tenant, with the token's security settings applied.
An end user with the Power User role can propose changes to dashboards they can edit, and apply them with a write-scoped token. End users never generate dashboards or change semantic models.
Dashboard access summaries list only the rules that reach the caller's tenant.

## Security Policies

Semaphor enforces three layers of policy on every query, from every query tool, including [`semaphor_query_sql_advanced`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_query_sql_advanced):

| Policy | What it controls |
| --- | --- |
| **CLS** (connection-level) | Which connections a caller can use |
| **SLS** (schema-level) | Which schemas, tables and columns are visible |
| **RLS** (row-level) | Which rows are returned, through filters injected into the SQL |

Two callers can get different numbers for the same question. That's expected: each sees only their own data.

## SQL Access

SQL and physical table discovery reach your database more directly than the semantic model, so they're limited:

- **Organization users** need an Author role or higher. Viewers stay on the semantic model.
- **Tenant users** have SQL unless the token turns it off.
- **Any token** minted with `"mcp": { "sql": false }` has no SQL or physical discovery.

Some tools every caller sees also accept arguments that reach physical tables, such as physical mode on [`semaphor_get_dataset_schema`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_get_dataset_schema). Without SQL access, those arguments return `capability_required`, and the semantic form of the tool keeps working.

## Semantic Domain Access

When you mint a token, `semanticDomainAccess` controls which semantic domains the agent can see:

```json title="Limit an end user's agent to two domains"
{
  "projectId": "p_...",
  "projectSecret": "<project-secret>",
  "tenantId": "tenant_123",
  "endUserEmail": "maria@acme.com",
  "mcp": { "scopes": ["mcp:read"] },
  "semanticDomainAccess": { "mode": "include", "domains": ["sales", "marketing"] }
}
```

| Mode | Behavior |
| --- | --- |
| `all` | Every semantic domain (default) |
| `none` | No semantic domains |
| `include` | Only the listed domains |
| `exclude` | Every domain except the listed ones |

A token whose domain access excludes every domain doesn't fall back to physical tables: [`semaphor_get_analysis_context`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_get_analysis_context) reports `recommendedPath: "restricted"`.

## Permission and Approval \[#permission-and-approval]

Writes raise two separate questions: may this caller write, and has someone approved this particular change?

### Permission: enforced by Semaphor

Applying a change needs the `mcp:write` scope, a role that can author dashboards, the per-change credential rule (semantic model changes need an interactive login), and the caller's permissions on the dashboard or project. Every change is previewed, then applied exactly as previewed.

### Approval: a client behavior

[`semaphor_apply_change`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_apply_change) is marked destructive and [`semaphor_propose_change`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_propose_change) read only. With a [live token](/docs/mcp/live-changes), [`semaphor_propose_change`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_propose_change) changes the user's screen (unsaved, undoable), so it is marked not read only and not destructive. These hints don't force a confirmation, and Semaphor can't prove a person saw the preview. Whether you're asked depends on your client: see [Making Changes](/docs/mcp/changes#permission-and-approval).

**Headless write tokens are delegated authority.** Minting a token with `mcp:write` approves that automation's writes in advance, with no per-change human approval. Grant write scope only to jobs that need it, with the shortest practical `tokenExpiry`.

## Session Security

- **Logins** are tied to the user's Semaphor account. The transport is stateless, and project access is checked on every call.
- **Tokens** are signed, carry their project, identity, scopes and SQL setting, and expire within 24 hours. A token that fails verification gets `401` with "MCP project token is invalid or expired."
- **Proposals** live 30 minutes and can only be applied by the user who proposed them, in the same project, with access to it at the time. Apply checks permissions again as they are then.

## Best Practices

- **Grant read unless you need writes.** Add `mcp:write` only for agents and automation that change dashboards.
- **Turn off SQL for end users who don't need it.** Set `"sql": false` so the agent stays on the semantic model.
- **Limit domains.** Use `semanticDomainAccess` to show the agent only the domains it needs.
- **Keep tokens short-lived.** Mint per job or per user session, with the shortest `tokenExpiry` that works.
- **Prefer [`semaphor_analyze`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_analyze).** It uses governed semantic domains, relationship diagnostics, and all three policy layers.
- **Store secrets safely.** Use environment variables or a secret manager, never commit tokens or project secrets, and keep MCP traffic on HTTPS.

# Tools Reference

Every Semaphor MCP tool, its arguments, what it returns, and which callers can use it.

Source: https://docs.semaphor.cloud/docs/mcp/tools-reference

Semaphor MCP has 20 tools in nine families. Each session sees only the tools its caller can use, so most sessions list fewer. Every tool name on these pages links here, and each tool's argument table is read from the server's own tool list, so it always matches the tool.

| Family | Tools |
| --- | --- |
| [Session](#session) | [`semaphor_list_projects`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_list_projects), [`semaphor_get_access_context`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_get_access_context) |
| [Semantic discovery](#semantic-discovery) | [`semaphor_get_analysis_context`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_get_analysis_context), [`semaphor_list_semantic_domains`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_list_semantic_domains), [`semaphor_list_datasets`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_list_datasets), [`semaphor_get_dataset_schema`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_get_dataset_schema), [`semaphor_get_domain_relationships`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_get_domain_relationships) |
| [Physical discovery](#physical-discovery) | [`semaphor_list_connections`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_list_connections), [`semaphor_list_databases`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_list_databases), [`semaphor_list_schemas`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_list_schemas), [`semaphor_list_tables`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_list_tables) |
| [Governed queries](#governed-queries) | [`semaphor_analyze`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_analyze), [`semaphor_matrix`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_matrix), [`semaphor_plan_analytics_recovery`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_plan_analytics_recovery) |
| [SQL](#sql) | [`semaphor_query_sql_advanced`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_query_sql_advanced) |
| [Dashboards](#dashboards) | [`semaphor_list_dashboards`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_list_dashboards), [`semaphor_get_dashboard_details`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_get_dashboard_details), [`semaphor_get_dashboard_analysis_context`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_get_dashboard_analysis_context) |
| [Changes](#changes) | [`semaphor_propose_change`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_propose_change), [`semaphor_apply_change`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_apply_change) |

## Who Sees Which Tools

Every session gets the session context, semantic discovery, governed query and dashboard tools. The rest depend on who is calling:

| Tools | Who gets them |
| --- | --- |
| [`semaphor_list_projects`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_list_projects) | Interactive logins only. Token sessions are pinned to the token's project. |
| Physical discovery and [`semaphor_query_sql_advanced`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_query_sql_advanced) | Sessions with SQL: an organization Author or higher (unless the token turns SQL off), or an end user whose token doesn't turn it off |
| [`semaphor_propose_change`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_propose_change) | Users who can author dashboards: an organization Author or higher, or an end user with the Power User role. Proposing saves nothing, so read-only sessions get it too. |
| [`semaphor_apply_change`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_apply_change) | The same users, in a session with the `mcp:write` scope |

| Caller | SQL | Write | Tools |
| --- | --- | --- | --- |
| Logged in, Author or Admin | Yes | Yes | 20 |
| Logged in, Author or Admin | Yes | No | 19 |
| Logged in, Viewer | No | No | 13 |
| Automation token, Author or Admin | Yes | Yes | 19 |
| Automation token, Author or Admin | Yes | No | 18 |
| Automation token, Author or Admin | Turned off | Yes | 14 |
| End-user token, Power User | Yes | Yes | 19 |
| End-user token, Power User | Turned off | No | 13 |
| End-user token, Viewer | Yes | No | 17 |
| End-user token, Viewer | Turned off | No | 12 |

Hiding a tool is for clarity, not the security control: the server authorizes every call. An argument that reaches physical tables or SQL inside a tool every caller sees (a physical source in [`semaphor_matrix`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_matrix), for example) returns `capability_required` for a session without SQL. Call [`semaphor_get_access_context`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_get_access_context) when a tool you expected is missing.

## Common Arguments

| Argument | Description |
| --- | --- |
| `projectId` | The project to work in. Interactive sessions pass it on project-scoped tools; a user with exactly one project can omit it. Token sessions use the token's project, and passing another project returns an error. |
| `responseFormat` | `"json"` (the default) or `"markdown"`. JSON results also arrive as structured content when your client supports it. |

Argument names are camelCase. A tool rejects an argument it doesn't define and names it in the error, so a misspelled argument is never silently ignored:

```text title="An unknown argument"
MCP error -32602: Input validation error: Invalid arguments for tool semaphor_get_analysis_context: Unrecognized key(s) in object: 'question'
```

## Session

### semaphor_list_projects

Lists the projects you can access. Available only in interactive sessions.

| Argument | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `responseFormat` | "markdown", "json" | No | `"json"` | Output format: "markdown" for human-readable or "json" for machine-readable |

### semaphor_get_access_context

Reports who the session is and what it can do. Call it when a tool you expected is missing.

| Argument | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `responseFormat` | "markdown", "json" | No | `"json"` | Output format: "markdown" for human-readable or "json" for machine-readable |

**Returns:** `actorType` (`organization` or `tenant`), `userId`, `organizationId`, `projectId`, `projectCount`, `accountProjectListingAvailable`, `scopeSummary` (one sentence), `mode` (`org_interactive`, `org_headless` or `tenant_embedded`), `credential`, `scopes` and `capabilities.sql`. End-user sessions add `tenantId` and the token's `semanticDomainAccess`.

```json title="Example response"
{
  "actorType": "organization",
  "projectId": "p_abc123",
  "mode": "org_headless",
  "credential": "mcp_project_token",
  "scopes": ["mcp:read"],
  "capabilities": { "sql": true },
  "accountProjectListingAvailable": false
}
```

## Semantic Discovery

### semaphor_get_analysis_context

The first call for any data question. It checks the semantic domains the caller can see and recommends where to go next.

| Argument | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `projectId` | string | No | | The project to work in. Interactive sessions pass it on project-scoped tools (a user with one project can omit it); token sessions use the token's project. |
| `responseFormat` | "markdown", "json" | No | `"json"` | Output format: "markdown" for human-readable or "json" for machine-readable |

**Returns:** `recommendedPath`, `recommendedNextTool`, `defaultAnalysisTool`, `fallbackAnalysisTool`, `guidance`, `semanticDomains`, `semanticDomainCount`, `fallbackConnections`, `projectId`, `projectCount`, `scopeSummary`, `actorType`, `mode` and `credential`.

`recommendedPath` is one of:

| Value | Meaning |
| --- | --- |
| `semantic` | Semantic domains are available. Next: [`semaphor_list_semantic_domains`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_list_semantic_domains). |
| `physical` | No semantic domains, but the caller can use connections. Next: [`semaphor_list_connections`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_list_connections). |
| `restricted` | Domains exist, but the token's `semanticDomainAccess` grants none of them. |
| `unavailable` | Nothing the caller can query in this project. |

```json title="Example response (shortened)"
{
  "projectId": "p_abc123",
  "semanticDomainCount": 3,
  "recommendedPath": "semantic",
  "recommendedNextTool": "semaphor_list_semantic_domains",
  "defaultAnalysisTool": "semaphor_analyze",
  "fallbackAnalysisTool": "semaphor_query_sql_advanced",
  "guidance": "Use semantic domains first for business questions. Then call semaphor_list_datasets for the chosen domain...",
  "semanticDomains": [{ "id": "dom_abc", "name": "sales", "label": "Sales" }]
}
```

### semaphor_list_semantic_domains

Lists the semantic domains the caller can see.

| Argument | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `projectId` | string | No | | Project ID to list domains from (uses authenticated user project if not specified) |

**Returns:** `domains`, each with `id`, `projectId`, `name`, `label`, `description`, `createdAt` and `createdBy`.

### semaphor_list_datasets

Lists the datasets in a semantic domain. Call it before [`semaphor_get_dataset_schema`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_get_dataset_schema) so the agent never guesses dataset names.

| Argument | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `projectId` | string | No | | The project to work in. Interactive sessions pass it on project-scoped tools (a user with one project can omit it); token sessions use the token's project. |
| `domainId` | string | Yes | | Semantic domain ID |

**Returns:** `datasets`, each with `name`, `label`, `description`, `analyticsRole` (such as `fact` or `dimension`), `type`, `domainId`, and connection metadata (`connectionId`, `connectionType`, `dialect`, `database`, `table`).

### semaphor_get_dataset_schema

Returns the fields of a semantic dataset, or the columns of a physical table.

- **Semantic mode** (preferred): pass `domainId` and `datasetName`. Any `connectionId` passed along is ignored.
- **Physical mode:** pass `mode: "physical"` or connection coordinates (`connectionId` or `connectionName`, with `databaseName`, `schemaName` and `tableName` as the connection needs). Physical mode needs SQL access.

| Argument | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `projectId` | string | No | | The project to work in. Interactive sessions pass it on project-scoped tools (a user with one project can omit it); token sessions use the token's project. |
| `mode` | "semantic", "physical" | No | | "semantic" (preferred) or "physical"; inferred from the coordinates you pass |
| `domainId` | string | No | | Semantic mode: the domain from semaphor_list_semantic_domains |
| `datasetName` | string | No | | Semantic mode: the exact dataset name from semaphor_list_datasets |
| `connectionId` | string | No | | Physical mode: the connection from semaphor_list_connections (ignored in semantic mode) |
| `connectionName` | string | No | | Physical mode: the connection by name, instead of connectionId |
| `databaseName` | string | No | | Physical mode: the database or catalog, when the connection has them |
| `schemaName` | string | No | | Physical mode: the schema, when the connection has them |
| `tableName` | string | No | | Physical mode: the table |
| `includeCalculatedFields` | boolean | No | `true` | Include calculated fields (default true) |
| `responseDetail` | "compact", "standard" | No | | Use "compact" for agent-friendly fields-only schema output that omits verbose relationship previews and heavy field metadata; key fields keep their references. |

**Returns:** `dataset` (its source), `fields`, `recommendedNextTool` and `toolChoiceGuidance`. Each field has `name`, `label`, `dataType`, `modelRole` (`fact`, `dimension` or `identifier`), `fieldKind`, `description` and a grounded `fieldRef`. In semantic mode with the standard detail, `reachableDatasets` previews related datasets one join away, with example refs such as `{ "name": "supplier_name", "datasetName": "dim_supplier" }`.

```json title="Semantic mode"
{ "domainId": "dom_abc", "datasetName": "orders", "responseDetail": "compact" }
```

### semaphor_get_domain_relationships

Returns the relationships between datasets in a semantic domain. Useful before multi-dataset questions.

| Argument | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `projectId` | string | No | | The project to work in. Interactive sessions pass it on project-scoped tools (a user with one project can omit it); token sessions use the token's project. |
| `domainId` | string | Yes | | Semantic domain ID |

**Returns:** `relationships`, each with `id`, `name`, `from`, `to`, `cardinality`, `defaultJoinType`, `isAutoJoin`, `joinPriority`, `confidence`, `isActive` and `description`.

## Physical Discovery

These tools need SQL access. Use them when the project has no semantic coverage for the question. Follow the capabilities [`semaphor_list_connections`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_list_connections) returns through the database and schema levels each connection needs.

### semaphor_list_connections

| Argument | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `projectId` | string | No | | The project to work in. Interactive sessions pass it on project-scoped tools (a user with one project can omit it); token sessions use the token's project. |

**Returns:** `connections`, with ID, name, type, dialect and the discovery levels each one has.

### semaphor_list_databases

| Argument | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `projectId` | string | No | | The project to work in. Interactive sessions pass it on project-scoped tools (a user with one project can omit it); token sessions use the token's project. |
| `connectionId` | string | Yes | | Connection ID to inspect |

**Returns:** `databases` (databases or catalogs).

### semaphor_list_schemas

| Argument | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `projectId` | string | No | | The project to work in. Interactive sessions pass it on project-scoped tools (a user with one project can omit it); token sessions use the token's project. |
| `connectionId` | string | Yes | | Connection ID to inspect |
| `databaseName` | string | Yes | | Database or catalog name |

**Returns:** `schemas`.

### semaphor_list_tables

| Argument | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `projectId` | string | No | | The project to work in. Interactive sessions pass it on project-scoped tools (a user with one project can omit it); token sessions use the token's project. |
| `connectionId` | string | Yes | | Connection ID to inspect |
| `databaseName` | string | No | | Database or catalog name |
| `schemaName` | string | No | | Schema name when the connection supports schemas |
| `nameCandidates` | array of string | No | | Candidate table/entity names to match after normalization |
| `limit` | integer (1 to 200) | No | `50` | Up to 200 tables (default 50) |

**Returns:** `tables` with exact coordinates. Pass `nameCandidates` when you have likely entity names, such as `["orders", "order_lines"]`.

## Governed Queries

### semaphor_analyze

The default query tool. It runs governed analytics over a semantic domain: metric by dimension, top-N, time series, filters, latest-available windows, period comparisons, period-change driver analysis, and joins through modeled relationships. Every call applies the caller's security policies.

| Argument | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `projectId` | string | No | | The project to work in. Interactive sessions pass it on project-scoped tools (a user with one project can omit it); token sessions use the token's project. |
| `fromCard` | object | No | | Run a dashboard card exactly as it runs on its dashboard, for example { "dashboardId": "...", "cardId": "..." }: its fields, filters, drill steps, dashboard inputs, date range and comparisons. Pass it without the query fields; the result is the card's own rows, with executionResult.constraints naming the filters and date windows it applied and any it skipped. A Matrix card, a text card and a card in a Document section cannot be run this way. |
| `domainId` | string | No | | Semantic domain id from semaphor_list_semantic_domains. |
| `datasetName` | string | No | | Exact dataset name from semaphor_list_datasets. |
| `measures` | array of object or null | No | | Required for semantic analysis: at least one measure. To count rows, use the "\__count" measure. Measure field names or source-bearing refs, for example \["sales", "profit"] or \[{ "name": "profit", "datasetName": "finance" }]. Optional measure aggregate override is supported, for example { "name": "gross_margin_value", "datasetName": "fact_sales_line", "aggregate": "AVG" }. Omit aggregate to use the semantic model default. |
| `primaryMeasure` | object | No | | Measure used for ranking, headline summaries, and period-change analysis. Must refer to one of measures after normalization. May carry the same aggregate override as the selected measure. |
| `dateField` | string or object | No | | Date column for time windows and comparisons, for example "order_date", or a source-bearing ref like { "name": "order_date", "datasetName": "orders" }. In standard queries it is only grouped when timeGrain is also provided. |
| `timeGrain` | "day", "week", "month", "quarter", "year" | No | | Time grouping for dateField. |
| `dimensions` | array of string or object | No | | Dimension column names, for example \["segment", "region"], or source-bearing refs from joined semantic datasets. |
| `comparison` | object | No | | Canonical comparison object, for example { "kind": "previous_period" }, { "kind": "previous_year" }, or { "kind": "target", "targetValue": 100 }. Omit comparison when no comparison is requested. |
| `analysis` | object | No | | Canonical shared metric analysis object. Use { "kind": "period_change" } for spike, decline, volatility, or period-over-period change analysis. |
| `analysisOrderBy` | "absolute_change", "positive_change", "negative_change", "period" | No | | Ordering for period-change analysis. Use "absolute_change" for biggest movements or "period" for chronological trend output. |
| `orderBy` | object | No | | Explicit result ordering for standard metric queries, for example { "field": { "name": "net_purchase_value", "datasetName": "fact_purchase_line" }, "direction": "desc" }. The field must be one selected metric, dateField, or dimension. |
| `timeWindow` | object | No | | Canonical bounded query window. Use calendar windows for whole calendar periods, such as { "kind": "calendar", "unit": "month", "period": "this" } (this month), { "kind": "calendar", "unit": "quarter", "period": "previous" } (the previous quarter) or { "kind": "calendar", "unit": "month", "period": "this", "toDate": true } (month to date). Use rolling windows such as { "unit": "day", "value": 14, "anchor": "now" } (the last 14 days up to now) or { "unit": "month", "value": 6, "anchor": "latest_available", "completeness": "complete_periods" }. Use absolute windows such as { "kind": "absolute", "start": "2025-01-01", "end": "2025-12-31" } only for fixed dates. Calendar windows and supported rolling windows stay relative when saved to a dashboard card. Rolling windows count back from now with anchor "now", or from the latest available value of their date field with anchor "latest_available" (the default when omitted). Absolute windows, second-based windows, and complete minute or hour periods are saved as fixed dates. |
| `calendarContext` | object | No | | Optional execution calendar context for timezone-aware date windows and grouping, for example { "tz": "America/Chicago", "weekStart": 0, "anchor": "now" }. Defaults to token calendar context or UTC. |
| `driverMode` | "absolute_delta", "largest_negative", "largest_positive", "positive_and_negative", "all" | No | | Driver output mode for previous-period comparisons. Use "all" for absolute, negative, and positive drivers. |
| `limit` | integer | No | | Most rows to return, at most 1000. Default 100. |
| `includePopulation` | boolean | No | `false` | When true, also return filtered population totals for the requested measures before dimension grouping and row limit. Use for share, concentration, and percent-of-total analysis. |
| `activeFilters` | array of object or string | No | `[]` | Optional canonical analytics filters or JSON-stringified filter arrays, for example \[{ "field": "direction", "operator": "equals", "values": \["Inbound"] }]. Use "values" arrays, not singular "value". Scalar operators such as equals/contains require exactly one value; between requires exactly two; in/not_in accept multiple values. |
| `responseFormat` | "markdown", "json" | No | `"json"` | Output format: "markdown" for human-readable or "json" for machine-readable |
| `responseDetail` | "compact", "standard", "debug" | No | `"standard"` | Controls JSON payload verbosity. "compact" removes duplicate root arrays, full SQL text, and repeated source-heavy intent blocks; "standard" preserves today's full governed response; "debug" is currently equivalent to standard. |

Also accepted, for Semaphor's own clients only: `chartTitle`, `chartType`, `connectionId`, `cardConfig`, `cardDataSource`. Agents should use the arguments above.

**Measures and fields.** Measures are names or refs such as `{ "name": "revenue", "datasetName": "orders" }`; a ref may add `aggregate` to override the modeled aggregate for this call, and `"__count"` counts rows. Dimensions and `dateField` take names or refs, including fields on related datasets.

**Time windows:**

| Window | Example |
| --- | --- |
| Calendar period | `{ "kind": "calendar", "unit": "month", "period": "this" }` (this month) |
| Previous period | `{ "kind": "calendar", "unit": "quarter", "period": "previous" }` |
| Period to date | `{ "kind": "calendar", "unit": "month", "period": "this", "toDate": true }` |
| Rolling, up to now | `{ "unit": "day", "value": 14, "anchor": "now" }` |
| Rolling, up to the latest data | `{ "unit": "month", "value": 6, "anchor": "latest_available" }` |
| Fixed dates | `{ "kind": "absolute", "start": "2025-01-01", "end": "2025-12-31" }` |

Rolling windows default to `anchor: "latest_available"`, which counts back from the newest date in the data rather than from today.

**Comparisons and drivers.** `comparison` is `{ "kind": "previous_period" }`, `{ "kind": "previous_year" }` or `{ "kind": "target", "targetValue": 100000 }`. `analysis: { "kind": "period_change" }` finds spikes, declines and period-over-period moves, ordered by `analysisOrderBy`. `driverMode` picks which drivers a comparison returns. See [Metric Comparisons](/docs/concepts/metric-comparisons).

```json title="Revenue by region, last six months"
{
  "domainId": "dom_abc",
  "datasetName": "orders",
  "measures": [{ "name": "revenue", "datasetName": "orders" }],
  "dimensions": [{ "name": "region", "datasetName": "customers" }],
  "dateField": { "name": "order_date", "datasetName": "orders" },
  "timeWindow": { "unit": "month", "value": 6, "anchor": "latest_available" },
  "limit": 10
}
```

**Returns:**

| Field | What it holds |
| --- | --- |
| `answerSummary` | One sentence about the result |
| `data.records` | The rows, keyed by column label, with `data.rowCount` and `data.rowLimitExceeded` |
| `executionResult.status` | `answered`, `partial` or `failed` |
| `executionResult.result` | The rows' typed `columns` (name, label, role, data type, source), `rowCount`, `limit` and `rowLimitExceeded` |
| `executionResult.fieldsUsed` | Each field the query ran with, its role and aggregate |
| `executionResult.constraints` | What the query applied, and what it skipped (below) |
| `executionResult.coverage` | `answeredObligations` and `missingObligations`: what part of the question was answered |
| `executionResult.validation` | `ok`, `errors`, `warnings` and `repairHints` |
| `executionResult.relationshipDiagnostics` | How the query joined datasets, and any fan-out risk |
| `querySpec`, `compiledQuery`, `metadata` | The query as resolved and compiled, with the join plan and the generated SQL |
| `comparison`, `drivers`, `changes` | For comparison and period-change calls |

```json title="Example response (shortened)"
{
  "answerSummary": "Successfully executed query spec (5 rows).",
  "data": {
    "records": [
      { "Region": "West", "Revenue": 1758699.06 },
      { "Region": "East", "Revenue": 1670049.2 }
    ],
    "rowCount": 5,
    "rowLimitExceeded": true
  },
  "executionResult": {
    "status": "answered",
    "resultShape": "records",
    "constraints": {
      "applied": [
        { "kind": "filter", "field": { "name": "orders.order_date", "label": "Order Date" }, "operation": "between",
          "values": ["2026-09-17", "2026-10-17"], "valueType": "date", "timezone": "America/Chicago" }
      ],
      "skipped": []
    },
    "coverage": { "answeredObligations": ["governed_query_executed"], "missingObligations": [] }
  }
}
```

`executionResult.constraints` says what the query applied:

- `applied`: each filter it ran with (field, operation, and values as executed; a relative date window shows the dates it resolved to), the card's own filters when you ran a card, and each measure's comparison windows. When measures ran over different periods (one for the full month, one month to date), a filter that applied to some of them names them in `measures`. A date filter names the calendar zone the query ran in as `timezone`: bounds that span whole days in that zone are those dates.
- `skipped`: each filter it left out, with the reason: `dataset_not_reachable`, `field_not_resolvable`, `missing_metadata` or `unsupported_calculated_filter`.

It never includes security policies or their values. `"responseDetail": "compact"` leaves out duplicate arrays, full field source metadata and the full SQL text, and lists what it left out in `omitted`.

When a field is wrong or missing, the tool returns `isError: true` with a `validation` payload: a code such as `missing_measure` or `invalid_dimension`, the valid candidates from the schema, and a recommended next step.

#### Run a dashboard card

Pass `fromCard` to answer from a dashboard card exactly as your users see it. The tool runs the card's own query with everything it runs on its dashboard: its fields, filters and drill steps, the dashboard's default inputs and date range, its parameters and its comparisons. The card runs as a new viewer opens the dashboard, under your security policies, and you need access to the dashboard.

```json title="The card's own answer"
{ "fromCard": { "dashboardId": "dash_abc", "cardId": "card_123" } }
```

The result is the card's rows (at most 1,000) with `executionResult.metadata.queryApproach` set to `"dashboard_card_replay"`, and `executionResult.constraints` naming the filters and date windows the card applied. To ask something the card doesn't show, build the query with the arguments above instead.

| Error code | When |
| --- | --- |
| `card_not_found` | The dashboard or card doesn't exist, or you can't open the dashboard |
| `card_not_supported` | A Matrix card, a text card or a card in a Document section; `details.reason` is `matrix`, `no_query` or `document_section` |
| `from_card_unavailable` | The card can't run right now (its dashboard filter mapping needs fixing), or the call also passed query arguments |

See [Querying Data](/docs/mcp/discover-and-query) for more examples.

### semaphor_matrix

Runs a governed Matrix window: multi-level row and column hierarchies, totals and subtotals, scoped sorting, and Top or Bottom N with Others.

| Argument | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `intent` | object | Yes | | The Matrix definition: a grounded source, rows, columns, measures, filters, sorts and rankings |
| `matrixWindowRequest` | object | Yes | | Which pages and blocks of the matrix to load: each page names an axis, a parent path and a size |
| `projectId` | string | No | | The project to work in. Interactive sessions pass it on project-scoped tools (a user with one project can omit it); token sessions use the token's project. |
| `calendarContext` | object | No | | Time zone and week start for date windows, as for semaphor_analyze |

**Returns:** `executionResult`, with the requested window at `executionResult.result.window`. A page cursor asks for the next sibling window; it doesn't mean the data was cut off. A window is never a complete export, and unloaded cells aren't zeros. This tool always returns JSON.

### semaphor_plan_analytics_recovery

Plans the next governed tool calls when an agent has a typed question and schema evidence but still has unanswered parts. It returns `plannedToolCalls` (such as [`semaphor_get_dataset_schema`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_get_dataset_schema) or [`semaphor_analyze`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_analyze)) and `diagnostics`. It plans; it doesn't run queries.

| Argument | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `operationIntent` | object | Yes | | The typed question to answer |
| `groundedSchemas` | array of object | No | | Dataset summaries already read, with measure, date and dimension candidates |
| `semanticTargets` | array of object | No | | Domains available for schema discovery |
| `physicalTargets` | array of object | No | | Grounded physical tables (needs SQL access) |
| `remainingToolCalls` | integer | No | | Planning budget, up to 20 |
| `responseFormat` | "markdown", "json" | No | | "json" (default) or "markdown" |

## SQL

### semaphor_query_sql_advanced

Runs SQL against a connection, under the same security policies as every other query, with optional Python post-processing. Use it when a question can't be expressed through [`semaphor_analyze`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_analyze). Needs SQL access.

| Argument | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `projectId` | string | No | | The project to work in. Interactive sessions pass it on project-scoped tools (a user with one project can omit it); token sessions use the token's project. |
| `connectionId` | string | Yes | | The connection from semaphor_list_connections |
| `analyzeFallbackReason` | "user_explicitly_requested_sql", "no_semantic_domain_available", "field_not_exposed_in_semantic_model", "unsupported_sql_construct", "unsupported_join_or_grain", "needs_raw_rows", "debugging_or_validation_query" | Yes | | Required reason this SQL escape hatch is necessary instead of governed semantic analysis. Do not use SQL merely because the analyze tool was not loaded; load it for governed BI questions. |
| `analyzeFallbackExplanation` | string | Yes | | Required concise explanation of the specific limitation or user request that makes governed semantic analysis insufficient for this SQL call. |
| `sql` | string | Yes | | The query. Include a row limit for exploration. |
| `pythonCode` | string | No | | Python that post-processes the result rows |

**Returns:** `records`, `rowLimitExceeded`, optional `output` and `warnings`, the echoed fallback reason, and `executionResult`.

```json title="Example"
{
  "connectionId": "conn_123",
  "analyzeFallbackReason": "unsupported_sql_construct",
  "analyzeFallbackExplanation": "Needs a running total with a window function over monthly revenue.",
  "sql": "SELECT month, revenue, SUM(revenue) OVER (ORDER BY month) AS running_total FROM monthly_revenue ORDER BY month LIMIT 100"
}
```

## Dashboards

### semaphor_list_dashboards

Lists the dashboards the caller can open in a project, most recently updated first.

| Argument | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `projectId` | string | No | | Project ID. Optional for project-token sessions because the token determines the active project. Interactive org-user sessions should pass projectId explicitly. |
| `search` | string | No | | Search dashboards by title or description |
| `tags` | array of string | No | | Exact dashboard tags to match. Tags must already be canonical lowercase values. |
| `tagMatch` | "all", "any" | No | `"all"` | Tag matching mode. "all" requires every tag; "any" requires at least one. Requires tags. |
| `origin` | "org_created", "tenant_created", "unknown" | No | | Who created the dashboard: "org_created" (an organization user), "tenant_created" (a tenant user) or "unknown" (no creator recorded: created before creators were recorded, or the creator was deleted). |
| `limit` | integer (1 to 500) | No | `100` | Maximum number of dashboards to return |
| `offset` | integer | No | | Number of results to skip, for page-numbered views. Can't be combined with cursor or updatedSince. Offset pages can skip or repeat dashboards that change while you read. |
| `cursor` | string | No | | nextCursor from the previous page of the same listing (same filters and updatedSince). With updatedSince (oldest change first) a scan sees every dashboard even while they change; newest first, a dashboard edited between pages can be missed. |
| `updatedSince` | string | No | | ISO timestamp. Returns dashboards updated at or after it, oldest change first; follow nextCursor to the end. Use 1970-01-01T00:00:00Z to scan everything. A dashboard edited during the scan appears again later, so de-duplicate by id. |
| `idsOnly` | boolean | No | | When true, returns only { ids, total, nextCursor }. A finished scan from updatedSince 1970-01-01T00:00:00Z with no search, tags or origin lists every accessible dashboard, so an id missing from it was deleted or is no longer accessible. Any other scan is partial by design. |
| `responseFormat` | "markdown", "json" | No | `"json"` | Output format: "markdown" for human-readable or "json" for machine-readable |

To read every dashboard while people may be editing them, scan oldest change first: pass `updatedSince` (`1970-01-01T00:00:00Z` for everything) and follow `nextCursor` to the end. A dashboard edited during the scan moves to the end and is read again, so de-duplicate by ID. The default newest-first order, and `offset`, can miss a dashboard that's edited between pages.

To find deleted dashboards, run a full scan with `idsOnly: true`: `updatedSince: "1970-01-01T00:00:00Z"` and no `search`, `tags` or `origin`. An ID that's missing from that finished scan was deleted, or you no longer have access to it. An incremental scan (a later `updatedSince`) or a filtered one leaves out dashboards on purpose, so don't treat its missing IDs as deletions.

**Returns:** `dashboards` (a page of rows), `total` and `nextCursor` (`null` on the last page). Each row has `id`, `title`, `description`, `projectId`, `tags`, `origin`, its creator and update times, and `access`: who can open the dashboard, as the dashboards console shows it, with a one-line summary in `access.line`. For tenant callers, and for dashboards that belong to a tenant, `access` lists only the rules that reach that tenant. With `idsOnly`, the response is `ids`, `total` and `nextCursor`.

### semaphor_get_dashboard_details

Returns one dashboard's details, or several at once.

| Argument | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `dashboardId` | string | No | | The unique ID of the dashboard to retrieve. Use this or dashboardIds. |
| `dashboardIds` | array of string | No | | Up to 20 dashboard IDs to retrieve at once. Use this or dashboardId. |
| `fields` | array of "summary", "cards", "bindings", "sql", "template", "outline" | No | | Which parts to return. "summary" (the default) is the dashboard record with its access; "cards", "bindings" (every dashboard filter and control as an input, and card control bindings), "sql" (saved card SQL) and "template" add those parts. "outline" (one dashboardId only) describes the dashboard for semaphor_propose_change. |
| `sheetIds` | array of string | No | | Limit cards, bindings, SQL, template and the outline to these sheets. |
| `cardIds` | array of string | No | | Limit cards, bindings, SQL, template and the outline to these cards. |
| `responseFormat` | "markdown", "json" | No | `"json"` | Output format: "markdown" for human-readable or "json" for machine-readable |

**Returns:** for one dashboard, its record (title, description, project, tenant, tags, `access`, dates) plus the parts you asked for. For several, `dashboards` and `notFound` (IDs that don't exist or that you can't open).

With a [live token](/docs/mcp/live-changes), every part but `summary` describes the dashboard on the user's screen, unsaved edits included, and names one `dashboardId`.

`outline` takes one `dashboardId`. It describes the dashboard for [making changes](/docs/mcp/changes#the-outline): every sheet, card and filter by ID and name, each sheet's layout, each card's query in the same fields [`semaphor_analyze`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_analyze) uses, each filter's reach and its default as the dashboard loads it, which cards and filters can be changed (and why not), the dashboard's `updatedAt`, and `access`: `canEdit`, or `canCopy` when you can save a private copy instead. With `fields: ["outline"]` alone, the response is `{ id, title, outline }`.

```json title="Two dashboards with their cards"
{
  "dashboardIds": ["d_q1sales", "d_pipeline"],
  "fields": ["summary", "cards"]
}
```

### semaphor_get_dashboard_analysis_context

Returns compact grounding for a dashboard the agent already knows: its cards, filters, authored measures, dimensions and date fields, source references, and optional query inputs for [`semaphor_analyze`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_analyze). Use it before broad discovery when a question starts from a dashboard. It reads the dashboard and never runs its queries. The project comes from the dashboard, so there's no `projectId`.

| Argument | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `dashboardId` | string | Yes | | The unique ID of the dashboard to inspect |
| `includeQueryInputs` | boolean | No | `true` | Include bounded cardConfig/cardDataSource query inputs when available so agents can use the dashboard's authored query setup. |
| `maxCards` | integer (1 to 50) | No | `30` | Maximum dashboard cards to return in the compact context. |
| `sheetIds` | array of string | No | | Only cards on these sheets. |
| `cardIds` | array of string | No | | Only these cards. |
| `responseFormat` | "markdown", "json" | No | `"json"` | Output format: "json" for machine-readable or "markdown" for human-readable |

**Returns:** `dashboard` (metadata and link), `summary` (counts, and `missingSheetIds` and `missingCardIds` for IDs that don't exist), `filters`, `cards` (per-card analyses with their saved `filterConditions` and `AND` and `OR` groups), `referencedSemanticDomains`, `referencedDatasets`, `referencedFieldCandidates`, `referencedConnections`, `referencedPhysicalSources`, `physicalSourceLimitations`, `analysisHints` and `suggestedNextSteps`. A relative window such as "This month" comes back as its relative intent, and a latest-value condition as `latestValue: true`, because the server resolves both on every run.

## Changes

[`semaphor_propose_change`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_propose_change) previews a change and saves nothing. [`semaphor_apply_change`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_apply_change) applies a stored proposal by its `proposalId`, exactly as previewed. See [Making Changes](/docs/mcp/changes) for every change, its fields, the responses, retries and errors.

### semaphor_propose_change

| Argument | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `projectId` | string | No | | For dashboard.generate and relationship.add in interactive sessions; dashboard changes use the dashboard's project |
| `dashboardId` | string | No | | The dashboard to change (dashboard changes) |
| `domainId` | string | No | | The semantic domain to change (relationship.add). Ignored with dashboard changes. |
| `changes` | array of object | Yes | | 1 to 20 changes, applied in order, all or nothing. See Making Changes for each change. |
| `saveAs` | "original", "copy" | No | | "original" (default) or "copy": save the changes as your own private copy |
| `preview` | "sample", "rows", "none" | No | | "sample" (default, up to 5 rows per changed card), "rows" (up to 50) or "none" |
| `include` | array of "outline" | No | | \["outline"] also returns the outline as it will be after the changes |
| `rationale` | string | No | | Why the change is needed, up to 500 characters; kept with the proposal |

**Returns:** `proposalId`, `expiresAt`, `risk`, `target`, `summary`, `changes`, `warnings`, `preview`, `proposedOutline` (with `include: ["outline"]`) and `next`. See [Proposals](/docs/mcp/changes#proposals). With a [live token](/docs/mcp/live-changes), the change is sent to the user's screen and the result also has `delivery`: its `deliveryId` and what became of it (`applied`, `not_applied` or `not_confirmed`).

### semaphor_apply_change

| Argument | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `proposalId` | string | Yes | | The proposalId from semaphor_propose_change. |

**Returns:** `applied`, `replayed`, `target`, `created`, `summary` and `warnings`. Safe to retry with the same `proposalId`: a retry returns the recorded result with `replayed: true` and never applies the change twice. See [Save, Copy and Apply](/docs/mcp/changes#save-copy-and-apply).

# Troubleshooting

Fix common Semaphor MCP connection, authentication, tool and query errors.

Source: https://docs.semaphor.cloud/docs/mcp/troubleshooting

## Start Here: Check the Session

When something is missing or refused, ask the agent to call [`semaphor_get_access_context`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_get_access_context). It reports:

- `mode`: `org_interactive` (logged in), `org_headless` (automation token) or `tenant_embedded` (end-user token)
- `scopes`: `mcp:read`, and `mcp:write` if the session can apply changes
- `capabilities.sql`: whether SQL and physical tables are available
- `projectId`: the token's project, or none for a login session, which passes `projectId` on each tool

Most problems below come down to one of these values.

## The Semaphor Tools Don't Appear

- Restart the client after installing the plugin (`/reload-plugins` in Claude Code).
- Check the plugin is installed: `claude plugin list`, or `codex plugin list --marketplace semaphor-analytics`.
- Check the server: run `/mcp` in Claude Code and look for `semaphor`, or run `codex mcp list` in Codex.
- If the server needs a login, complete it (`/mcp` in Claude Code, `codex mcp login semaphor` in Codex), then ask again.

## A Tool I Expected Is Missing

The tool list depends on who is calling. See [Tools Reference](/docs/mcp/tools-reference#who-sees-which-tools).

| Missing | Why | Fix |
| --- | --- | --- |
| SQL and table tools | Your role is Viewer, or the token was minted with `"sql": false` | Ask for an Author role, or mint a token with SQL on |
| [`semaphor_propose_change`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_propose_change) | Your role can't author dashboards: below Author for an organization user, below Power User for an end user | Ask for a role that can author dashboards |
| [`semaphor_apply_change`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_apply_change) | The session doesn't have `mcp:write` (proposing still works) | Log in again with write access, or mint a token with `mcp:write` |
| [`semaphor_list_projects`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_list_projects) | You're using a token, which is pinned to one project | Expected. The token's project is used automatically. |

## Authentication Errors

| Status and message | What it means | What to do |
| --- | --- | --- |
| `401` "No authorization provided" | No credential was sent, or the login expired | Log in again (`/mcp`, or `codex mcp login semaphor`), or check the `Authorization` header |
| `401` "Invalid token" or "Token has expired" | The OAuth token failed verification or expired | Log in again |
| `401` "MCP project token is invalid or expired. Mint a new one with POST /api/v1/token and the mcp option." | The MCP project token expired, has a bad signature, or was minted for a different server | Mint a new token with the `mcp` option and update it in your client |
| `401` "This token was not minted for the MCP. Mint an MCP project token with POST /api/v1/token and the mcp option." | The token was minted without the `mcp` option, for example a dashboard embed token or an API token | Mint an MCP project token. See [Other tokens](/docs/mcp/connect-with-token#other-tokens). |
| `403` "Insufficient scope" | The credential doesn't include `mcp:read` | Log in again, or mint a new token |
| `400` `invalid_grant` when refreshing a login | The refresh failed | Log in again |

Check the header format: `Authorization: Bearer <token>`, with one space after `Bearer`.

## Tool Errors

Tool errors arrive as results with `isError: true`. Structured errors include a `code`:

| Code | What it means | What to do |
| --- | --- | --- |
| `capability_required` | An argument needs SQL access the session doesn't have. The payload names the `argument`. | Use the semantic form of the tool (domain, dataset and fields) |
| `missing_relationship`, `ambiguous_relationship` | Data is disconnected or has multiple possible relationship paths | Choose related data or specify the intended relationship |
| `unsafe_join` | A join could count measure values more than once | Follow the returned guidance to group by shared fields or query the measures separately |
| `unsupported_capability` | The requested execution strategy is not supported by this view | Change the query configuration |
| `execution_failed`, `timeout` | The query failed or timed out while running | Retry or narrow the request; include the error code if the problem persists |
| `tool_timeout` | The tool ran past the server's time limit | Ask a narrower question: fewer rows, a shorter window, fewer fields |
| `write_scope_required` | Apply was called by a session without `mcp:write`; the error lists `requiredScope` and `grantedScopes` | Log in again with write access, or use a token with `mcp:write` |
| `change_not_allowed_for_credential` | A token session asked for a semantic model change (its tool doesn't list `relationship.add`, so this usually shows as an input validation error) | Use an interactive login for semantic model changes |
| `invalid_change_target` | The target doesn't fit the changes, or one proposal mixes kinds of change | Dashboard changes take `dashboardId`; `relationship.add` takes `domainId`; `dashboard.generate` takes neither |
| `invalid_change` | The proposed change failed validation; nothing was stored | Fix the request and propose again |
| `proposal_not_found` | Unknown or expired proposal, or someone else's | Propose again |
| `proposal_stale` | The target changed after the preview | Propose again |
| `apply_in_progress` | The first apply of this proposal is still running | Retry the same call shortly |
| `apply_outcome_unknown` | The first apply never recorded a result | Follow `howToCheck` to see whether the change landed before proposing again |

See [Making Changes](/docs/mcp/changes#errors) for the change errors in context.

## Project Errors

| Message | What it means | What to do |
| --- | --- | --- |
| `Project token is scoped to project "..."` | A token session can't switch projects | Use the token's project, or mint a token for the other project |
| `projectId is required: you can access N projects` | You belong to several projects and didn't name one | Tell the agent which project you mean. The error lists your projects. |
| `Project "..." not found or you do not have access to it` | The project ID is wrong, or you aren't a member | Check the ID, and your access in the Semaphor console |

## Mint Errors

`POST /api/v1/token` with the `mcp` option returns `400` with `{ "error": "<code>", "message": "<text>" }`:

| `error` | Fix |
| --- | --- |
| `mcp_invalid_scope` | `mcp.scopes` must be `["mcp:read"]` or `["mcp:read", "mcp:write"]`, and `mcp.sql` must be `true` or `false` |
| `mcp_write_not_allowed` | Write access needs a user to act as: an organization user (`orgUserId`) with an Author role or higher, or an end user (`endUserId` with `tenantId`) |
| `mcp_token_lifetime_invalid` | `tokenExpiry` must be a whole number of seconds from 60 to 86400 |
| `mcp_identity_required` | Pass `orgUserId`, or `endUserId` with `tenantId`; a `tenantId` alone isn't supported for MCP tokens |

## Discovery Issues

### No Semantic Domains Found

[`semaphor_get_analysis_context`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_get_analysis_context) reports the reason in `recommendedPath`:

- **`physical`:** The project has no semantic domains. Callers with SQL access can use physical discovery. To use [`semaphor_analyze`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_analyze), ask your admin to set up a [semantic domain](/docs/concepts/semantic-domains/overview).
- **`restricted`:** Domains exist, but the token's `semanticDomainAccess` doesn't include any of them. Mint a token that includes the domains the agent needs.
- **`unavailable`:** There's nothing this caller can query in the project. Check connections, CLS policies and, for tokens, whether SQL is turned off.

### Empty Dataset List

The semantic domain may not have datasets yet. Check the domain setup in the Semaphor console.

## Query Issues

### A Query Returns No Rows

- Check that the dataset or table has data for the window you asked about.
- Row-level security may be filtering every row for this caller. Two people can see different results for the same question.
- With [`semaphor_query_sql_advanced`](https://docs.semaphor.cloud/docs/mcp/tools-reference#semaphor_query_sql_advanced), try a simple count first.

### Answers Look Wrong

- Ask the agent which dataset, fields and time window it used. Governed results report the window that ran.
- Compare with the dashboard: ask the agent to read the dashboard's analysis context and re-run a card's question.

### Connection Refused or Timeouts

- Check that the data connection is active in the Semaphor console.
- Check that the database is reachable from Semaphor.

## Client-Specific Issues

### Claude Code: Change Tools Ask Every Time

Claude Code asks before an MCP tool runs unless it's allowed. Choose "Yes, and don't ask again", or add the tool to `permissions.allow`. See [Making Changes](/docs/mcp/changes#permission-and-approval).

### Cursor: "Tool not found"

- Restart Cursor after changing `~/.cursor/mcp.json`.
- Check the file is valid JSON (no trailing commas).

### claude.ai: Connector Not Appearing

- Check that your Claude plan supports custom connectors.
- Remove and re-add the connector, and complete the sign-in.

### Your Own HTTP Client: `406 Not Acceptable`

Send `Accept: application/json, text/event-stream` on every request. See [Connect with a Token](/docs/mcp/connect-with-token#point-a-client-at-the-token).

## Live Changes Don't Reach the Dashboard

If your agent hears `screen_unavailable` while your user has the dashboard open, check that:

- the token was minted with `mcp: { live: true }`;
- the iframe URL has `?showControls=true`;
- your user can edit the dashboard, so they see Edit in the toolbar;
- the tab has been in use within the last two minutes;
- your users' network passes streaming responses through (see [Show the iframe with the toolbar](/docs/mcp/live-changes#show-the-iframe-with-the-toolbar)).

If it hears `multiple_screens`, the dashboard is open in more than one tab with the same token. [Live Changes](/docs/mcp/live-changes#when-the-screen-cant-be-read) lists every refusal and what to do.

## Getting Help

If you're still stuck:

- Check your project, connection and user status in the Semaphor console.
- Review your security policies (CLS, SLS, RLS) in the project settings.
- Contact support at <support@semaphor.cloud>.
