-
Notifications
You must be signed in to change notification settings - Fork 11
feat(mcp): remote MCP server for workspaces (v1, read-only) #590
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
91bc0b3
f939ff4
c601e20
1968670
282e1ea
e73a751
c36e7eb
7666146
f061200
b6ceff3
249d578
5cad17a
3f86add
166f681
50c6659
127223e
408b9e6
7563c32
c73b44d
99879dc
0f2e5a5
f6bad79
86fedb6
ac8e0be
1435221
c615b79
deed547
8878f17
181baab
7078b1b
9e67386
53f34ec
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,5 @@ | ||
| node_modules | ||
| .pnpm-store/ | ||
| .DS_Store | ||
| dist | ||
| dist-ssr | ||
|
|
||
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| CREATE TABLE `jwks` ( | ||
| `id` text PRIMARY KEY NOT NULL, | ||
| `public_key` text NOT NULL, | ||
| `private_key` text NOT NULL, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Prompt for AI agents |
||
| `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, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The Prompt for AI agents |
||
| `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`); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: The
oauth_access_tokenandoauth_refresh_tokentables includeexpires_at(andrevokedon 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 onexpires_atfor both tables and onrevokedforoauth_refresh_tokento avoid future performance degradation.Prompt for AI agents