Skip to content

feat: OAuth 2.1 login, org/key management, and a local MCP server - #19

Merged
JustSteveKing merged 5 commits into
mainfrom
feat/oauth-mcp-updates
Jul 28, 2026
Merged

feat: OAuth 2.1 login, org/key management, and a local MCP server#19
JustSteveKing merged 5 commits into
mainfrom
feat/oauth-mcp-updates

Conversation

@JustSteveKing

Copy link
Copy Markdown
Collaborator

Adds the auth MVP and a local MCP server, both against the auth host (console.tabstack.ai).

Auth

  • OAuth 2.1 authorization code + PKCE (S256) login over a 127.0.0.1 loopback redirect (never localhost), with state + RFC 9207 iss checks and a 2-minute timeout. openBrowser/hasDisplay are package vars so the whole flow is testable against httptest.
  • Session manager: single-flight refresh with rotation, absolute expiry read from expires_in, renews ~60s early. A 401 session_expired refreshes once and retries once; a 401 invalid_session is terminal without a refresh (they used to be conflated).
  • Two credentials, split by host: user session (auth host only) and org-scoped API key (product host only) — the console package never sees a key, client never sees the session.
  • Orgs & keys: auth switch, selector resolution (id / case-insensitive name / unique prefix), and API-key setup during login (create, or adopt an existing key — offered only when the org has one; --api-key-setup=existing errors when it has none).
  • Config v2: TOML, mode 0600, atomic writes, keyed by org id; lossless migration of a legacy top-level api_key. config show / path / drop-legacy-key.

MCP server

  • tabstack mcp runs a local Model Context Protocol server over stdio (official modelcontextprotocol/go-sdk), exposing extract_markdown / extract_json / generate_json, automate / research, schema_list / schema_resolve (read-only), and whoami / list_orgs / active_org.
  • Product tools use the resolved API key; if none is stored but a session exists, one is minted for the active org on startup and persisted. Management tools use the session. The host/credential split is preserved.
  • stdout carries JSON-RPC only; all diagnostics go to stderr. Streaming tools forward SSE events as MCP progress notifications and aggregate the final answer plus in-band failure. Clean shutdown on stdin close or signal.

Docs & tooling

  • README: install via install.sh, plus an MCP section with Claude Desktop setup.
  • CLAUDE.md architecture notes for the console client, OAuth flow, and MCP server.
  • scripts/smoke-mcp.sh (make smoke-mcp) drives the stdio handshake end to end (bash 3.2-safe, offline by default, live tool call when a key is present).

Verification

make lint and make test pass. New tests cover the OAuth flow, session/401 semantics, org/key setup, and the MCP tools (in-memory transport + mocked hosts); the smoke script was run offline (13/13) and live (15/15).

🤖 Generated with Claude Code

Add the auth MVP and a local MCP server, both built on the same auth host.

Auth (internal/console, cmd/{login,auth,switch,orgs,keysetup,keys,config}):
- OAuth 2.1 authorization code + PKCE (S256) login over a 127.0.0.1 loopback
  redirect, state + RFC 9207 iss checks, 2-minute timeout.
- Session manager: single-flight refresh with rotation, absolute expiry from
  expires_in, renew ~60s early. A 401 session_expired refreshes once and retries
  once; a 401 invalid_session is terminal without a refresh.
- Two credentials kept strictly apart by host: user session (auth host only) and
  org-scoped API key (product host only).
- Org switching, selector resolution (id / name / unique prefix), and API-key
  setup during login (create, or adopt an existing key, gated on key existence).
- Config v2 (TOML, 0600, atomic writes) with lossless migration of a legacy
  top-level api_key; config show/path/drop-legacy-key.

MCP server (cmd/mcp.go, internal/mcp):
- `tabstack mcp` runs a local Model Context Protocol server over stdio using the
  official go-sdk, exposing extract/generate/automate/research, the local schema
  store (read-only), and read-only account context as tools.
- Product tools use the resolved API key; if none is stored but a session
  exists, one is minted for the active org on startup. Management tools use the
  session. stdout carries JSON-RPC only; diagnostics go to stderr.
- Streaming tools forward SSE events as MCP progress notifications and aggregate
  the final answer plus in-band failure.

