Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
91bc0b3
feat(oauth): add Better Auth OAuth provider for MCP access
syednahm Jul 3, 2026
f939ff4
refactor(workspaces): implemented MCP token verification
syednahm Jul 3, 2026
c601e20
feat(mcp): integrate MCP request handling and update dependencies
syednahm Jul 3, 2026
1968670
feat(mcp): enhance MCP server setup with actor integration and tool reg
syednahm Jul 3, 2026
282e1ea
feat(ui): add MCP connections settings UI and OAuth consent screen
syednahm Jul 3, 2026
e73a751
refactor(mcp): streamline input schemas for reading workspace items
syednahm Jul 3, 2026
c36e7eb
feat(mcp): add OAuth protected-resource metadata and valid audiences
syednahm Jul 3, 2026
7666146
feat(mcp): verify tokens against /mcp audience and workspace:read
syednahm Jul 3, 2026
f061200
feat(mcp): emit WWW-Authenticate header on auth failures
syednahm Jul 3, 2026
b6ceff3
feat(mcp): harden tools with access denials and audit hook
syednahm Jul 3, 2026
249d578
docs(mcp): add developer setup and v1 limitations
syednahm Jul 3, 2026
5cad17a
Merge remote-tracking branch 'upstream/main' into remote-mcp-server
syednahm Jul 3, 2026
3f86add
feat(mcp): refactor MCP server URL handling in ConnectionsSettingsPage
syednahm Jul 3, 2026
166f681
feat(mcp): enhance access denial handling with dynamic error codes
syednahm Jul 4, 2026
50c6659
Merge remote-tracking branch 'upstream/main' into remote-mcp-server
syednahm Jul 4, 2026
127223e
test(mcp): update test to handle tokens w/o scope claims
syednahm Jul 4, 2026
408b9e6
fix(account): ensure unique OAuth scope labels by removing duplicates
syednahm Jul 4, 2026
7563c32
refactor(mcp): replace hardcoded scopes with MCP_SUPPORTED_SCOPES
syednahm Jul 4, 2026
c73b44d
fix(connections): revoke consents per-id to avoid state races
syednahm Jul 4, 2026
99879dc
feat(mcp): support case-insensitive bearer token scheme in authorization
syednahm Jul 4, 2026
0f2e5a5
refactor(oauth-api): simplify request path building for auth endpoints
syednahm Jul 4, 2026
f6bad79
fix(oauth-api): improve error handling in auth response parsing
syednahm Jul 4, 2026
86fedb6
chore(dependencies): update betterauth packages to version 1.6.23
syednahm Jul 4, 2026
ac8e0be
feat(mcp): implement safe truncation for UTF-16 pairs in capping
syednahm Jul 4, 2026
1435221
feat(oauth): add nav button for missing client id in consent page
syednahm Jul 4, 2026
c615b79
fix(connections): add padding to settings page layout
syednahm Jul 4, 2026
deed547
Merge branch 'main' into remote-mcp-server
syednahm Jul 4, 2026
8878f17
fix(oauth): revoke tokens on disconnect, cap access TTL to 20m
syednahm Jul 4, 2026
181baab
fix(oauth): show completion screen after consent decision
syednahm Jul 4, 2026
7078b1b
refactor(auth): remove unused import for account deletion email
syednahm Jul 4, 2026
9e67386
fix(oauth): improve error handling during redirect after consent
syednahm Jul 4, 2026
53f34ec
feat(mcp): implement MCP connection authorization and related tests
syednahm Jul 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
node_modules
.pnpm-store/
.DS_Store
dist
dist-ssr
Expand Down
54 changes: 54 additions & 0 deletions docs/MCP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# ThinkEx MCP server (v1)

Remote, read-only Model Context Protocol endpoint over ThinkEx workspaces.

## Endpoint

- **URL:** `https://<app-origin>/mcp` (locally `http://localhost:3000/mcp`)
- **Transport:** Streamable HTTP (stateless)
- **Auth:** OAuth 2.1 bearer token with `workspace:read` scope

Clients discover auth via `GET /.well-known/oauth-protected-resource/mcp` (RFC 9728); unauthenticated `/mcp` requests return `401` with a `WWW-Authenticate` header pointing there. Tokens must use the RFC 8707 `resource` parameter `https://<app-origin>/mcp`; only JWTs with that audience are accepted (opaque tokens are rejected).

## Adding to a client

Dynamic client registration and consent are enabled, so clients only need the URL.

- **Cursor** (`~/.cursor/mcp.json`) / **Windsurf** (`mcp_config.json`): `{ "mcpServers": { "thinkex": { "url": "https://<app-origin>/mcp" } } }` (Windsurf uses `serverUrl`).
- **VS Code** (`.vscode/mcp.json`): `{ "servers": { "thinkex": { "type": "http", "url": "https://<app-origin>/mcp" } } }`.
- **Claude Desktop:** add as a custom connector under Settings → Connectors.
- **stdio-only clients:** bridge with `npx -y mcp-remote https://<app-origin>/mcp`.

