Shared rules
Paths, slugs, metadata semantics, connection resolution, scope, rate limits and retention. The rules every Dashies MCP tool shares.
These rules are enforced in one place in the server and therefore apply identically across tools. The per-tool pages name the rules that bite them and link back here rather than restating them.
Paths and slugs
You pass <slug>/<filename>. The handle is added server-side from your
authorization; never include it. A bare <slug> means <slug>/index.html.
| Rule | Value | Error text |
|---|---|---|
| Slug charset | /^[a-z0-9-]+$/ | slug "<x>" must match [a-z0-9-] |
| Slug length | 64 characters | slug exceeds 64-char limit (got <n>) |
| Slug not empty | slug must not be empty | |
| Filename charset | /^[a-z0-9._-]+$/ | filename "<x>" must match [a-z0-9._-] |
| Path shape | exactly 1 or 2 segments | path "<p>": must be <slug>/<filename> (exactly two segments) |
| Path not empty | path must not be empty | |
| Version id | a UUID | version_id must be a valid uuid |
| Upload cap | 5242880 bytes (5 MiB) | 413 payload too large: ... |
| Spec cap | 5242880 bytes | spec is <n> bytes, over the 5242880-byte limit ... |
Slugs are unique per account, and aliases left behind by earlier renames share that same namespace. A slug that would be shadowed by the app itself is reserved and refused by name.
Dashboard URLs
The canonical form is a subdomain, not a path:
https://<your-handle>.dashies.xyz/<slug>
https://<workspace-slug>.dashies.xyz/<slug>
Every success message the server emits uses that form. Renaming leaves the old slug as an alias that 301-redirects to the new canonical URL.
Dashboard metadata
name, tags, chart and visibility follow one rule set, shared by
publish_dashboard, update_dashboard and the spec publish. The distinction
that matters is omitted versus empty:
| You pass | Effect |
|---|---|
The field omitted, or null | Preserved. On republish the prior value survives. |
name: "" or whitespace | Rejected: name must be a non-empty string |
chart: "" | Rejected: chart must be a non-empty string |
tags: [] | Clears all tags. |
tags not an array | Rejected: tags must be an array of strings |
| A non-string tag entry | Rejected: tags[<i>] must be a string (got <typeof>), never silently filtered |
visibility other than public or private | Rejected: visibility must be "public" or "private" |
There is no way to clear a name: omitting it preserves, and an empty string is
refused. That asymmetry is deliberate, because a nameless dashboard has nothing
to show in a list.
New dashboards default to public personally and private (members-only) in a
workspace. Visibility is not plan-gated in either direction.
Connections
A connection names where a refreshable dashboard's SQL runs. Two kinds:
self, the built-in no-PII Dashies metrics view. Always available, needs no connection id, and is the default wheneverconnectionis omitted.- A warehouse connection you may use, named by its UUID. Warehouse connections are created in the Dashies web app, never through MCP, and require a paid plan.
Resolution runs in two steps, each with its own refusals.
Shape, before anything is looked up:
| Condition | Error text |
|---|---|
| Not a string | connection must be a string |
Neither self nor a UUID | connection "<x>" is not a valid connection; pass "self" (Dashies' own no-PII metrics) or the id of a warehouse connection you own. Connect and manage warehouses in the Dashies web app (Connections); list_connections returns their ids. |
Usability, once it is looked up:
| Condition | Error text |
|---|---|
| Lookup failed in transport | Could not verify the connection: <err> |
| Unknown, foreign, wrong-scope or not a warehouse | Connection "<id>" was not found among your connections. Connect and manage warehouses in the Dashies web app (Connections), then pass an active connection's id (from list_connections) here - or use "self" (Dashies' own no-PII metrics) with no connection id. |
| Found but not active | Connection "<label or id>" is not active (status: <status>). Test it in the Dashies web app (Connections) and try again once it is active. |
One refusal covers four causes, on purpose
Unknown, someone else's, wrong-scope and not-a-warehouse all produce the same sentence. That is not vagueness: a distinguishable "exists but is not yours" would let anyone probe for the existence of a connection id.
A connection you own is not enough for a workspace dashboard
A connection's scope is the immutable (owner, workspace_id) pair, fixed when it
was created, with no move path in either direction. A workspace dashboard bound
to a personal connection publishes fine and can then never refresh, and
dry_run does not catch it. If you are publishing into a workspace, add the
warehouse from inside that workspace first.
Scope: personal and workspace
Your authorization is locked to one target when you grant it, and that lock, not an argument, decides the default. The grant is in one of three states:
| Grant state | Behaviour |
|---|---|
| Workspace-locked | That workspace is used. A workspace argument must match its slug or the call is refused. |
| Personal-locked | Personal. Any non-empty workspace argument is refused. |
| Legacy (no lock) | A workspace argument is honoured; otherwise personal. |
The refusals, verbatim:
This connection is authorized for your personal account only and cannot act on a
workspace. Re-authorize the connection and choose the workspace to use it there.
This connection is authorized for the "<locked-slug>" workspace and cannot act on
"<arg>". Re-authorize the connection and choose that workspace to use it there.
On the legacy path a workspace argument can additionally fail with
workspace must be a non-empty slug string, workspace "<slug>" not found, or
not a member of workspace "<slug>".
Seventeen of the eighteen tools accept a workspace target.
trigger_refresh is the single exception
and refuses one with:
Triggering a refresh on demand isn't available for workspace dashboards yet - it
works on personal dashboards only.
Two tools take no workspace argument because they are connection-scoped rather
than dashboard-scoped (introspect_schema
and validate_cube_sql), and
list_connections derives its scope
purely from the grant.
Some refusals say 'in your account' even on a workspace target
The four slug-keyed readers report a missing workspace dashboard as
No dashboard named "<slug>" in your account. The lookup is correct and
membership-gated; only the wording is personal. set_refresh_schedule is the one
that words it properly, with No dashboard named "<slug>" in that workspace.
Rate limits
Four independent counters, each a fixed ceiling over a 60-second window:
| Surface | Ceiling per minute | Keyed on |
|---|---|---|
publish_dashboard, both modes and both scopes | 60 | your user id |
validate_cube_sql | 120 | your user id |
trigger_refresh | 20 | your user id |
Client registration (POST /register) | 30 | client IP |
A throttled tool returns:
Rate limit exceeded for <tool name>: too many requests. Wait about 60 seconds and try again.
A throttled registration is not a tool error but an HTTP 429 carrying
retry-after: 60 and the body
{"error":"rate_limited","error_description":"too many client registrations, please retry shortly"}.
trigger_refresh is limited twice: this 20-per-minute budget, plus an
authoritative per-owner budget on the refresh service itself, which produces a
different sentence (Too many refresh triggers right now. Wait about a minute and try again.).
Rate limits fail open
If the limiter itself is unavailable, requests are allowed rather than refused. That is the deliberate inverse of the authorization gates, which fail closed: a throttle exists to control cost, so its outage must not look like a permission error.
Version retention
| Rule | Value |
|---|---|
| Automatic snapshots kept per dashboard | 20 (oldest unnamed ones pruned) |
| Named versions kept per dashboard | 30, exempt from the autosave prune |
| Versions returned by one list call | 50, with no pagination |
Naming a version with
update_dashboard_version is
what protects it from the prune. The read limit of 50 is exactly the two caps
added together, which is why the list needs no cursor.
Authorization
OAuth 2.1 with PKCE and Dynamic Client Registration. There are no tokens to
copy, paste or rotate. Your client discovers the metadata off a 401,
registers itself, opens your browser, and you sign in with Google once.
The steps behind that, and where each can fail:
- A tool call returns
401with aWWW-Authenticateheader. - The client fetches
/.well-known/oauth-authorization-serverand/.well-known/oauth-protected-resource. - The client registers at
POST /register. Unauthenticated, so IP-rate-limited at 30 per minute. - The client opens
GET /authorize. The request is stored for 30 minutes and your browser is sent to the Dashies consent screen. - The consent screen shows the real client name, the exact redirect URI in use, and the requested scope, so a non-loopback redirect URI is visible to you before you approve.
- You sign in, optionally pick a workspace, and click Allow access.
- The server verifies the token, mints a session dedicated to this grant, and only then consumes the request. A transient failure leaves it retryable, so clicking Allow access again is safe.
- The client exchanges the code at
POST /tokenand stores the access token in your OS keychain.
Clicking Cancel returns error=access_denied with
error_description=The user declined the authorization request.
Token lifetime. The MCP access token lives at most 50 minutes and is always clamped to expire before the underlying session it carries. On expiry the client refreshes silently; each grant owns its own rotating chain, so two AI clients and your browser tab cannot invalidate each other. That is why a working connection does not re-authorize every hour.
If a tool returns an auth error, the client re-triggers the browser flow. If it does not, retrying the call prompts it.
Check it worked
Ask your AI tool for your list of connections. Three outcomes, all informative:
a list means a live authorization and a paid plan; You have no warehouse connections. means a live authorization with none added yet; a browser window
means the handshake is running and you are about to be authorized.