Semaphor
MCP

Build an Agent

Everything an agent needs to work with Semaphor MCP, in one page you can hand to Claude or any other 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, 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 has every argument of every tool.

Connect

Endpointhttps://semaphor.cloud/api/mcp (self-hosted: your Semaphor URL followed by /api/mcp)
TransportMCP over Streamable HTTP
Interactive clientsOAuth: the client opens a Semaphor login. See Connect an AI Client.
Your own agent or backendA bearer token: Authorization: Bearer <MCP project token>. See Connect with a Token.

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

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.

.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:

Request
{
  "jsonrpc": "2.0",
  "id": 7,
  "method": "tools/call",
  "params": {
    "name": "semaphor_get_analysis_context",
    "arguments": {}
  }
}
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 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. 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 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, show the user its summary sentences, and call 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 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.

semaphor_get_analysis_context
{}
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 lists domains when there are several; semaphor_list_datasets lists a domain's datasets.

semaphor_list_datasets
{ "domainId": "dom_sales" }
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.

semaphor_get_dataset_schema
{ "domainId": "dom_sales", "datasetName": "orders", "responseDetail": "compact" }
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.

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

semaphor_list_dashboards
{ "search": "operations" }
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.

semaphor_analyze
{ "fromCard": { "dashboardId": "d_ops", "cardId": "c_shipments" } }
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. 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.

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.

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."
}
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.

semaphor_apply_change
{ "proposalId": "chg_xoK9M..." }
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 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 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, 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. For a simple breakdown, semaphor_analyze with two dimensions is enough.

When a Call Fails

You getIt meansDo this
An input validation error naming an argumentThe argument name or shape is wrongFix it from the tool's schema; don't add arguments the schema doesn't list
validation with missing_measure, invalid_dimension or another field codeA field name is wrongUse one of the candidates the error lists, or read semaphor_get_dataset_schema
executionResult.status: "partial"Part of the question was answeredRead coverage.missingObligations and tell the user what's missing
capability_requiredThe call needs SQL or physical access the session doesn't haveUse the semantic model, or ask an admin for SQL access
card_not_supportedfromCard can't run that cardBuild the query from the card's fields
copy_requiredYou can view the dashboard but not edit itPropose again with "saveAs": "copy"
write_scope_requiredThe session can propose but not applyTell the user to apply in the editor, or connect with mcp:write
proposal_staleThe dashboard changed after the previewRead the outline again and propose again
apply_outcome_unknownAn apply never recorded a resultFollow howToCheck before proposing again

When an agent is stuck, semaphor_plan_analytics_recovery plans the next calls from what it was trying to do and the schemas it has already read.

The Tools

ToolUse it to
semaphor_get_analysis_contextStart a data question: the recommended path and next tool
semaphor_list_projectsList projects (interactive sessions only)
semaphor_get_access_contextSee who the session acts as and what it can do
semaphor_list_semantic_domainsList semantic domains
semaphor_list_datasetsList a domain's datasets
semaphor_get_dataset_schemaRead a dataset's fields, or a physical table's columns
semaphor_get_domain_relationshipsRead how a domain's datasets join
semaphor_analyzeRun governed analytics, or a dashboard card with fromCard
semaphor_matrixRun a pivot window
semaphor_plan_analytics_recoveryGet suggested next calls when stuck
semaphor_list_connections, semaphor_list_databases, semaphor_list_schemas, semaphor_list_tablesBrowse physical sources (sessions with SQL)
semaphor_query_sql_advancedRun SQL when the semantic model can't express the question (sessions with SQL)
semaphor_list_dashboardsFind dashboards
semaphor_get_dashboard_detailsRead a dashboard: who can open it, its cards, and its outline for changes
semaphor_get_dashboard_analysis_contextRead a known dashboard compactly for analysis: its cards, filters, fields and query inputs
semaphor_propose_changePreview a change; saves nothing
semaphor_apply_changeSave a previewed change (sessions with mcp:write)

On this page