## Tools

Read-only, called in sequence:

1. `thinkex_list_workspaces` — workspaces for the user (id, name, description, role). Get a workspace `id`.
2. `thinkex_workspace_list_items` — items under a `path` (`recursive`, `limit`; paginated). Start at `/`.
3. `thinkex_workspace_read_items` — read specific `paths` with an optional `pages` range (PDF pages for files, 1000-line pages for documents).

Domain failures (path not found, folder read, unsupported type, out-of-range pages, workspace access denied) return as structured `failed` entries, not protocol errors.

## Local development

1. Copy `.dev.vars.example` to `.dev.vars` (or `pnpm dev` with Infisical).
2. `pnpm db:migrate:local` (first run only).
3. `CLOUDFLARE_VITE_FORCE_LOCAL=true pnpm serve:dev`
4. `npx @modelcontextprotocol/inspector`, point it at `http://localhost:3000/mcp` (Streamable HTTP). Sign in (guest or Google) and complete consent when prompted.

Required env vars (in `.dev.vars`, see `docs/ENVIRONMENT.md`):

- `BETTER_AUTH_SECRET` — random 32+ char string; signs sessions and tokens.
- `BETTER_AUTH_URL` — app origin (e.g. `http://localhost:3000`); the OAuth issuer, JWKS host, and `/mcp` token audience.

Google creds are optional; guest sign-in is enough to authorize a client.

## v1 limitations