Docs and tooling: README (install via install.sh, MCP + Claude Desktop setup),
CLAUDE.md architecture notes, and scripts/smoke-mcp.sh (make smoke-mcp) driving
the stdio handshake. make lint and make test pass.
@JustSteveKing
JustSteveKing requested a review from a team July 27, 2026 20:23
@JustSteveKing JustSteveKing self-assigned this Jul 27, 2026
@JustSteveKing
JustSteveKing requested a review from srbiv July 27, 2026 20:24
Adoption of an existing key was only reachable during login/switch when no key
was stored. `tabstack keys use [key-id]` exposes it as its own command and,
unlike the login path, replaces an already-stored key. With no id it adopts the
sole candidate or prompts; with an id it adopts directly (non-interactive).

Refactor the shared logic in cmd/keysetup.go: adoptExistingKey becomes
adoptKey(orgID, keyID) with a chooseKey helper (exact id-match, or
sole-candidate/prompt selection).

@srbiv srbiv left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three small notes inline, none blocking.

Comment thread internal/console/oauth.go
form.Set("code", code)
form.Set("redirect_uri", redirectURI)
form.Set("client_id", ClientID)
form.Set("code_verifier", verifier)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small one: the token endpoint accepts an optional label here, and it's what the sessions list uses to name each device. Without it the server falls back to the User-Agent, so every session shows up as Go-http-client/1.1 and you can't tell a laptop from a CI box when deciding which one to revoke. Adding form.Set("label", hostname) gets you mac.lan instead.

// SessionManager hands out a valid access token, refreshing when it has
// expired, and persists the rotated refresh token.
//
// Refresh is single-flight: concurrent callers that arrive during a refresh wait

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is exactly right within a process, and worth calling out because it's easy to miss. The one gap is across processes: with the MCP server running in Claude Desktop and someone also using the CLI in a terminal, two processes share the stored session, so when the hour-long access token expires both may refresh and one loses. It surfaces as "session expired, run tabstack auth login" when nothing is actually wrong. If it's useful, one option is to re-read the config on invalid_grant and retry once when the stored refresh token has changed, since that means a sibling already rotated it successfully.

Comment thread internal/console/client.go Outdated

var payload struct {
Error string `json:"error"`
Message string `json:"message"`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The console sends its detail in error_description rather than message, so this drops the useful text. The case people will actually hit is a duplicate key name: keys create --name laptop twice returns 422 with "Name has already been taken for this organization", but it currently renders as console error (422): invalid_request. Reading error_description as a fallback covers it.

@srbiv srbiv left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One more, same category as the others — small and non-blocking.

Comment thread cmd/login.go Outdated
ReadHeaderTimeout: 10 * time.Second,
}
go func() { _ = srv.Serve(ln) }()
defer func() { _ = srv.Close() }()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth swapping Close() for Shutdown() here, with an explicit Flush() in the handler after writing the page. The handler writes the body and signals the channel, but the response only flushes when the handler returns, so Close() can tear the connection down mid-write and the browser gets a reset instead of the page. On the happy path the token exchange usually buys enough time; it's the failure path that bites, since a fast exchange error returns in milliseconds and that's exactly when someone most wants to see the "Sign-in failed" page. The terminal still prints the real error either way, so it's cosmetic, just a confusing kind of cosmetic.

- Send a `label` (hostname) on the token exchange so sessions are legible in
  `auth sessions` instead of showing the Go User-Agent.
- decodeError falls back to `error_description` for the message, so console
  detail (e.g. "Name has already been taken for this organization") is kept
  instead of collapsing to the bare error code.
- Loopback callback page is Flushed and the server is Shutdown (not Close), so
  the page reaches the browser on the fast failure path rather than a reset.
- Session refresh recovers from cross-process rotation: on invalid_grant, if the
  stored refresh token changed, a sibling (e.g. the MCP server vs the CLI)
  already rotated, so adopt its session or retry once with its token instead of
  forcing a re-login.

Tests added for each; make lint and make test pass.
The loopback callback previously left the browser parked on the local page.
On success it now redirects to the console (the configured auth host) via a
meta refresh with a manual-link fallback, so the redirect works without
JavaScript and cannot be told to close a tab it did not open. The target is
only ever the auth host, never a value from the callback query, so no code or
state leaks onward. The failure page still stays put so the error is readable.
Point the post-login redirect at <auth-host>/oauth/connected rather than the
auth host root, so the browser lands on the dedicated connected page. Target is
still built only from the configured auth host, never the callback query.
@JustSteveKing
JustSteveKing merged commit 48c3117 into main Jul 28, 2026
1 check passed
@JustSteveKing
JustSteveKing deleted the feat/oauth-mcp-updates branch July 28, 2026 18:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants