OAuth
Connect MCP clients to vTilt with OAuth 2.1 + PKCE — no personal API key required. Covers dynamic client registration, discovery, scopes, and the consent flow.
vTilt's MCP server speaks OAuth 2.1 with PKCE, so modern MCP clients (Claude Desktop, ChatGPT Connector, Cursor, custom agents) can sign you in with a browser instead of asking for a vtu_ personal API key. The first time a client connects, vTilt walks you through the same sign-in flow you use for the dashboard and shows a one-screen consent prompt; after that the client refreshes its access token in the background.
#When to choose OAuth vs a personal API key
| You're using… | Use OAuth | Use a personal API key |
|---|---|---|
| Claude Desktop, ChatGPT Connector, Cursor (≥ MCP DCR support) | Yes — click "Connect" and sign in | Only if your client doesn't support OAuth yet |
A shell script, CI job, or curl | No — there's no browser to consent | Yes — paste Authorization: Bearer vtu_… |
| A custom agent you wrote | Yes if you can wire OAuth 2.1 + PKCE | Yes if shipping OAuth is overkill |
| Sharing with a teammate | Each teammate connects their own client | Mint a new key per teammate (revoke independently) |
OAuth tokens are short-lived JWTs (1 hour) refreshed automatically; vtu_ keys live until you revoke them. Both flows give the same access to the same projects you can see in the dashboard.
#1. Connect your client
Most clients that support OAuth detect vTilt automatically once you point them at the MCP URL:
https://www.vtilt.com/api/mcpThe client fetches /.well-known/oauth-protected-resource/api/mcp to discover that this resource is protected by vTilt's authorization server, then redirects you to https://www.vtilt.com/auth/signin in your browser. After you sign in, vTilt shows a consent screen listing the client's name, homepage, and the scopes it's requesting. Click Allow and the client receives an authorization code, exchanges it for an access token, and starts calling tools.
You won't see this flow again until the client is reset — refreshing tokens happens silently in the background.
#2. Discovery endpoints
These are public, unauthenticated metadata documents that let MCP clients self-configure:
| Endpoint | What it advertises |
|---|---|
https://www.vtilt.com/.well-known/oauth-authorization-server | OAuth 2.1 authorization server metadata (RFC 8414): authorize, token, registration URLs. |
https://www.vtilt.com/.well-known/openid-configuration | OpenID Connect Discovery document. Same data as above plus OIDC-specific fields. |
https://www.vtilt.com/.well-known/oauth-protected-resource/api/mcp | Protected Resource Metadata (RFC 9728): tells clients which authorization server protects /api/mcp. |
#3. Dynamic Client Registration
vTilt supports Dynamic Client Registration (RFC 7591), so MCP clients can self-register without you provisioning anything in the dashboard. The flow is:
curl -X POST 'https://www.vtilt.com/api/auth/oauth2/register' \
-H 'Content-Type: application/json' \
-d '{
"client_name": "My Custom Agent",
"redirect_uris": ["http://localhost:8080/callback"],
"scope": "mcp:read offline_access",
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"]
}'A successful response returns a client_id you store in your agent's config. Public clients (those that can't keep a secret — desktop apps, CLIs, browser SPAs) should set token_endpoint_auth_method: "none" and use PKCE; confidential clients (backend services with a secure secret store) can use client_secret_basic or client_secret_post.
#4. Scopes
| Scope | Purpose |
|---|---|
mcp:read | Read your dashboard data via MCP tools (projects, persons, events, recordings, KPIs, docs search, VQL). Included in default MCP client registration. |
mcp:write | Call write-scoped MCP tools (project-update, google-ads-* mutations, …). Opt-in, not a default — your client must request it (it is advertised in scopes_supported) and you grant it on the consent screen. Required to see write tools in tools/list. Does not bypass project permissions — each tool still checks RBAC (e.g. google_ads:write on your project role). |
offline_access | Issue a refresh token so the client can renew its access token without re-prompting you for consent. |
openid | Standard OIDC scope. Lets the client retrieve an ID token alongside the access token. |
email | Includes your email in the ID token so the client can show "signed in as you@example.com" in its UI. |
Most agent workflows want mcp:read offline_access — the agent reads data, refreshes silently, and never has to ask again. Add mcp:write when you need write tools (for example project-update or google-ads-* mutations). The consent screen lists each scope in plain language; every write call lands a row in the MCP audit log (see MCP server overview → Writing data).
#Write access (mcp:write) vs project permissions
OAuth scopes and project permissions are separate:
| Layer | What it controls | Example |
|---|---|---|
OAuth mcp:write | Whether write-scoped tools appear in tools/list and may be invoked at the credential level | Without it, google-ads-campaign-status-set is hidden |
| Project RBAC | Whether your user may perform the action on the pinned project | google_ads:write on the project member record |
Org owners and admins get full project permissions automatically. Members need the relevant actions on their project role even when the OAuth token includes mcp:write.
vTilt advertises mcp:write on the authorization server and on /.well-known/oauth-protected-resource/api/mcp. It is opt-in, not a default — following least-privilege, new client registrations default to mcp:read only. A spec-compliant client reads scopes_supported and requests mcp:write for you; if yours doesn't, add mcp:write to the scope= parameter it sends to /oauth2/authorize.
- Ensure your client requests
mcp:write(most modern clients do this automatically fromscopes_supported; otherwise add it to thescope=parameter). - Disconnect the existing MCP OAuth connection in your client (or revoke the client’s refresh token), then reconnect and sign in again. On the consent screen you should see an extra line: Change project data via MCP…
- Call
context-get—key_accessshould be"write"and write tools (including Google Ads writes) should appear intools/listwhen your project role allows them.
If you connected before requesting mcp:write, your old token only carries mcp:read; reconnecting with the wider scope is required — refreshing the access token does not add scopes.
#5. The consent screen
After signing in you'll see a screen like this:
My Custom Agent
https://my-agent.example.com
My Custom Agent is requesting permission to access your vTilt account.
This will allow My Custom Agent to:
• Read your dashboard data (projects, persons, events, recordings)
• Change project data via MCP (write tools — audited)
• Stay connected when you are not actively using the app
[ Deny ] [ Allow ]vTilt remembers your decision per (user, client_id) — you only see the screen once per client. To revisit or revoke your decisions, manage clients from your account settings (revoke flow lands alongside future Phase 2 increments; for now, revoking a refresh token via Better Auth's API immediately kills the session).
#6. Token lifecycle
| Token | Format | Lifetime | Where it lives |
|---|---|---|---|
| Authorization code | Random string | 60 s | Returned to the client's redirect_uri once. Single-use. |
| Access token | JWT | 1 hour | Sent as Authorization: Bearer … on every MCP request. |
| Refresh token | Random string | 30 days | Stored by the client; used to mint a new access token when the old one expires. |
| ID token | JWT | 1 hour | OIDC token returned alongside the access token if openid was requested. |
Access tokens are verified locally against vTilt's JWKS (no DB round-trip), so the per-request cost is the same as vtu_ keys.
#7. Project pinning
OAuth tokens give the same access as vtu_ keys — every project you can see in the dashboard. Pin a project the same three ways:
# Header (most clients let you set headers per connection)
x-vtilt-project-id: 7c5c1a3a-9d62-4e09-8a4f-9a3ce1b1f4a8
# Query parameter on the MCP URL
https://www.vtilt.com/api/mcp?project_id=7c5c1a3a-9d62-4e09-8a4f-9a3ce1b1f4a8
# Session pin via tool call
> "Switch me to the Marketing site project."Single-project accounts don't need to pin — the server picks your only project automatically.
#8. Common errors
| Error response | Meaning | Fix |
|---|---|---|
{"code":-32001,"message":"Unauthorized","data":{"reason":"oauth_signature_invalid"}} | JWT signature can't be verified against vTilt's JWKS | Reconnect the client — its access token was minted against a different host |
{"code":-32001,"message":"Unauthorized","data":{"reason":"oauth_jwks_not_found"}} | JWKS document returned HTTP 404 (wrong path or routing) | Ensure GET https://www.vtilt.com/api/auth/jwks returns 200; deploy must pin NEXT_PUBLIC_APP_URL to that same origin |
{"code":-32001,"message":"Unauthorized","data":{"reason":"oauth_audience_mismatch"}} | aud claim doesn't equal https://www.vtilt.com/api/mcp | Make sure the client requested a token for the MCP resource (see the warning below) |
{"code":-32001,"message":"Unauthorized","data":{"reason":"oauth_expired"}} | Access token's exp claim is in the past | Client should refresh the token — most do this automatically |
{"code":-32001,"message":"Unauthorized","data":{"reason":"oauth_unknown_user"}} | JWT sub doesn't resolve to a vTilt user (user was deleted) | Reconnect the client and sign in again |
{"code":-32003,"message":"Forbidden","data":{"reason":"project_not_accessible"}} | Header/query pin points at a project this user can't access | Remove the pin or pick a project from your dashboard |
HTTP 400 at /api/auth/oauth2/authorize with error=invalid_request | Missing PKCE code_challenge (or invalid redirect_uri) | Send code_challenge + code_challenge_method=S256; register redirect_uri |
#Next steps
- Authentication — overview of both auth paths and the
vtu_key flow. - Claude Desktop — connect via OAuth (recommended) or the
mcp-remotebridge. - MCP server overview — the full tool catalogue and example prompts.