Connect with a Token
Connect automation, an agent in your product, or your own code to Semaphor with an MCP project 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>.
AutomationorgUserId
The token acts as an organization user. Use it for CI jobs, schedulers and scripts, with write scope if the job creates dashboards.
An end user in your productendUserId
The token acts as one of your end users, with the same security policies as their embedded dashboards. It's always read only.
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.
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
}'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
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.
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 expands ${SEMAPHOR_MCP_TOKEN} from the environment:
{
"mcpServers": {
"semaphor-token": {
"type": "http",
"url": "https://semaphor.cloud/api/mcp",
"headers": { "Authorization": "Bearer ${SEMAPHOR_MCP_TOKEN}" }
}
}
}Or from the command line:
claude mcp add --transport http semaphor-token https://semaphor.cloud/api/mcp --header "Authorization: Bearer ${SEMAPHOR_MCP_TOKEN}"Codex reads the token from the named variable:
[mcp_servers.semaphor-token]
url = "https://semaphor.cloud/api/mcp"
bearer_token_env_var = "SEMAPHOR_MCP_TOKEN"{
"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. You should see the token's project.
Without an MCP SDK, send JSON-RPC over HTTP. Include both content types in Accept; the server answers with JSON.
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}'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
}'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.
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 for questions the semantic model can't express. See 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
| 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 is only for organization users with an Author role or higher. |
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 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 end user, 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
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 an organization user (orgUserId) with an Author role or higher. End-user tokens are read only. |
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
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 |