- **Read-only** — three read tools only; no write tools.
- **No images** — text/Markdown projections only; unsupported types return `status: "unsupported"`.
- **Async PDFs** — `status: "pending"` means extraction isn't ready yet (retry later); `status: "failed"` means extraction failed.
- **No resources or subscriptions** — tools only; no `thinkex://` URIs or change notifications.
- **Read cap** — 100k chars per read response (`truncated: true` if exceeded).
- **No rate limiting** on tool calls yet; scope and membership are enforced on every call at the token and operation layers.
- **Audit logging** is structured (`recordMcpToolCall`) but not wired to telemetry yet; actor fields (`userId`, `clientId`, `scopes`) are recorded at each tool invocation for a future issue.
101 changes: 101 additions & 0 deletions drizzle/0002_mcp_oauth.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
CREATE TABLE `jwks` (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: The oauth_access_token and oauth_refresh_token tables include expires_at (and revoked on refresh tokens) but don't have indexes on these lifecycle columns. As token volume grows, cleanup and revocation queries will scan the entire table. Consider adding indexes on expires_at for both tables and on revoked for oauth_refresh_token to avoid future performance degradation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At drizzle/0002_mcp_oauth.sql, line 45:

<comment>The `oauth_access_token` and `oauth_refresh_token` tables include `expires_at` (and `revoked` on refresh tokens) but don't have indexes on these lifecycle columns. As token volume grows, cleanup and revocation queries will scan the entire table. Consider adding indexes on `expires_at` for both tables and on `revoked` for `oauth_refresh_token` to avoid future performance degradation.</comment>

<file context>
@@ -0,0 +1,101 @@
+--> statement-breakpoint
+CREATE UNIQUE INDEX `oauth_client_client_id_unique` ON `oauth_client` (`client_id`);--> statement-breakpoint
+CREATE INDEX `oauthClient_userId_idx` ON `oauth_client` (`user_id`);--> statement-breakpoint
+CREATE TABLE `oauth_refresh_token` (
+	`id` text PRIMARY KEY NOT NULL,
+	`token` text NOT NULL,
</file context>

`id` text PRIMARY KEY NOT NULL,
`public_key` text NOT NULL,
`private_key` text NOT NULL,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: This migration stores JWT signing private keys and bearer tokens in plain text columns. A database leak would expose the jwks.private_key, allowing attackers to forge JWTs and impersonate users, and would expose every active refresh/access token for session hijacking. Consider encrypting the signing key at rest (e.g., envelope encryption) and hashing refresh tokens before storage per RFC 9700.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At drizzle/0002_mcp_oauth.sql, line 4:

<comment>This migration stores JWT signing private keys and bearer tokens in plain `text` columns. A database leak would expose the `jwks.private_key`, allowing attackers to forge JWTs and impersonate users, and would expose every active refresh/access token for session hijacking. Consider encrypting the signing key at rest (e.g., envelope encryption) and hashing refresh tokens before storage per RFC 9700.</comment>

<file context>
@@ -0,0 +1,101 @@
+CREATE TABLE `jwks` (
+	`id` text PRIMARY KEY NOT NULL,
+	`public_key` text NOT NULL,
+	`private_key` text NOT NULL,
+	`created_at` integer NOT NULL,
+	`expires_at` integer
</file context>

`created_at` integer NOT NULL,
`expires_at` integer
);
--> statement-breakpoint
CREATE TABLE `oauth_client` (
`id` text PRIMARY KEY NOT NULL,
`client_id` text NOT NULL,
`client_secret` text,
`disabled` integer,
`skip_consent` integer,
`enable_end_session` integer,
`subject_type` text,
`scopes` text,
`user_id` text,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: The oauth_consent table currently allows multiple rows for the same (client_id, user_id) pair because it only has separate non-unique indexes on each column. Duplicate consent records can lead to inconsistent scope resolution (e.g., which row wins when querying consent). Adding a composite unique index on (client_id, user_id) would prevent this. If anonymous consent is not intended, also consider making user_id non-nullable.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At drizzle/0002_mcp_oauth.sql, line 18:

<comment>The `oauth_consent` table currently allows multiple rows for the same `(client_id, user_id)` pair because it only has separate non-unique indexes on each column. Duplicate consent records can lead to inconsistent scope resolution (e.g., which row wins when querying consent). Adding a composite unique index on `(client_id, user_id)` would prevent this. If anonymous consent is not intended, also consider making `user_id` non-nullable.</comment>

<file context>
@@ -0,0 +1,101 @@
+	`enable_end_session` integer,
+	`subject_type` text,
+	`scopes` text,
+	`user_id` text,
+	`created_at` integer,
+	`updated_at` integer,
</file context>

`created_at` integer,
`updated_at` integer,
`name` text,
`uri` text,
`icon` text,
`contacts` text,
`tos` text,
`policy` text,
`software_id` text,
`software_version` text,
`software_statement` text,
`redirect_uris` text NOT NULL,
`post_logout_redirect_uris` text,
`token_endpoint_auth_method` text,
`grant_types` text,
`response_types` text,
`public` integer,
`type` text,
`require_pkce` integer,
`reference_id` text,
`metadata` text,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `oauth_client_client_id_unique` ON `oauth_client` (`client_id`);--> statement-breakpoint
CREATE INDEX `oauthClient_userId_idx` ON `oauth_client` (`user_id`);--> statement-breakpoint
CREATE TABLE `oauth_refresh_token` (
`id` text PRIMARY KEY NOT NULL,
`token` text NOT NULL,
`client_id` text NOT NULL,
`session_id` text,
`user_id` text NOT NULL,
`reference_id` text,
`expires_at` integer NOT NULL,
`created_at` integer NOT NULL,
`revoked` integer,
`auth_time` integer,
`scopes` text NOT NULL,
FOREIGN KEY (`client_id`) REFERENCES `oauth_client`(`client_id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `oauth_refresh_token_token_unique` ON `oauth_refresh_token` (`token`);--> statement-breakpoint
CREATE INDEX `oauthRefreshToken_clientId_idx` ON `oauth_refresh_token` (`client_id`);--> statement-breakpoint
CREATE INDEX `oauthRefreshToken_sessionId_idx` ON `oauth_refresh_token` (`session_id`);--> statement-breakpoint
CREATE INDEX `oauthRefreshToken_userId_idx` ON `oauth_refresh_token` (`user_id`);--> statement-breakpoint
CREATE TABLE `oauth_access_token` (
`id` text PRIMARY KEY NOT NULL,
`token` text NOT NULL,
`client_id` text NOT NULL,
`session_id` text,
`user_id` text,
`reference_id` text,
`refresh_id` text,
`expires_at` integer NOT NULL,
`created_at` integer NOT NULL,
`scopes` text NOT NULL,
FOREIGN KEY (`client_id`) REFERENCES `oauth_client`(`client_id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`refresh_id`) REFERENCES `oauth_refresh_token`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `oauth_access_token_token_unique` ON `oauth_access_token` (`token`);--> statement-breakpoint
CREATE INDEX `oauthAccessToken_clientId_idx` ON `oauth_access_token` (`client_id`);--> statement-breakpoint
CREATE INDEX `oauthAccessToken_sessionId_idx` ON `oauth_access_token` (`session_id`);--> statement-breakpoint
CREATE INDEX `oauthAccessToken_userId_idx` ON `oauth_access_token` (`user_id`);--> statement-breakpoint
CREATE INDEX `oauthAccessToken_refreshId_idx` ON `oauth_access_token` (`refresh_id`);--> statement-breakpoint
CREATE TABLE `oauth_consent` (
`id` text PRIMARY KEY NOT NULL,
`client_id` text NOT NULL,
`user_id` text,
`reference_id` text,
`scopes` text NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`client_id`) REFERENCES `oauth_client`(`client_id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `oauthConsent_clientId_idx` ON `oauth_consent` (`client_id`);--> statement-breakpoint
CREATE INDEX `oauthConsent_userId_idx` ON `oauth_consent` (`user_id`);
Loading