From 277339f6ae9f291c35ce35d99533c22591dde36d Mon Sep 17 00:00:00 2001 From: Zacharie Laik Date: Mon, 4 May 2026 20:39:08 +0200 Subject: [PATCH 1/3] feat(mcp): add user-configurable MCP servers (URL + custom headers) Lets users register Streamable-HTTP MCP servers from the Settings page. Tools discovered from each enabled server are merged into the per-request tool set under the `mcp____` prefix and dispatched back to the right server via runToolCalls. Headers (e.g. `Authorization: Bearer ...`) are stored on the row. Backend - New `user_mcp_servers` table (RLS owner-only) with migration 001 + the same DDL inlined in the one-shot schema. - `lib/mcp/{client,servers,types}.ts`: thin wrapper around @modelcontextprotocol/sdk's StreamableHTTPClientTransport, per-request loader, schema converter (MCP `inputSchema` -> Mike's OpenAIToolSchema) with 64-char tool-name truncation. - `runLLMStream` and `runToolCalls` accept an optional `mcpServers` list; chat routes load + close clients in a try/finally. - New `routes/mcpServers.ts` mounted at `/user/mcp-servers` with GET/POST/PATCH/DELETE plus `/test` for connect-and-list-tools probing. All handlers filter by user_id since the backend uses the service role key. Frontend - New `account/mcp` settings tab and page: add/edit/delete servers, toggle enabled, run test connection. Header values are masked in the form (type=password) and the GET endpoint returns header keys only. - `mikeApi.ts`: typed CRUD wrappers. Notes for review - Header values are stored via the same RLS-only model used today for `user_profiles.claude_api_key`/`gemini_api_key`. Per-row encryption is a clean follow-up. - OAuth-protected MCP servers are out of scope for this PR; a follow-up will add an OAuth 2.1 client (PKCE + dynamic client registration) so spec-conformant servers (e.g. https://legaldatahunter.com/mcp) work without manual token paste. --- backend/migrations/000_one_shot_schema.sql | 45 ++ backend/migrations/001_user_mcp_servers.sql | 49 ++ backend/package-lock.json | 657 ++++++++++++++++++ backend/package.json | 1 + backend/src/index.ts | 2 + backend/src/lib/chatTools.ts | 50 +- backend/src/lib/mcp/client.ts | 123 ++++ backend/src/lib/mcp/servers.ts | 133 ++++ backend/src/lib/mcp/types.ts | 34 + backend/src/routes/chat.ts | 7 + backend/src/routes/mcpServers.ts | 244 +++++++ backend/src/routes/projectChat.ts | 7 + frontend/src/app/(pages)/account/layout.tsx | 1 + frontend/src/app/(pages)/account/mcp/page.tsx | 495 +++++++++++++ frontend/src/app/lib/mikeApi.ts | 67 ++ 15 files changed, 1911 insertions(+), 4 deletions(-) create mode 100644 backend/migrations/001_user_mcp_servers.sql create mode 100644 backend/src/lib/mcp/client.ts create mode 100644 backend/src/lib/mcp/servers.ts create mode 100644 backend/src/lib/mcp/types.ts create mode 100644 backend/src/routes/mcpServers.ts create mode 100644 frontend/src/app/(pages)/account/mcp/page.tsx diff --git a/backend/migrations/000_one_shot_schema.sql b/backend/migrations/000_one_shot_schema.sql index 80d563af..9828d65b 100644 --- a/backend/migrations/000_one_shot_schema.sql +++ b/backend/migrations/000_one_shot_schema.sql @@ -272,6 +272,51 @@ begin end; $$; +-- --------------------------------------------------------------------------- +-- User MCP servers +-- --------------------------------------------------------------------------- + +create table if not exists public.user_mcp_servers ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users(id) on delete cascade, + slug text not null, + name text not null, + url text not null, + headers jsonb not null default '{}'::jsonb, + enabled boolean not null default true, + last_error text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint user_mcp_servers_slug_format + check (slug ~ '^[a-z0-9_-]{1,24}$'), + unique (user_id, slug) +); + +create index if not exists idx_user_mcp_servers_user + on public.user_mcp_servers(user_id, enabled); + +alter table public.user_mcp_servers enable row level security; + +drop policy if exists "Users can view their own MCP servers" on public.user_mcp_servers; +create policy "Users can view their own MCP servers" + on public.user_mcp_servers for select + using (auth.uid() = user_id); + +drop policy if exists "Users can insert their own MCP servers" on public.user_mcp_servers; +create policy "Users can insert their own MCP servers" + on public.user_mcp_servers for insert + with check (auth.uid() = user_id); + +drop policy if exists "Users can update their own MCP servers" on public.user_mcp_servers; +create policy "Users can update their own MCP servers" + on public.user_mcp_servers for update + using (auth.uid() = user_id); + +drop policy if exists "Users can delete their own MCP servers" on public.user_mcp_servers; +create policy "Users can delete their own MCP servers" + on public.user_mcp_servers for delete + using (auth.uid() = user_id); + -- --------------------------------------------------------------------------- -- Tabular reviews -- --------------------------------------------------------------------------- diff --git a/backend/migrations/001_user_mcp_servers.sql b/backend/migrations/001_user_mcp_servers.sql new file mode 100644 index 00000000..bd592257 --- /dev/null +++ b/backend/migrations/001_user_mcp_servers.sql @@ -0,0 +1,49 @@ +-- User-configurable MCP (Model Context Protocol) servers. +-- Each row points the chat backend at a Streamable-HTTP MCP endpoint that +-- exposes additional tools. Tools discovered from these endpoints are merged +-- into the per-request tool list and routed under the `mcp____` prefix. +-- +-- Sensitive header values (e.g. Authorization tokens) live in the `headers` +-- jsonb column. Access is gated by Postgres RLS — owner-only — matching the +-- precedent set by user_profiles.claude_api_key / gemini_api_key. + +create table if not exists public.user_mcp_servers ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users(id) on delete cascade, + slug text not null, + name text not null, + url text not null, + headers jsonb not null default '{}'::jsonb, + enabled boolean not null default true, + last_error text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint user_mcp_servers_slug_format + check (slug ~ '^[a-z0-9_-]{1,24}$'), + unique (user_id, slug) +); + +create index if not exists idx_user_mcp_servers_user + on public.user_mcp_servers(user_id, enabled); + +alter table public.user_mcp_servers enable row level security; + +drop policy if exists "Users can view their own MCP servers" on public.user_mcp_servers; +create policy "Users can view their own MCP servers" + on public.user_mcp_servers for select + using (auth.uid() = user_id); + +drop policy if exists "Users can insert their own MCP servers" on public.user_mcp_servers; +create policy "Users can insert their own MCP servers" + on public.user_mcp_servers for insert + with check (auth.uid() = user_id); + +drop policy if exists "Users can update their own MCP servers" on public.user_mcp_servers; +create policy "Users can update their own MCP servers" + on public.user_mcp_servers for update + using (auth.uid() = user_id); + +drop policy if exists "Users can delete their own MCP servers" on public.user_mcp_servers; +create policy "Users can delete their own MCP servers" + on public.user_mcp_servers for delete + using (auth.uid() = user_id); diff --git a/backend/package-lock.json b/backend/package-lock.json index 86f82382..90b08c42 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -13,6 +13,7 @@ "@aws-sdk/client-s3": "^3.787.0", "@aws-sdk/s3-request-presigner": "^3.787.0", "@google/genai": "^1.50.1", + "@modelcontextprotocol/sdk": "^1.29.0", "@supabase/supabase-js": "^2.49.4", "cors": "^2.8.5", "docx": "^9.5.0", @@ -1436,6 +1437,358 @@ } } }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/@napi-rs/canvas": { "version": "0.1.97", "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz", @@ -2781,6 +3134,45 @@ "node": ">= 14" } }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv/node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, "node_modules/append-field": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", @@ -3008,6 +3400,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -3305,6 +3711,27 @@ "node": ">= 0.6" } }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", + "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/express": { "version": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", @@ -3351,6 +3778,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.4.1.tgz", + "integrity": "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -3369,6 +3814,22 @@ "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", "license": "Apache-2.0" }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fast-xml-builder": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz", @@ -3650,6 +4111,15 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.12.16", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.16.tgz", + "integrity": "sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/html-to-text": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", @@ -3774,6 +4244,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -3783,12 +4262,33 @@ "node": ">= 0.10" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "license": "MIT" }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/json-bigint": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", @@ -3811,6 +4311,18 @@ "node": ">=16" } }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/jszip": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", @@ -4135,6 +4647,15 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/option": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz", @@ -4206,6 +4727,15 @@ "node": ">=0.10.0" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-to-regexp": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", @@ -4233,6 +4763,15 @@ "url": "https://ko-fi.com/killymxi" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/prettier": { "version": "3.8.1", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", @@ -4383,6 +4922,15 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resend": { "version": "4.8.0", "resolved": "https://registry.npmjs.org/resend/-/resend-4.8.0.tgz", @@ -4414,6 +4962,55 @@ "node": ">= 4" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/router/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -4525,6 +5122,27 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -4784,6 +5402,27 @@ "node": ">= 8" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/ws": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", @@ -4840,6 +5479,24 @@ "engines": { "node": ">=0.4" } + }, + "node_modules/zod": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.2.tgz", + "integrity": "sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/backend/package.json b/backend/package.json index 50dfb585..354a389b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,6 +13,7 @@ "@aws-sdk/client-s3": "^3.787.0", "@aws-sdk/s3-request-presigner": "^3.787.0", "@google/genai": "^1.50.1", + "@modelcontextprotocol/sdk": "^1.29.0", "@supabase/supabase-js": "^2.49.4", "cors": "^2.8.5", "docx": "^9.5.0", diff --git a/backend/src/index.ts b/backend/src/index.ts index 0e99fffb..b704edc1 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -9,6 +9,7 @@ import { tabularRouter } from "./routes/tabular"; import { workflowsRouter } from "./routes/workflows"; import { userRouter } from "./routes/user"; import { downloadsRouter } from "./routes/downloads"; +import { mcpServersRouter } from "./routes/mcpServers"; const app = express(); const PORT = process.env.PORT ?? 3001; @@ -31,6 +32,7 @@ app.use("/workflows", workflowsRouter); app.use("/user", userRouter); app.use("/users", userRouter); app.use("/download", downloadsRouter); +app.use("/user/mcp-servers", mcpServersRouter); app.get("/health", (_req, res) => res.json({ ok: true })); diff --git a/backend/src/lib/chatTools.ts b/backend/src/lib/chatTools.ts index c3ab2439..72540401 100644 --- a/backend/src/lib/chatTools.ts +++ b/backend/src/lib/chatTools.ts @@ -21,6 +21,8 @@ import { type LlmMessage, type OpenAIToolSchema, } from "./llm"; +import { findMcpServerForTool } from "./mcp/servers"; +import type { LoadedMcpServer } from "./mcp/types"; const STANDARD_FONT_DATA_URL = (() => { try { @@ -1495,6 +1497,7 @@ export async function runToolCalls( docIndex?: DocIndex, turnEditState?: TurnEditState, projectId?: string | null, + mcpServers?: LoadedMcpServer[], ): Promise<{ toolResults: unknown[]; docsRead: { filename: string; document_id?: string }[]; @@ -1524,6 +1527,33 @@ export async function runToolCalls( /* ignore */ } + if (tc.function.name.startsWith("mcp__") && mcpServers?.length) { + const match = findMcpServerForTool(tc.function.name, mcpServers); + if (!match) { + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: `MCP tool '${tc.function.name}' not available (server may have been removed mid-request).`, + }); + continue; + } + const { server, originalName } = match; + write( + `data: ${JSON.stringify({ + type: "mcp_tool_call", + server: server.row.name, + tool: originalName, + })}\n\n`, + ); + const content = await server.client.callTool(originalName, args); + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content, + }); + continue; + } + if (tc.function.name === "read_document") { const rawDocId = args.doc_id as string; const docId = @@ -2312,11 +2342,22 @@ export async function runLLMStream(params: { * generated docs still get persisted, but as standalone documents. */ projectId?: string | null; + /** + * MCP servers loaded for this user (already connected, with tool lists + * fetched). Their tools are merged into the per-request tool set under + * the `mcp____` prefix. Leave undefined when no MCP support is + * wired in or the user has none configured. + */ + mcpServers?: LoadedMcpServer[]; }): Promise<{ fullText: string; events: AssistantEvent[] }> { - const { apiMessages, docStore, docIndex, userId, db, write, extraTools, workflowStore, tabularStore, buildCitations, model, apiKeys, projectId } = params; - const activeTools = extraTools?.length - ? [...TOOLS, ...WORKFLOW_TOOLS, ...extraTools] - : [...TOOLS, ...WORKFLOW_TOOLS]; + const { apiMessages, docStore, docIndex, userId, db, write, extraTools, workflowStore, tabularStore, buildCitations, model, apiKeys, projectId, mcpServers } = params; + const mcpTools = (mcpServers ?? []).flatMap((s) => s.tools); + const activeTools = [ + ...TOOLS, + ...WORKFLOW_TOOLS, + ...(extraTools ?? []), + ...mcpTools, + ]; // Extract system prompt; pass remaining turns to the adapter as // plain user/assistant messages. @@ -2483,6 +2524,7 @@ export async function runLLMStream(params: { docIndex, turnEditState, projectId, + mcpServers, ); for (const r of docsRead) { events.push({ diff --git a/backend/src/lib/mcp/client.ts b/backend/src/lib/mcp/client.ts new file mode 100644 index 00000000..a819f717 --- /dev/null +++ b/backend/src/lib/mcp/client.ts @@ -0,0 +1,123 @@ +// Thin wrapper around the MCP TypeScript SDK's Streamable-HTTP client. +// +// Mike opens one client per (user, MCP server) per chat request. Connections +// are short-lived: we initialize, list tools, run any tools the model calls, +// then close in a `finally` on the request handler. There is no connection +// pool — each chat request pays an `initialize` round-trip per enabled +// server. This keeps the design stateless and avoids needing a worker. + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; + +const CONNECT_TIMEOUT_MS = 10_000; +const CALL_TIMEOUT_MS = 60_000; + +export class McpHttpClient { + private client: Client | null = null; + private transport: StreamableHTTPClientTransport | null = null; + + constructor( + private readonly url: string, + private readonly headers: Record, + ) {} + + async connect(): Promise { + this.transport = new StreamableHTTPClientTransport(new URL(this.url), { + requestInit: { + headers: this.headers, + }, + }); + this.client = new Client( + { name: "mike", version: "1.0.0" }, + { capabilities: {} }, + ); + await withTimeout( + this.client.connect(this.transport), + CONNECT_TIMEOUT_MS, + "MCP connect", + ); + } + + async listTools(): Promise { + if (!this.client) throw new Error("MCP client not connected"); + const result = await withTimeout( + this.client.listTools(), + CONNECT_TIMEOUT_MS, + "MCP listTools", + ); + return result.tools as Tool[]; + } + + /** + * Calls a tool and returns its text content joined by blank lines. + * Errors (transport failures, MCP `isError`) are turned into a text + * response so the model can surface them rather than crashing the chat. + */ + async callTool( + name: string, + args: Record, + ): Promise { + if (!this.client) return "MCP client not connected"; + try { + const result = await withTimeout( + this.client.callTool({ name, arguments: args }), + CALL_TIMEOUT_MS, + `MCP callTool(${name})`, + ); + const blocks = (result.content ?? []) as Array<{ + type?: string; + text?: string; + }>; + const text = blocks + .filter((b) => b?.type === "text" && typeof b.text === "string") + .map((b) => b.text) + .join("\n\n"); + if (result.isError) { + return `MCP tool '${name}' returned error: ${text || "(no detail)"}`; + } + return text || "(tool returned no text content)"; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return `MCP tool '${name}' failed: ${msg}`; + } + } + + async close(): Promise { + try { + await this.client?.close(); + } catch { + /* ignore */ + } + try { + await this.transport?.close(); + } catch { + /* ignore */ + } + this.client = null; + this.transport = null; + } +} + +function withTimeout( + p: Promise, + ms: number, + label: string, +): Promise { + return new Promise((resolve, reject) => { + const t = setTimeout( + () => reject(new Error(`${label} timed out after ${ms}ms`)), + ms, + ); + p.then( + (v) => { + clearTimeout(t); + resolve(v); + }, + (e) => { + clearTimeout(t); + reject(e); + }, + ); + }); +} diff --git a/backend/src/lib/mcp/servers.ts b/backend/src/lib/mcp/servers.ts new file mode 100644 index 00000000..2c323e0b --- /dev/null +++ b/backend/src/lib/mcp/servers.ts @@ -0,0 +1,133 @@ +// Per-request MCP server loader. +// +// Called once at the top of each chat request. Reads the user's enabled MCP +// servers from Postgres, opens a Streamable-HTTP client to each in parallel, +// fetches its tool list, and converts each tool to the OpenAI-style schema +// Mike's LLM adapter speaks. Tool names are prefixed with `mcp____` so +// the dispatcher in chatTools can route calls back to the right server. + +import { createHash } from "crypto"; +import type { OpenAIToolSchema } from "../llm/types"; +import type { createServerSupabase } from "../supabase"; +import { McpHttpClient } from "./client"; +import type { LoadedMcpServer, McpServerRow } from "./types"; + +const TOOL_NAME_MAX = 64; +const TOOL_PREFIX = "mcp__"; + +export async function loadEnabledMcpServersForUser( + userId: string, + db: ReturnType, +): Promise { + const { data, error } = await db + .from("user_mcp_servers") + .select("*") + .eq("user_id", userId) + .eq("enabled", true); + if (error || !data || data.length === 0) return []; + + const rows = data as McpServerRow[]; + const results = await Promise.allSettled(rows.map(loadOne)); + + const out: LoadedMcpServer[] = []; + for (let i = 0; i < results.length; i++) { + const r = results[i]; + const row = rows[i]; + if (r.status === "fulfilled" && r.value) { + out.push(r.value); + // Clear stale error on success. + if (row.last_error) { + await db + .from("user_mcp_servers") + .update({ last_error: null }) + .eq("id", row.id); + } + } else { + const err = + r.status === "rejected" + ? r.reason instanceof Error + ? r.reason.message + : String(r.reason) + : "unknown error"; + console.warn( + `[mcp] failed to load server ${row.slug} (${row.url}): ${err}`, + ); + await db + .from("user_mcp_servers") + .update({ last_error: err.slice(0, 1000) }) + .eq("id", row.id); + } + } + return out; +} + +async function loadOne(row: McpServerRow): Promise { + const client = new McpHttpClient(row.url, row.headers ?? {}); + await client.connect(); + const mcpTools = await client.listTools(); + + const tools: OpenAIToolSchema[] = []; + const toolNameMap = new Map(); + for (const t of mcpTools) { + const prefixed = prefixedToolName(row.slug, t.name); + toolNameMap.set(prefixed, t.name); + tools.push({ + type: "function", + function: { + name: prefixed, + description: `[${row.name}] ${t.description ?? ""}`.trim(), + parameters: (t.inputSchema as Record) ?? { + type: "object", + properties: {}, + }, + }, + }); + } + + return { + row, + tools, + toolNameMap, + client: { + callTool: (name, args) => client.callTool(name, args), + close: () => client.close(), + }, + }; +} + +/** + * `mcp____`, capped at 64 chars (Anthropic's limit). + * If the natural name is too long, the toolName tail is replaced with a + * 12-hex-char hash so the prefix stays intact and the dispatcher can route. + */ +export function prefixedToolName(slug: string, toolName: string): string { + const natural = `${TOOL_PREFIX}${slug}__${toolName}`; + if (natural.length <= TOOL_NAME_MAX) return natural; + const hash = createHash("sha256") + .update(toolName) + .digest("hex") + .slice(0, 12); + const head = `${TOOL_PREFIX}${slug}__`; + const room = TOOL_NAME_MAX - head.length - 1 /* underscore */ - hash.length; + const truncated = toolName.slice(0, Math.max(0, room)); + return `${head}${truncated}_${hash}`.slice(0, TOOL_NAME_MAX); +} + +export async function closeMcpServers(servers: LoadedMcpServer[]): Promise { + await Promise.allSettled(servers.map((s) => s.client.close())); +} + +/** + * Look up which loaded server owns a prefixed tool name. Used by the chat + * tool dispatcher. + */ +export function findMcpServerForTool( + prefixedName: string, + servers: LoadedMcpServer[], +): { server: LoadedMcpServer; originalName: string } | null { + for (const s of servers) { + const original = s.toolNameMap.get(prefixedName); + if (original) return { server: s, originalName: original }; + } + return null; +} diff --git a/backend/src/lib/mcp/types.ts b/backend/src/lib/mcp/types.ts new file mode 100644 index 00000000..1ee8f47b --- /dev/null +++ b/backend/src/lib/mcp/types.ts @@ -0,0 +1,34 @@ +import type { OpenAIToolSchema } from "../llm/types"; + +export type McpServerRow = { + id: string; + user_id: string; + slug: string; + name: string; + url: string; + headers: Record; + enabled: boolean; + last_error: string | null; +}; + +/** + * One MCP server, opened for the duration of a single chat request. + * + * `tools` are already prefixed (`mcp____`) and ready to merge + * into the per-request tool list. The original tool name is preserved in + * `toolNameMap` so the dispatcher can call back into the MCP server with the + * unprefixed name. + */ +export type LoadedMcpServer = { + row: McpServerRow; + tools: OpenAIToolSchema[]; + /** prefixed tool name → original MCP tool name */ + toolNameMap: Map; + client: { + callTool: ( + toolName: string, + args: Record, + ) => Promise; + close: () => Promise; + }; +}; diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts index b56c2936..42435b9a 100644 --- a/backend/src/routes/chat.ts +++ b/backend/src/routes/chat.ts @@ -13,6 +13,10 @@ import { import { completeText } from "../lib/llm"; import { getUserApiKeys, getUserModelSettings } from "../lib/userSettings"; import { checkProjectAccess } from "../lib/access"; +import { + closeMcpServers, + loadEnabledMcpServersForUser, +} from "../lib/mcp/servers"; export const chatRouter = Router(); @@ -435,6 +439,7 @@ chatRouter.post("/", requireAuth, async (req, res) => { const write = (line: string) => res.write(line); const apiKeys = await getUserApiKeys(userId, db); + const mcpServers = await loadEnabledMcpServersForUser(userId, db); try { write(`data: ${JSON.stringify({ type: "chat_id", chatId })}\n\n`); @@ -450,6 +455,7 @@ chatRouter.post("/", requireAuth, async (req, res) => { model, apiKeys, projectId: project_id ?? null, + mcpServers, }); console.log("[chat/stream] LLM stream finished", { @@ -482,6 +488,7 @@ chatRouter.post("/", requireAuth, async (req, res) => { /* ignore */ } } finally { + await closeMcpServers(mcpServers); res.end(); } }); diff --git a/backend/src/routes/mcpServers.ts b/backend/src/routes/mcpServers.ts new file mode 100644 index 00000000..9826058b --- /dev/null +++ b/backend/src/routes/mcpServers.ts @@ -0,0 +1,244 @@ +// CRUD for user-configurable MCP (Model Context Protocol) servers. +// +// Mounted at `/user/mcp-servers`. The backend uses Supabase's service role +// (bypassing RLS), so every handler MUST filter by `user_id = userId`. + +import { Router } from "express"; +import { requireAuth } from "../middleware/auth"; +import { createServerSupabase } from "../lib/supabase"; +import { McpHttpClient } from "../lib/mcp/client"; + +export const mcpServersRouter = Router(); + +const SLUG_RE = /^[a-z0-9_-]{1,24}$/; +const NAME_MAX = 80; +const URL_MAX = 500; +const HEADER_NAME_RE = /^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$/; +const MAX_HEADERS = 20; +const MAX_HEADER_VALUE_LEN = 4096; + +type Body = { + name?: unknown; + slug?: unknown; + url?: unknown; + headers?: unknown; + enabled?: unknown; +}; + +function deriveSlug(name: string): string { + const base = name + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^[-_]+|[-_]+$/g, "") + .slice(0, 24); + return base || "mcp"; +} + +function validateUrl(raw: string): { ok: true } | { ok: false; error: string } { + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + return { ok: false, error: "url is not a valid URL" }; + } + if (parsed.protocol === "https:") return { ok: true }; + if ( + parsed.protocol === "http:" && + (parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1") + ) { + return { ok: true }; + } + return { ok: false, error: "url must use https (or http://localhost)" }; +} + +function validateHeaders( + raw: unknown, +): { ok: true; value: Record } | { ok: false; error: string } { + if (raw === undefined || raw === null) return { ok: true, value: {} }; + if (typeof raw !== "object" || Array.isArray(raw)) { + return { ok: false, error: "headers must be an object of string→string" }; + } + const entries = Object.entries(raw as Record); + if (entries.length > MAX_HEADERS) { + return { ok: false, error: `headers may not have more than ${MAX_HEADERS} entries` }; + } + const out: Record = {}; + for (const [k, v] of entries) { + if (!HEADER_NAME_RE.test(k)) { + return { ok: false, error: `invalid header name: ${k}` }; + } + if (typeof v !== "string" || v.length > MAX_HEADER_VALUE_LEN) { + return { ok: false, error: `header '${k}' value must be a string of ≤${MAX_HEADER_VALUE_LEN} chars` }; + } + out[k] = v; + } + return { ok: true, value: out }; +} + +function publicShape>(row: T) { + const { headers, ...rest } = row as T & { headers?: Record }; + return { + ...rest, + header_keys: headers ? Object.keys(headers) : [], + }; +} + +// GET /user/mcp-servers — list (header values redacted, only keys returned) +mcpServersRouter.get("/", requireAuth, async (_req, res) => { + const userId = res.locals.userId as string; + const db = createServerSupabase(); + const { data, error } = await db + .from("user_mcp_servers") + .select("id, slug, name, url, headers, enabled, last_error, created_at, updated_at") + .eq("user_id", userId) + .order("created_at", { ascending: true }); + if (error) return void res.status(500).json({ detail: error.message }); + res.json((data ?? []).map(publicShape)); +}); + +// POST /user/mcp-servers — create +mcpServersRouter.post("/", requireAuth, async (req, res) => { + const userId = res.locals.userId as string; + const body = (req.body ?? {}) as Body; + + const name = typeof body.name === "string" ? body.name.trim() : ""; + if (!name || name.length > NAME_MAX) { + return void res.status(400).json({ detail: `name is required (≤${NAME_MAX} chars)` }); + } + const url = typeof body.url === "string" ? body.url.trim() : ""; + if (!url || url.length > URL_MAX) { + return void res.status(400).json({ detail: `url is required (≤${URL_MAX} chars)` }); + } + const urlOk = validateUrl(url); + if (!urlOk.ok) return void res.status(400).json({ detail: urlOk.error }); + + let slug = typeof body.slug === "string" && body.slug.trim() + ? body.slug.trim().toLowerCase() + : deriveSlug(name); + if (!SLUG_RE.test(slug)) { + return void res.status(400).json({ detail: "slug must match /^[a-z0-9_-]{1,24}$/" }); + } + + const headersOk = validateHeaders(body.headers); + if (!headersOk.ok) return void res.status(400).json({ detail: headersOk.error }); + + const enabled = body.enabled === false ? false : true; + + const db = createServerSupabase(); + const { data, error } = await db + .from("user_mcp_servers") + .insert({ + user_id: userId, + slug, + name, + url, + headers: headersOk.value, + enabled, + }) + .select("id, slug, name, url, headers, enabled, last_error, created_at, updated_at") + .single(); + if (error) { + const status = error.code === "23505" ? 409 : 500; + return void res.status(status).json({ detail: error.message }); + } + res.json(publicShape(data)); +}); + +// PATCH /user/mcp-servers/:id — update name/url/headers/enabled +mcpServersRouter.patch("/:id", requireAuth, async (req, res) => { + const userId = res.locals.userId as string; + const { id } = req.params; + const body = (req.body ?? {}) as Body; + const update: Record = { updated_at: new Date().toISOString() }; + + if (body.name !== undefined) { + const name = typeof body.name === "string" ? body.name.trim() : ""; + if (!name || name.length > NAME_MAX) { + return void res.status(400).json({ detail: `name must be 1–${NAME_MAX} chars` }); + } + update.name = name; + } + if (body.url !== undefined) { + const url = typeof body.url === "string" ? body.url.trim() : ""; + if (!url) return void res.status(400).json({ detail: "url is required" }); + const urlOk = validateUrl(url); + if (!urlOk.ok) return void res.status(400).json({ detail: urlOk.error }); + update.url = url; + } + if (body.headers !== undefined) { + const headersOk = validateHeaders(body.headers); + if (!headersOk.ok) return void res.status(400).json({ detail: headersOk.error }); + update.headers = headersOk.value; + } + if (body.enabled !== undefined) { + update.enabled = body.enabled === true; + } + + const db = createServerSupabase(); + const { data, error } = await db + .from("user_mcp_servers") + .update(update) + .eq("id", id) + .eq("user_id", userId) + .select("id, slug, name, url, headers, enabled, last_error, created_at, updated_at") + .single(); + if (error || !data) { + return void res.status(404).json({ detail: error?.message ?? "Not found" }); + } + res.json(publicShape(data)); +}); + +// DELETE /user/mcp-servers/:id +mcpServersRouter.delete("/:id", requireAuth, async (req, res) => { + const userId = res.locals.userId as string; + const { id } = req.params; + const db = createServerSupabase(); + const { error } = await db + .from("user_mcp_servers") + .delete() + .eq("id", id) + .eq("user_id", userId); + if (error) return void res.status(500).json({ detail: error.message }); + res.status(204).send(); +}); + +// POST /user/mcp-servers/:id/test — connect + list_tools, return summary +mcpServersRouter.post("/:id/test", requireAuth, async (req, res) => { + const userId = res.locals.userId as string; + const { id } = req.params; + const db = createServerSupabase(); + const { data: row, error } = await db + .from("user_mcp_servers") + .select("url, headers") + .eq("id", id) + .eq("user_id", userId) + .single(); + if (error || !row) { + return void res.status(404).json({ detail: "Not found" }); + } + + const client = new McpHttpClient(row.url, (row.headers ?? {}) as Record); + try { + await client.connect(); + const tools = await client.listTools(); + await db + .from("user_mcp_servers") + .update({ last_error: null }) + .eq("id", id); + res.json({ + ok: true, + tool_count: tools.length, + tools: tools.map((t) => ({ name: t.name, description: t.description ?? "" })), + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await db + .from("user_mcp_servers") + .update({ last_error: message.slice(0, 1000) }) + .eq("id", id); + res.status(200).json({ ok: false, error: message }); + } finally { + await client.close(); + } +}); diff --git a/backend/src/routes/projectChat.ts b/backend/src/routes/projectChat.ts index 5e299615..35129a67 100644 --- a/backend/src/routes/projectChat.ts +++ b/backend/src/routes/projectChat.ts @@ -13,6 +13,10 @@ import { } from "../lib/chatTools"; import { getUserApiKeys } from "../lib/userSettings"; import { checkProjectAccess } from "../lib/access"; +import { + closeMcpServers, + loadEnabledMcpServersForUser, +} from "../lib/mcp/servers"; const PROJECT_SYSTEM_PROMPT_EXTRA = `PROJECT CONTEXT: You are operating within a project folder that contains a collection of legal documents the user has organised for a single matter. The user's questions will usually refer to one or more documents in this project — your job is to find the relevant files to work on. Use list_documents to see what is available and fetch_documents / read_document to pull in any documents you need before answering. @@ -153,6 +157,7 @@ projectChatRouter.post("/", requireAuth, async (req, res) => { const write = (line: string) => res.write(line); const apiKeys = await getUserApiKeys(userId, db); + const mcpServers = await loadEnabledMcpServersForUser(userId, db); try { write(`data: ${JSON.stringify({ type: "chat_id", chatId })}\n\n`); @@ -169,6 +174,7 @@ projectChatRouter.post("/", requireAuth, async (req, res) => { model, apiKeys, projectId, + mcpServers, }); const annotations = extractAnnotations(fullText, docIndex, events); @@ -196,6 +202,7 @@ projectChatRouter.post("/", requireAuth, async (req, res) => { /* ignore */ } } finally { + await closeMcpServers(mcpServers); res.end(); } }); diff --git a/frontend/src/app/(pages)/account/layout.tsx b/frontend/src/app/(pages)/account/layout.tsx index 543638c1..608da98b 100644 --- a/frontend/src/app/(pages)/account/layout.tsx +++ b/frontend/src/app/(pages)/account/layout.tsx @@ -14,6 +14,7 @@ interface TabDef { const TABS: TabDef[] = [ { id: "general", label: "General", href: "/account" }, { id: "models", label: "Models & API Keys", href: "/account/models" }, + { id: "mcp", label: "MCP Servers", href: "/account/mcp" }, ]; export default function AccountLayout({ diff --git a/frontend/src/app/(pages)/account/mcp/page.tsx b/frontend/src/app/(pages)/account/mcp/page.tsx new file mode 100644 index 00000000..7144ad36 --- /dev/null +++ b/frontend/src/app/(pages)/account/mcp/page.tsx @@ -0,0 +1,495 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { + AlertCircle, + Check, + ChevronUp, + Loader2, + Plus, + Trash2, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + createMcpServer, + deleteMcpServer, + listMcpServers, + testMcpServer, + updateMcpServer, + type McpServer, + type McpServerTestResult, +} from "@/app/lib/mikeApi"; + +type DraftHeader = { key: string; value: string }; + +type Draft = { + name: string; + url: string; + headers: DraftHeader[]; +}; + +const EMPTY_DRAFT: Draft = { + name: "", + url: "", + headers: [{ key: "", value: "" }], +}; + +export default function McpServersPage() { + const [servers, setServers] = useState([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + + const [showAdd, setShowAdd] = useState(false); + const [draft, setDraft] = useState(EMPTY_DRAFT); + const [saving, setSaving] = useState(false); + const [addError, setAddError] = useState(null); + + const [testing, setTesting] = useState>({}); + const [testResults, setTestResults] = useState< + Record + >({}); + + const reload = useCallback(async () => { + setLoadError(null); + try { + const list = await listMcpServers(); + setServers(list); + } catch (err) { + setLoadError(err instanceof Error ? err.message : "Failed to load"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + reload(); + }, [reload]); + + const handleAdd = async () => { + setAddError(null); + const name = draft.name.trim(); + const url = draft.url.trim(); + if (!name || !url) { + setAddError("Name and URL are required."); + return; + } + const headers: Record = {}; + for (const h of draft.headers) { + const k = h.key.trim(); + if (!k) continue; + headers[k] = h.value; + } + setSaving(true); + try { + await createMcpServer({ name, url, headers }); + setDraft(EMPTY_DRAFT); + setShowAdd(false); + await reload(); + } catch (err) { + setAddError(err instanceof Error ? err.message : "Failed to save"); + } finally { + setSaving(false); + } + }; + + const handleToggleEnabled = async (server: McpServer) => { + try { + await updateMcpServer(server.id, { enabled: !server.enabled }); + await reload(); + } catch (err) { + alert(err instanceof Error ? err.message : "Failed to update"); + } + }; + + const handleDelete = async (server: McpServer) => { + if (!confirm(`Remove MCP server "${server.name}"?`)) return; + try { + await deleteMcpServer(server.id); + await reload(); + } catch (err) { + alert(err instanceof Error ? err.message : "Failed to delete"); + } + }; + + const handleTest = async (server: McpServer) => { + setTesting((s) => ({ ...s, [server.id]: true })); + try { + const result = await testMcpServer(server.id); + setTestResults((r) => ({ ...r, [server.id]: result })); + } catch (err) { + setTestResults((r) => ({ + ...r, + [server.id]: { + ok: false, + error: err instanceof Error ? err.message : String(err), + }, + })); + } finally { + setTesting((s) => ({ ...s, [server.id]: false })); + // Reload so last_error reflects the test outcome. + reload(); + } + }; + + return ( +
+
+
+

+ MCP Servers +

+ +
+

+ Connect external{" "} + + Model Context Protocol + {" "} + servers to extend Mike with extra tools (legal-data + sources, web research, internal company APIs, …). + Tools discovered from each server become available to the + chat assistant under the{" "} + + mcp__<slug>__<tool> + {" "} + name. +

+
+ + {showAdd && ( + + )} + + {loading ? ( +
+ Loading + servers… +
+ ) : loadError ? ( +
+ + {loadError} +
+ ) : servers.length === 0 ? ( +
+ No MCP servers configured yet. +
+ ) : ( +
+ {servers.map((s) => ( + handleToggleEnabled(s)} + onDelete={() => handleDelete(s)} + onTest={() => handleTest(s)} + /> + ))} +
+ )} +
+ ); +} + +function AddForm({ + draft, + setDraft, + onSave, + saving, + error, +}: { + draft: Draft; + setDraft: (d: Draft) => void; + onSave: () => void; + saving: boolean; + error: string | null; +}) { + const updateHeader = (idx: number, patch: Partial) => { + const headers = draft.headers.map((h, i) => + i === idx ? { ...h, ...patch } : h, + ); + setDraft({ ...draft, headers }); + }; + const addHeaderRow = () => + setDraft({ + ...draft, + headers: [...draft.headers, { key: "", value: "" }], + }); + const removeHeaderRow = (idx: number) => + setDraft({ + ...draft, + headers: draft.headers.filter((_, i) => i !== idx), + }); + + return ( +
+
+ + + setDraft({ ...draft, name: e.target.value }) + } + /> +
+
+ + + setDraft({ ...draft, url: e.target.value }) + } + /> +

+ Streamable-HTTP MCP endpoint. Must be HTTPS (or + http://localhost for local testing). +

+
+
+ +

+ Sent on every request. Common usage:{" "} + + Authorization + {" "} + →{" "} + + Bearer <token> + + . +

+
+ {draft.headers.map((h, idx) => ( +
+ + updateHeader(idx, { key: e.target.value }) + } + className="flex-1" + /> + + updateHeader(idx, { + value: e.target.value, + }) + } + className="flex-1" + type="password" + /> + +
+ ))} + +
+
+ {error && ( +
+ + {error} +
+ )} +
+ +
+
+ ); +} + +function ServerCard({ + server, + testing, + testResult, + onToggle, + onDelete, + onTest, +}: { + server: McpServer; + testing: boolean; + testResult?: McpServerTestResult; + onToggle: () => void; + onDelete: () => void; + onTest: () => void; +}) { + const [showDetails, setShowDetails] = useState(false); + return ( +
+
+
+
+ {server.name} + + {server.enabled ? "Enabled" : "Disabled"} + + {server.last_error && ( + + Last error + + )} +
+
+ {server.url} +
+ {server.header_keys.length > 0 && ( +
+ Headers: {server.header_keys.join(", ")} +
+ )} +
+
+ + + +
+
+ + {testResult && ( +
+ {testResult.ok ? ( +
+ + Discovered {testResult.tool_count ?? 0} tool + {testResult.tool_count === 1 ? "" : "s"}. + {testResult.tools && testResult.tools.length > 0 && ( + + )} +
+ ) : ( +
+ + + {testResult.error ?? "Unknown error"} + +
+ )} + {showDetails && testResult.tools && ( +
    + {testResult.tools.map((t) => ( +
  • + + {t.name} + + {t.description ? ` — ${t.description}` : ""} +
  • + ))} +
+ )} +
+ )} + + {server.last_error && !testResult && ( +
+ + {server.last_error} +
+ )} +
+ ); +} + diff --git a/frontend/src/app/lib/mikeApi.ts b/frontend/src/app/lib/mikeApi.ts index 2d2a7417..73ec8dc0 100644 --- a/frontend/src/app/lib/mikeApi.ts +++ b/frontend/src/app/lib/mikeApi.ts @@ -815,3 +815,70 @@ export async function deleteWorkflowShare( method: "DELETE", }); } + +// --------------------------------------------------------------------------- +// MCP servers +// --------------------------------------------------------------------------- + +export interface McpServer { + id: string; + slug: string; + name: string; + url: string; + header_keys: string[]; + enabled: boolean; + last_error: string | null; + created_at: string; + updated_at: string; +} + +export interface McpServerTestResult { + ok: boolean; + tool_count?: number; + tools?: { name: string; description: string }[]; + error?: string; +} + +export async function listMcpServers(): Promise { + return apiRequest("/user/mcp-servers"); +} + +export async function createMcpServer(payload: { + name: string; + url: string; + slug?: string; + headers?: Record; + enabled?: boolean; +}): Promise { + return apiRequest("/user/mcp-servers", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); +} + +export async function updateMcpServer( + id: string, + payload: { + name?: string; + url?: string; + headers?: Record; + enabled?: boolean; + }, +): Promise { + return apiRequest(`/user/mcp-servers/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); +} + +export async function deleteMcpServer(id: string): Promise { + await apiRequest(`/user/mcp-servers/${id}`, { method: "DELETE" }); +} + +export async function testMcpServer(id: string): Promise { + return apiRequest(`/user/mcp-servers/${id}/test`, { + method: "POST", + }); +} From fad06acac44b41eb36ba7b7c48e0e212e3c48d9c Mon Sep 17 00:00:00 2001 From: Zacharie Laik Date: Mon, 4 May 2026 22:53:25 +0200 Subject: [PATCH 2/3] feat(mcp): rename to Connectors, prettier tool calls, observability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Polish on top of the initial MCP support commit. Same scope (no auth/marketplace yet), just smoothing the rough edges from a real test session. UX - Settings tab + chat-input button renamed to "Connectors". MCP is mentioned in the page description (with a link to modelcontextprotocol.io) so the protocol is still discoverable. - New `Connectors` button next to Documents / Workflows in the chat input opens a popover with a per-server toggle switch. Hides itself when the user has no connectors configured. - Tool calls in chat now render `Running · ` (friendly) instead of the raw `mcp____` prefix; the original name still routes correctly. - After each MCP tool call, a result block shows ✓/✗ + first line of output, with a "Show details" toggle that expands pretty-printed JSON arguments and the full text output. - New connectors auto-discover their tool list immediately on save (no extra Test click). Re-enabling a disabled connector also auto-tests. - Settings card redesigned: status pill, header chips, expandable per-tool descriptions with More/Less. Sanitises Name field if it looks like a Bearer token was pasted into it (best-effort safety net). - Amber "only add connectors you trust" notice at the top of the page and a compact restated form inside the Add panel. Backend - New SSE event type `mcp_tool_result` with `{ server, tool, ok, args, output }`. args/output capped at 4 KB each before persistence (the model still receives the untruncated tool output — only the user-visible preview is capped). - `tool_call_start` now optionally carries `display_name`; the renderer prefers it. --- backend/src/lib/chatTools.ts | 60 +++- frontend/src/app/(pages)/account/layout.tsx | 2 +- frontend/src/app/(pages)/account/mcp/page.tsx | 299 +++++++++++++----- .../components/assistant/AssistantMessage.tsx | 102 +++++- .../app/components/assistant/ChatInput.tsx | 2 + .../components/assistant/McpToggleButton.tsx | 173 ++++++++++ frontend/src/app/components/shared/types.ts | 13 + frontend/src/app/hooks/useAssistantChat.ts | 16 + 8 files changed, 584 insertions(+), 83 deletions(-) create mode 100644 frontend/src/app/components/assistant/McpToggleButton.tsx diff --git a/backend/src/lib/chatTools.ts b/backend/src/lib/chatTools.ts index 72540401..e1db1977 100644 --- a/backend/src/lib/chatTools.ts +++ b/backend/src/lib/chatTools.ts @@ -1486,6 +1486,32 @@ export type DocReplicatedResult = { }[]; }; +/** + * One MCP tool call worth of observability — surfaced to the chat UI so the + * user can see what was sent and what came back. `args` and `output` are + * already capped in size before this event is emitted/persisted. + */ +export type McpToolResultEvent = { + type: "mcp_tool_result"; + server: string; + tool: string; + ok: boolean; + args: string; + output: string; +}; + +/** + * Cap previewed args/output to keep `chat_messages.content` from bloating. + * The model still receives the full untruncated tool output — this only + * affects what is shown to and persisted for the user. + */ +const MCP_PREVIEW_MAX = 4096; + +function truncateForPreview(s: string): string { + if (s.length <= MCP_PREVIEW_MAX) return s; + return s.slice(0, MCP_PREVIEW_MAX) + "\n…(truncated)"; +} + export async function runToolCalls( toolCalls: ToolCall[], docStore: DocStore, @@ -1506,6 +1532,7 @@ export async function runToolCalls( docsReplicated: DocReplicatedResult[]; workflowsApplied: { workflow_id: string; title: string }[]; docsEdited: DocEditedResult[]; + mcpResults: McpToolResultEvent[]; }> { const toolResults: unknown[] = []; const docsRead: { filename: string; document_id?: string }[] = []; @@ -1518,6 +1545,7 @@ export async function runToolCalls( const docsReplicated: DocReplicatedResult[] = []; const workflowsApplied: { workflow_id: string; title: string }[] = []; const docsEdited: DocEditedResult[] = []; + const mcpResults: McpToolResultEvent[] = []; for (const tc of toolCalls) { let args: Record = {}; @@ -1546,6 +1574,19 @@ export async function runToolCalls( })}\n\n`, ); const content = await server.client.callTool(originalName, args); + // The model gets the untruncated content; the user-facing preview + // is capped to keep chat_messages.content from bloating. + const ok = !content.startsWith(`MCP tool '${originalName}' `); + const preview: McpToolResultEvent = { + type: "mcp_tool_result", + server: server.row.name, + tool: originalName, + ok, + args: truncateForPreview(JSON.stringify(args)), + output: truncateForPreview(content), + }; + write(`data: ${JSON.stringify(preview)}\n\n`); + mcpResults.push(preview); toolResults.push({ role: "tool", tool_call_id: tc.id, @@ -2236,6 +2277,7 @@ export async function runToolCalls( docsReplicated, workflowsApplied, docsEdited, + mcpResults, }; } @@ -2321,7 +2363,8 @@ type AssistantEvent = download_url: string; annotations: EditAnnotation[]; } - | { type: "content"; text: string }; + | { type: "content"; text: string } + | McpToolResultEvent; export async function runLLMStream(params: { apiMessages: unknown[]; @@ -2485,10 +2528,21 @@ export async function runLLMStream(params: { // and the first tool-specific event. onToolCallStart: (call) => { flushText(); + // For MCP tools, emit a friendly display name (server + tool) + // alongside the raw prefixed name. The UI renders display_name + // when present so users don't see `mcp____`. + let display_name: string | undefined; + if (call.name.startsWith("mcp__") && mcpServers?.length) { + const match = findMcpServerForTool(call.name, mcpServers); + if (match) { + display_name = `${match.server.row.name} · ${match.originalName}`; + } + } write( `data: ${JSON.stringify({ type: "tool_call_start", name: call.name, + ...(display_name ? { display_name } : {}), })}\n\n`, ); }, @@ -2513,6 +2567,7 @@ export async function runLLMStream(params: { docsReplicated, workflowsApplied, docsEdited, + mcpResults, } = await runToolCalls( toolCalls, docStore, @@ -2577,6 +2632,9 @@ export async function runLLMStream(params: { annotations: e.annotations, }); } + for (const r of mcpResults) { + events.push(r); + } // Index alignment would break if any tool branch skips its // push (unhandled tool name, disabled store, guard failure). diff --git a/frontend/src/app/(pages)/account/layout.tsx b/frontend/src/app/(pages)/account/layout.tsx index 608da98b..475fa81d 100644 --- a/frontend/src/app/(pages)/account/layout.tsx +++ b/frontend/src/app/(pages)/account/layout.tsx @@ -14,7 +14,7 @@ interface TabDef { const TABS: TabDef[] = [ { id: "general", label: "General", href: "/account" }, { id: "models", label: "Models & API Keys", href: "/account/models" }, - { id: "mcp", label: "MCP Servers", href: "/account/mcp" }, + { id: "mcp", label: "Connectors", href: "/account/mcp" }, ]; export default function AccountLayout({ diff --git a/frontend/src/app/(pages)/account/mcp/page.tsx b/frontend/src/app/(pages)/account/mcp/page.tsx index 7144ad36..bd3d063c 100644 --- a/frontend/src/app/(pages)/account/mcp/page.tsx +++ b/frontend/src/app/(pages)/account/mcp/page.tsx @@ -82,10 +82,14 @@ export default function McpServersPage() { } setSaving(true); try { - await createMcpServer({ name, url, headers }); + const created = await createMcpServer({ name, url, headers }); setDraft(EMPTY_DRAFT); setShowAdd(false); await reload(); + // Auto-discover tools so the user sees the tool list right away + // without an extra Test click. Errors surface inline via the + // server card's last_error / testResults render. + void runAutoTest(created.id); } catch (err) { setAddError(err instanceof Error ? err.message : "Failed to save"); } finally { @@ -93,17 +97,40 @@ export default function McpServersPage() { } }; + const runAutoTest = async (id: string) => { + setTesting((s) => ({ ...s, [id]: true })); + try { + const result = await testMcpServer(id); + setTestResults((r) => ({ ...r, [id]: result })); + } catch (err) { + setTestResults((r) => ({ + ...r, + [id]: { + ok: false, + error: err instanceof Error ? err.message : String(err), + }, + })); + } finally { + setTesting((s) => ({ ...s, [id]: false })); + reload(); + } + }; + const handleToggleEnabled = async (server: McpServer) => { + const wasDisabled = !server.enabled; try { await updateMcpServer(server.id, { enabled: !server.enabled }); await reload(); + // Auto-test when going disabled → enabled so the user sees + // immediately if the server still works after re-enabling. + if (wasDisabled) void runAutoTest(server.id); } catch (err) { alert(err instanceof Error ? err.message : "Failed to update"); } }; const handleDelete = async (server: McpServer) => { - if (!confirm(`Remove MCP server "${server.name}"?`)) return; + if (!confirm(`Remove connector "${server.name}"?`)) return; try { await deleteMcpServer(server.id); await reload(); @@ -137,7 +164,7 @@ export default function McpServersPage() {

- MCP Servers + Connectors

- Connect external{" "} + Connectors plug external tools into Mike via the{" "} Model Context Protocol {" "} - servers to extend Mike with extra tools (legal-data - sources, web research, internal company APIs, …). - Tools discovered from each server become available to the - chat assistant under the{" "} + (MCP) — legal-data sources, web research, internal + company APIs, and so on. Tools discovered from each + connector become available to the chat assistant under + the{" "} mcp__<slug>__<tool> {" "} @@ -176,6 +203,27 @@ export default function McpServersPage() {

+ {/* Trust trade-off warning. Surfaced once at the top so users + don't paste URLs and tokens for servers they haven't vetted. */} +
+ +
+

+ Only add connectors you trust +

+

+ A connector’s operator can see anything Mike + sends in tool calls — your prompts, document + excerpts, and the tool’s own response. Custom + headers (including{" "} + + Authorization + {" "} + tokens) are sent on every request. +

+
+
+ {showAdd && ( ) : servers.length === 0 ? (
- No MCP servers configured yet. + No connectors configured yet.
) : (
@@ -254,12 +302,17 @@ function AddForm({
setDraft({ ...draft, name: e.target.value }) } /> +

+ Shown in chat when the assistant calls a tool. Don’t + paste tokens here — use the Headers section below + for credentials. +

@@ -340,6 +393,10 @@ function AddForm({ {error}
)} +

+ By saving, you confirm you trust this server’s operator + with anything Mike sends to it during tool calls. +

-
- {testResult && ( -
- {testResult.ok ? ( -
- + {/* Errors / status footer */} + {testResult && !testResult.ok && ( +
+ + + {testResult.error ?? "Unknown error"} + +
+ )} + {server.last_error && !testResult && ( +
+ + {server.last_error} +
+ )} + + {/* Tool list */} + {testResult?.ok && testResult.tools && testResult.tools.length > 0 && ( +
+ - )} -
- ) : ( -
- - - {testResult.error ?? "Unknown error"} - -
- )} - {showDetails && testResult.tools && ( -
    + {testResult.tool_count === 1 ? "" : "s"} + + + {showDetails ? "Hide" : "Show"} + + + {showDetails && ( +
      {testResult.tools.map((t) => ( -
    • - - {t.name} - - {t.description ? ` — ${t.description}` : ""} -
    • + ))}
    )}
)} +
+ ); +} - {server.last_error && !testResult && ( -
- - {server.last_error} -
+function ToolListItem({ + name, + description, +}: { + name: string; + description: string; +}) { + const [expanded, setExpanded] = useState(false); + const trimmed = description.trim(); + const isLong = trimmed.length > 160; + const shown = expanded || !isLong ? trimmed : trimmed.slice(0, 160) + "…"; + return ( +
  • +
    + + {name} + + {isLong && ( + + )} +
    + {trimmed && ( +

    {shown}

    )} -
  • + ); } diff --git a/frontend/src/app/components/assistant/AssistantMessage.tsx b/frontend/src/app/components/assistant/AssistantMessage.tsx index 48b0425b..930a6fe1 100644 --- a/frontend/src/app/components/assistant/AssistantMessage.tsx +++ b/frontend/src/app/components/assistant/AssistantMessage.tsx @@ -421,6 +421,89 @@ function ReasoningBlock({ ); } +function McpToolResultBlock({ + server, + tool, + ok, + args, + output, + showConnector, +}: { + server: string; + tool: string; + ok: boolean; + args: string; + output: string; + showConnector?: boolean; +}) { + const [expanded, setExpanded] = useState(false); + const prettyArgs = (() => { + try { + const parsed = JSON.parse(args); + return JSON.stringify(parsed, null, 2); + } catch { + return args; + } + })(); + const outputPreview = output.split("\n").slice(0, 1).join("\n"); + const outputClamped = + outputPreview.length > 160 + ? outputPreview.slice(0, 160) + "…" + : outputPreview; + return ( +
    + {showConnector && ( +
    + )} +
    +
    + +
    + {expanded && ( +
    +
    +
    + Arguments +
    +
    +                            {prettyArgs || "(none)"}
    +                        
    +
    +
    +
    + Output +
    +
    +                            {output || "(empty)"}
    +                        
    +
    +
    + )} +
    + ); +} + function DocReadBlock({ filename, onClick, @@ -1239,7 +1322,11 @@ export function AssistantMessage({
    Running - {event.name ? `${event.name}...` : "tool..."} + {event.display_name + ? `${event.display_name}...` + : event.name + ? `${event.name}...` + : "tool..."}
    ); @@ -1336,6 +1423,19 @@ export function AssistantMessage({ /> ); } + if (event.type === "mcp_tool_result") { + return ( + + ); + } return null; }; diff --git a/frontend/src/app/components/assistant/ChatInput.tsx b/frontend/src/app/components/assistant/ChatInput.tsx index 7f56192b..76256544 100644 --- a/frontend/src/app/components/assistant/ChatInput.tsx +++ b/frontend/src/app/components/assistant/ChatInput.tsx @@ -21,6 +21,7 @@ import { AddDocButton } from "./AddDocButton"; import { AddDocumentsModal } from "../shared/AddDocumentsModal"; import { AssistantWorkflowModal } from "./AssistantWorkflowModal"; import { ApiKeyMissingModal } from "../shared/ApiKeyMissingModal"; +import { McpToggleButton } from "./McpToggleButton"; import { ModelToggle } from "./ModelToggle"; import { useSelectedModel } from "@/app/hooks/useSelectedModel"; import { useUserProfile } from "@/contexts/UserProfileContext"; @@ -271,6 +272,7 @@ export const ChatInput = forwardRef(function ChatInput( )} +
    diff --git a/frontend/src/app/components/assistant/McpToggleButton.tsx b/frontend/src/app/components/assistant/McpToggleButton.tsx new file mode 100644 index 00000000..53d09b84 --- /dev/null +++ b/frontend/src/app/components/assistant/McpToggleButton.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { AlertCircle, Plug, Plus } from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + listMcpServers, + updateMcpServer, + type McpServer, +} from "@/app/lib/mikeApi"; + +/** + * Sit next to "Documents" / "Workflows" in the chat input. Opens a popover + * where the user toggles each of their configured MCP servers on/off. The + * toggle flips `enabled` on the row, which the chat backend honors at the + * start of the next request. + */ +export function McpToggleButton() { + const [servers, setServers] = useState(null); + const [open, setOpen] = useState(false); + const [busy, setBusy] = useState>({}); + + const reload = useCallback(async () => { + try { + const list = await listMcpServers(); + setServers(list); + } catch { + setServers([]); + } + }, []); + + // Refresh when the menu opens so toggles always reflect current state + // (the user may have edited servers in the settings page). + useEffect(() => { + if (open) reload(); + else if (servers === null) reload(); + }, [open, reload, servers]); + + const handleToggle = async (server: McpServer) => { + setBusy((s) => ({ ...s, [server.id]: true })); + // Optimistic flip. + setServers((prev) => + prev + ? prev.map((s) => + s.id === server.id ? { ...s, enabled: !s.enabled } : s, + ) + : prev, + ); + try { + await updateMcpServer(server.id, { enabled: !server.enabled }); + } catch { + // Revert on error. + await reload(); + } finally { + setBusy((s) => ({ ...s, [server.id]: false })); + } + }; + + // Hide the button entirely when the user has no servers configured — + // surface only emerges when there's something to toggle. + if (servers !== null && servers.length === 0) return null; + + const enabledCount = servers?.filter((s) => s.enabled).length ?? 0; + const totalCount = servers?.length ?? 0; + + return ( + + + + + + + Connectors + + + {servers?.map((s) => ( + handleToggle(s)} + /> + ))} + + + + Manage connectors + + + + ); +} + +function McpRow({ + server, + busy, + onToggle, +}: { + server: McpServer; + busy: boolean; + onToggle: () => void; +}) { + const safeName = + server.name.trim().length > 0 ? server.name.trim() : "Untitled"; + return ( + + ); +} + +function ToggleSwitch({ on }: { on: boolean }) { + return ( + + + + ); +} diff --git a/frontend/src/app/components/shared/types.ts b/frontend/src/app/components/shared/types.ts index 2fa4d6dc..475ca4f6 100644 --- a/frontend/src/app/components/shared/types.ts +++ b/frontend/src/app/components/shared/types.ts @@ -85,6 +85,8 @@ export type AssistantEvent = | { type: "tool_call_start"; name: string; + /** Friendly label (e.g. "Legal Data Hunter · search") for MCP tools. */ + display_name?: string; isStreaming?: boolean; } | { type: "thinking"; isStreaming?: boolean } @@ -112,6 +114,17 @@ export type AssistantEvent = isStreaming?: boolean; } | { type: "doc_download"; filename: string; download_url: string } + | { + type: "mcp_tool_result"; + server: string; + tool: string; + ok: boolean; + /** JSON-stringified args (capped server-side). */ + args: string; + /** Tool output text (capped server-side). */ + output: string; + isStreaming?: boolean; + } | { type: "doc_replicated"; /** Source document filename. */ diff --git a/frontend/src/app/hooks/useAssistantChat.ts b/frontend/src/app/hooks/useAssistantChat.ts index fa82ef40..0e04ecf7 100644 --- a/frontend/src/app/hooks/useAssistantChat.ts +++ b/frontend/src/app/hooks/useAssistantChat.ts @@ -534,6 +534,9 @@ export function useAssistantChat({ pushEvent({ type: "tool_call_start", name: (data.name as string) ?? "", + display_name: + (data.display_name as string | undefined) ?? + undefined, isStreaming: true, }); continue; @@ -756,6 +759,19 @@ export function useAssistantChat({ continue; } + if (data.type === "mcp_tool_result") { + pushEvent({ + type: "mcp_tool_result", + server: (data.server as string) ?? "", + tool: (data.tool as string) ?? "", + ok: data.ok !== false, + args: (data.args as string) ?? "", + output: (data.output as string) ?? "", + }); + pushThinkingPlaceholder(); + continue; + } + if (data.type === "citations") { // End-of-stream signal — scrub any lingering // placeholders so they don't persist into the From 52749e6e67d1190959fb5072e92000247f368dfd Mon Sep 17 00:00:00 2001 From: Zacharie Laik Date: Tue, 5 May 2026 10:15:49 +0200 Subject: [PATCH 3/3] feat(mcp): OAuth 2.1 sign-in for connectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds OAuth 2.1 (RFC 9728 discovery + RFC 7591 dynamic client registration + PKCE) so spec-conformant MCP servers like https://legaldatahunter.com/mcp work without the user pasting any token. The MCP TypeScript SDK does almost all the heavy lifting via its `auth()` helper — discovery, DCR, PKCE, code exchange, refresh. We only have to plug in an OAuthClientProvider whose getters/setters read and write the row's oauth_* columns, plus an HMAC-signed state token so the popup callback can look the row up without a server-side session. DB - migration 002 + inline patch to the one-shot: alter table user_mcp_servers add auth_type ('headers'|'oauth' default 'headers'), add oauth_metadata jsonb, add oauth_tokens jsonb, add oauth_code_verifier text; Backend - New `lib/mcp/oauth.ts`: - `DbOAuthProvider` implements OAuthClientProvider, persists everything on the user_mcp_servers row. - "initiate" mode (used by /oauth/start) captures the authorize URL into a property so the route can return it for the popup; "use" mode (used by chat) throws ReauthRequiredError when the SDK wants the user back, so the caller can mark the row reauth_required. - signOAuthState/verifyOAuthState — HMAC over user_id+server_id (5 min TTL) reusing DOWNLOAD_SIGNING_SECRET. No DB round-trip on callback. - `lib/mcp/client.ts`: accepts an optional authProvider passed through to StreamableHTTPClientTransport — the SDK auto-attaches Authorization headers and auto-refreshes on 401. - `lib/mcp/servers.ts`: builds a DbOAuthProvider for OAuth rows that have tokens; rows without tokens are skipped (UI surfaces a "Sign in" button in settings instead). - New `routes/mcpOauth.ts` mounted at /mcp/oauth: public callback that verifies state, finishes the SDK auth() flow, and returns a small HTML page that postMessage()s the opener and closes the popup. - `routes/mcpServers.ts`: - POST /:id/oauth/start kicks off discovery + DCR via the SDK and returns { authorize_url } for the frontend popup. - POST creates honor `auth_type`; PATCH/test/list now project + return auth_type and a boolean oauth_authorized (the access_token itself never round-trips to the browser). - `BACKEND_PUBLIC_URL` env var (defaults to http://localhost:${PORT}) used to build the OAuth redirect URI; documented in `.env.example`. Frontend - `account/mcp/page.tsx`: - Authentication mode radio in the Add form: "API key / headers" vs "OAuth (auto-discover)". Headers section hides itself in OAuth mode. - Save button label switches to "Save & sign in" for OAuth, which immediately opens the authorize popup. The page polls listMcpServers until oauth_authorized flips, then auto-runs tool discovery. - Per-card status pills: "OAuth · signed in" (blue) / "OAuth · sign-in required" (amber). Cards in the latter state show a "Sign in" button instead of "Test". - Simplified copy per user feedback: dropped the OAuth explainer block, redundant "By saving..." trust pill, and helper text under Name and URL inputs. Single load-bearing trust warning at top of page remains. - `mikeApi.ts`: `startMcpOauth(id)` wrapper. Security notes for reviewers - access_token / refresh_token / oauth_metadata are stored at-rest in jsonb (RLS owner-only). Per-row encryption deferred to a separate hardening PR — matches existing precedent for user_profiles.{claude, gemini}_api_key. - State token is HMAC-signed with DOWNLOAD_SIGNING_SECRET, 5 min TTL, carries user_id + server_id only. CSRF-safe across the popup hop with no server-side session needed. - Public client (token_endpoint_auth_method=none, PKCE-protected) — no client secret needed for confidential storage. --- backend/.env.example | 3 + backend/migrations/000_one_shot_schema.sql | 5 + .../migrations/002_user_mcp_servers_oauth.sql | 16 + backend/src/index.ts | 2 + backend/src/lib/mcp/client.ts | 3 + backend/src/lib/mcp/oauth.ts | 288 ++++++++++++++++++ backend/src/lib/mcp/servers.ts | 42 ++- backend/src/lib/mcp/types.ts | 4 + backend/src/routes/mcpOauth.ts | 115 +++++++ backend/src/routes/mcpServers.ts | 102 ++++++- frontend/src/app/(pages)/account/mcp/page.tsx | 191 ++++++++++-- frontend/src/app/lib/mikeApi.ts | 11 + 12 files changed, 734 insertions(+), 48 deletions(-) create mode 100644 backend/migrations/002_user_mcp_servers_oauth.sql create mode 100644 backend/src/lib/mcp/oauth.ts create mode 100644 backend/src/routes/mcpOauth.ts diff --git a/backend/.env.example b/backend/.env.example index 1db370a9..9c99c871 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,5 +1,8 @@ PORT=3001 FRONTEND_URL=http://localhost:3000 +# Externally-reachable backend URL. Used to build the OAuth callback URL for +# MCP connectors. Defaults to http://localhost:${PORT} when unset. +BACKEND_PUBLIC_URL=http://localhost:3001 SUPABASE_URL=https://your-project.supabase.co SUPABASE_SECRET_KEY=your-supabase-service-role-key diff --git a/backend/migrations/000_one_shot_schema.sql b/backend/migrations/000_one_shot_schema.sql index 9828d65b..54a7b095 100644 --- a/backend/migrations/000_one_shot_schema.sql +++ b/backend/migrations/000_one_shot_schema.sql @@ -285,6 +285,11 @@ create table if not exists public.user_mcp_servers ( headers jsonb not null default '{}'::jsonb, enabled boolean not null default true, last_error text, + auth_type text not null default 'headers' + check (auth_type in ('headers', 'oauth')), + oauth_metadata jsonb, + oauth_tokens jsonb, + oauth_code_verifier text, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), constraint user_mcp_servers_slug_format diff --git a/backend/migrations/002_user_mcp_servers_oauth.sql b/backend/migrations/002_user_mcp_servers_oauth.sql new file mode 100644 index 00000000..b53b75da --- /dev/null +++ b/backend/migrations/002_user_mcp_servers_oauth.sql @@ -0,0 +1,16 @@ +-- OAuth 2.1 support for user-configured MCP connectors. +-- Adds auth-mode toggle + storage for the discovered authorization-server +-- metadata, the dynamically-registered client info, the access/refresh +-- tokens, and the (transient) PKCE verifier between /oauth/start and +-- /oauth/callback. +-- +-- Tokens are stored at-rest in jsonb (RLS owner-only). Per-row encryption +-- is intentionally deferred to a separate hardening PR — this matches the +-- existing precedent for user_profiles.{claude,gemini}_api_key. + +alter table public.user_mcp_servers + add column if not exists auth_type text not null default 'headers' + check (auth_type in ('headers', 'oauth')), + add column if not exists oauth_metadata jsonb, + add column if not exists oauth_tokens jsonb, + add column if not exists oauth_code_verifier text; diff --git a/backend/src/index.ts b/backend/src/index.ts index b704edc1..4f4d599a 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -10,6 +10,7 @@ import { workflowsRouter } from "./routes/workflows"; import { userRouter } from "./routes/user"; import { downloadsRouter } from "./routes/downloads"; import { mcpServersRouter } from "./routes/mcpServers"; +import { mcpOauthRouter } from "./routes/mcpOauth"; const app = express(); const PORT = process.env.PORT ?? 3001; @@ -33,6 +34,7 @@ app.use("/user", userRouter); app.use("/users", userRouter); app.use("/download", downloadsRouter); app.use("/user/mcp-servers", mcpServersRouter); +app.use("/mcp/oauth", mcpOauthRouter); app.get("/health", (_req, res) => res.json({ ok: true })); diff --git a/backend/src/lib/mcp/client.ts b/backend/src/lib/mcp/client.ts index a819f717..897d606d 100644 --- a/backend/src/lib/mcp/client.ts +++ b/backend/src/lib/mcp/client.ts @@ -8,6 +8,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; import type { Tool } from "@modelcontextprotocol/sdk/types.js"; const CONNECT_TIMEOUT_MS = 10_000; @@ -20,6 +21,7 @@ export class McpHttpClient { constructor( private readonly url: string, private readonly headers: Record, + private readonly authProvider?: OAuthClientProvider, ) {} async connect(): Promise { @@ -27,6 +29,7 @@ export class McpHttpClient { requestInit: { headers: this.headers, }, + ...(this.authProvider ? { authProvider: this.authProvider } : {}), }); this.client = new Client( { name: "mike", version: "1.0.0" }, diff --git a/backend/src/lib/mcp/oauth.ts b/backend/src/lib/mcp/oauth.ts new file mode 100644 index 00000000..61e41a31 --- /dev/null +++ b/backend/src/lib/mcp/oauth.ts @@ -0,0 +1,288 @@ +// OAuth 2.1 client glue for MCP connectors. +// +// The MCP SDK does almost all of the heavy lifting via its `auth()` helper — +// RFC 9728 discovery, dynamic client registration (RFC 7591), PKCE (S256), +// authorization-code exchange, and token refresh. We only have to plug in an +// `OAuthClientProvider` whose getters/setters read and write the row's +// oauth_* columns, plus a thin HMAC-signed state token so the callback can +// look the row up without a server-side session. + +import crypto from "crypto"; +import type { + OAuthClientInformationFull, + OAuthClientInformationMixed, + OAuthClientMetadata, + OAuthTokens, +} from "@modelcontextprotocol/sdk/shared/auth.js"; +import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; +import type { createServerSupabase } from "../supabase"; + +const STATE_TTL_SECONDS = 5 * 60; // 5 minutes +const CLIENT_NAME = "Mike"; +const CLIENT_URI = "https://github.com/willchen96/mike"; + +function backendPublicUrl(): string { + const url = process.env.BACKEND_PUBLIC_URL?.trim(); + if (url) return url.replace(/\/+$/, ""); + const port = process.env.PORT ?? "3001"; + return `http://localhost:${port}`; +} + +export function oauthCallbackUrl(): string { + return `${backendPublicUrl()}/mcp/oauth/callback`; +} + +// --------------------------------------------------------------------------- +// State token (CSRF + flow continuation across the popup hop). +// HMAC-signed, no DB round-trip; encodes { user_id, server_id, exp }. +// Reuses DOWNLOAD_SIGNING_SECRET — the same secret already gates download +// tokens and would already have to be rotated on compromise. +// --------------------------------------------------------------------------- + +function getSecret(): string { + return ( + process.env.DOWNLOAD_SIGNING_SECRET ?? + process.env.SUPABASE_SECRET_KEY ?? + "dev-secret" + ); +} + +function b64url(buf: Buffer): string { + return buf + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); +} + +function b64urlDecode(s: string): Buffer { + let t = s.replace(/-/g, "+").replace(/_/g, "/"); + while (t.length % 4) t += "="; + return Buffer.from(t, "base64"); +} + +export function signOAuthState(payload: { + user_id: string; + server_id: string; +}): string { + const body = { + ...payload, + exp: Math.floor(Date.now() / 1000) + STATE_TTL_SECONDS, + }; + const enc = b64url(Buffer.from(JSON.stringify(body), "utf8")); + const sig = crypto.createHmac("sha256", getSecret()).update(enc).digest(); + return `${enc}.${b64url(sig)}`; +} + +export function verifyOAuthState( + token: string, +): { user_id: string; server_id: string } | null { + const parts = token.split("."); + if (parts.length !== 2) return null; + const [enc, sigEnc] = parts; + const expected = crypto + .createHmac("sha256", getSecret()) + .update(enc) + .digest(); + const expectedEnc = b64url(expected); + if (sigEnc.length !== expectedEnc.length) return null; + if ( + !crypto.timingSafeEqual(Buffer.from(sigEnc), Buffer.from(expectedEnc)) + ) { + return null; + } + try { + const body = JSON.parse(b64urlDecode(enc).toString("utf8")) as { + user_id: string; + server_id: string; + exp: number; + }; + if (!body.user_id || !body.server_id) return null; + if (Math.floor(Date.now() / 1000) > body.exp) return null; + return { user_id: body.user_id, server_id: body.server_id }; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// OAuthClientProvider implementation backed by user_mcp_servers row. +// +// Two modes: +// - "initiate": from POST /oauth/start. The SDK's auth() will discover, DCR +// if needed, generate PKCE, and call redirectToAuthorization() with the +// authorize URL. We capture that URL into `lastAuthorizeUrl` and the +// route returns it to the frontend popup. +// - "use": from a chat request. If the SDK needs a fresh authorization +// (refresh failed or never happened), redirectToAuthorization() throws +// so the caller can mark the row reauth_required and surface to the UI. +// --------------------------------------------------------------------------- + +export type OAuthProviderMode = "initiate" | "use"; + +export class DbOAuthProvider implements OAuthClientProvider { + private metadataCache: Record | null = null; + private tokensCache: OAuthTokens | null = null; + private codeVerifierCache: string | null = null; + private mode: OAuthProviderMode; + private signedState: string; + + /** Set by redirectToAuthorization() in `initiate` mode. */ + public lastAuthorizeUrl: URL | null = null; + + constructor( + private readonly db: ReturnType, + private readonly serverId: string, + private readonly userId: string, + mode: OAuthProviderMode, + ) { + this.mode = mode; + this.signedState = signOAuthState({ + user_id: userId, + server_id: serverId, + }); + } + + get redirectUrl(): string { + return oauthCallbackUrl(); + } + + get clientMetadata(): OAuthClientMetadata { + return { + client_name: CLIENT_NAME, + client_uri: CLIENT_URI, + redirect_uris: [oauthCallbackUrl()], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + // Public client — no client secret stored, PKCE-protected. + token_endpoint_auth_method: "none", + }; + } + + state(): string { + return this.signedState; + } + + async clientInformation(): Promise< + OAuthClientInformationMixed | undefined + > { + await this.loadMetadata(); + const ci = (this.metadataCache as { client?: OAuthClientInformationFull } | null) + ?.client; + return ci ?? undefined; + } + + async saveClientInformation( + info: OAuthClientInformationMixed, + ): Promise { + await this.loadMetadata(); + const next = { ...(this.metadataCache ?? {}), client: info }; + this.metadataCache = next; + await this.db + .from("user_mcp_servers") + .update({ oauth_metadata: next }) + .eq("id", this.serverId); + } + + async tokens(): Promise { + if (this.tokensCache) return this.tokensCache; + const { data } = await this.db + .from("user_mcp_servers") + .select("oauth_tokens") + .eq("id", this.serverId) + .single(); + const t = (data?.oauth_tokens ?? null) as OAuthTokens | null; + this.tokensCache = t; + return t ?? undefined; + } + + async saveTokens(tokens: OAuthTokens): Promise { + this.tokensCache = tokens; + // The PKCE verifier is one-shot; no point persisting after token + // exchange. Clearing also keeps the row tidy on subsequent refreshes. + this.codeVerifierCache = null; + await this.db + .from("user_mcp_servers") + .update({ + oauth_tokens: tokens, + oauth_code_verifier: null, + last_error: null, + }) + .eq("id", this.serverId); + } + + async redirectToAuthorization(authorizationUrl: URL): Promise { + if (this.mode === "initiate") { + this.lastAuthorizeUrl = authorizationUrl; + return; + } + // In "use" mode (mid-chat), we have nowhere to redirect the user; the + // caller will catch this and mark the row reauth_required so the UI + // prompts the user to re-sign in from settings. + throw new ReauthRequiredError( + `Connector requires re-sign-in (would redirect to ${authorizationUrl.origin})`, + ); + } + + async saveCodeVerifier(codeVerifier: string): Promise { + this.codeVerifierCache = codeVerifier; + await this.db + .from("user_mcp_servers") + .update({ oauth_code_verifier: codeVerifier }) + .eq("id", this.serverId); + } + + async codeVerifier(): Promise { + if (this.codeVerifierCache) return this.codeVerifierCache; + const { data } = await this.db + .from("user_mcp_servers") + .select("oauth_code_verifier") + .eq("id", this.serverId) + .single(); + if (!data?.oauth_code_verifier) { + throw new Error("Missing PKCE verifier — start the flow again"); + } + this.codeVerifierCache = data.oauth_code_verifier; + return data.oauth_code_verifier; + } + + async invalidateCredentials( + scope: "all" | "client" | "tokens" | "verifier" | "discovery", + ): Promise { + const update: Record = {}; + if (scope === "all" || scope === "tokens") + update.oauth_tokens = null; + if (scope === "all" || scope === "client" || scope === "discovery") + update.oauth_metadata = null; + if (scope === "all" || scope === "verifier") + update.oauth_code_verifier = null; + if (Object.keys(update).length === 0) return; + await this.db + .from("user_mcp_servers") + .update(update) + .eq("id", this.serverId); + if (update.oauth_tokens === null) this.tokensCache = null; + if (update.oauth_metadata === null) this.metadataCache = null; + if (update.oauth_code_verifier === null) this.codeVerifierCache = null; + } + + private async loadMetadata(): Promise { + if (this.metadataCache !== null) return; + const { data } = await this.db + .from("user_mcp_servers") + .select("oauth_metadata") + .eq("id", this.serverId) + .single(); + this.metadataCache = (data?.oauth_metadata ?? {}) as Record< + string, + unknown + >; + } +} + +export class ReauthRequiredError extends Error { + constructor(message?: string) { + super(message ?? "Re-authorization required"); + this.name = "ReauthRequiredError"; + } +} diff --git a/backend/src/lib/mcp/servers.ts b/backend/src/lib/mcp/servers.ts index 2c323e0b..5b36a0d4 100644 --- a/backend/src/lib/mcp/servers.ts +++ b/backend/src/lib/mcp/servers.ts @@ -10,6 +10,7 @@ import { createHash } from "crypto"; import type { OpenAIToolSchema } from "../llm/types"; import type { createServerSupabase } from "../supabase"; import { McpHttpClient } from "./client"; +import { DbOAuthProvider, ReauthRequiredError } from "./oauth"; import type { LoadedMcpServer, McpServerRow } from "./types"; const TOOL_NAME_MAX = 64; @@ -27,7 +28,9 @@ export async function loadEnabledMcpServersForUser( if (error || !data || data.length === 0) return []; const rows = data as McpServerRow[]; - const results = await Promise.allSettled(rows.map(loadOne)); + const results = await Promise.allSettled( + rows.map((row) => loadOne(row, userId, db)), + ); const out: LoadedMcpServer[] = []; for (let i = 0; i < results.length; i++) { @@ -43,26 +46,47 @@ export async function loadEnabledMcpServersForUser( .eq("id", row.id); } } else { + const reason = r.status === "rejected" ? r.reason : "unknown error"; + const isReauth = reason instanceof ReauthRequiredError; const err = - r.status === "rejected" - ? r.reason instanceof Error - ? r.reason.message - : String(r.reason) - : "unknown error"; + reason instanceof Error ? reason.message : String(reason); console.warn( `[mcp] failed to load server ${row.slug} (${row.url}): ${err}`, ); await db .from("user_mcp_servers") - .update({ last_error: err.slice(0, 1000) }) + .update({ + last_error: isReauth + ? "reauth_required" + : err.slice(0, 1000), + }) .eq("id", row.id); } } return out; } -async function loadOne(row: McpServerRow): Promise { - const client = new McpHttpClient(row.url, row.headers ?? {}); +async function loadOne( + row: McpServerRow, + userId: string, + db: ReturnType, +): Promise { + let authProvider: DbOAuthProvider | undefined; + if (row.auth_type === "oauth") { + // No tokens yet → don't even try to connect; the UI will surface a + // "Sign in" affordance and the user kicks off /oauth/start. + if (!row.oauth_tokens) { + throw new ReauthRequiredError( + "Connector not yet authorized — sign in from settings", + ); + } + authProvider = new DbOAuthProvider(db, row.id, userId, "use"); + } + const client = new McpHttpClient( + row.url, + row.headers ?? {}, + authProvider, + ); await client.connect(); const mcpTools = await client.listTools(); diff --git a/backend/src/lib/mcp/types.ts b/backend/src/lib/mcp/types.ts index 1ee8f47b..c347ddfa 100644 --- a/backend/src/lib/mcp/types.ts +++ b/backend/src/lib/mcp/types.ts @@ -9,6 +9,10 @@ export type McpServerRow = { headers: Record; enabled: boolean; last_error: string | null; + auth_type: "headers" | "oauth"; + oauth_metadata: Record | null; + oauth_tokens: Record | null; + oauth_code_verifier: string | null; }; /** diff --git a/backend/src/routes/mcpOauth.ts b/backend/src/routes/mcpOauth.ts new file mode 100644 index 00000000..cec60239 --- /dev/null +++ b/backend/src/routes/mcpOauth.ts @@ -0,0 +1,115 @@ +// Unauthenticated OAuth callback for MCP connectors. +// +// The user is bounced here from the connector's authorization server with +// `?code=...&state=...` after consenting in the popup. We don't have an +// auth header here (different origin / popup context), so the route is +// public — the HMAC-signed `state` token carries the user_id + server_id +// we need to find the row. + +import { Router } from "express"; +import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; +import { createServerSupabase } from "../lib/supabase"; +import { DbOAuthProvider, verifyOAuthState } from "../lib/mcp/oauth"; + +export const mcpOauthRouter = Router(); + +const RESULT_HTML = (success: boolean, message?: string) => ` + + + + ${success ? "Connector connected" : "Connector failed"} + + + +

    ${ + success ? "✓ Connector connected" : "✗ Connection failed" + }

    +

    ${ + success + ? "You can close this window and return to Mike." + : (message ?? "Something went wrong. Close this window and try again.") + }

    + + +`; + +mcpOauthRouter.get("/callback", async (req, res) => { + const code = (req.query.code as string | undefined)?.trim(); + const state = (req.query.state as string | undefined)?.trim(); + const error = (req.query.error as string | undefined)?.trim(); + + if (error) { + return void res + .status(400) + .type("html") + .send(RESULT_HTML(false, `Authorization server returned: ${error}`)); + } + if (!code || !state) { + return void res + .status(400) + .type("html") + .send(RESULT_HTML(false, "Missing code or state.")); + } + + const decoded = verifyOAuthState(state); + if (!decoded) { + return void res + .status(400) + .type("html") + .send(RESULT_HTML(false, "Invalid or expired state — restart sign-in.")); + } + + const db = createServerSupabase(); + const { data: row, error: fetchErr } = await db + .from("user_mcp_servers") + .select("id, user_id, url, auth_type") + .eq("id", decoded.server_id) + .eq("user_id", decoded.user_id) + .single(); + if (fetchErr || !row) { + return void res + .status(404) + .type("html") + .send(RESULT_HTML(false, "Connector not found.")); + } + if (row.auth_type !== "oauth") { + return void res + .status(400) + .type("html") + .send(RESULT_HTML(false, "Connector is not configured for OAuth.")); + } + + const provider = new DbOAuthProvider(db, row.id, row.user_id, "initiate"); + try { + const result = await auth(provider, { + serverUrl: row.url, + authorizationCode: code, + }); + if (result !== "AUTHORIZED") { + throw new Error(`Token exchange returned ${result}`); + } + // saveTokens() ran inside auth() and cleared last_error. + return void res.type("html").send(RESULT_HTML(true)); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await db + .from("user_mcp_servers") + .update({ last_error: message.slice(0, 1000) }) + .eq("id", row.id); + return void res + .status(500) + .type("html") + .send(RESULT_HTML(false, message)); + } +}); diff --git a/backend/src/routes/mcpServers.ts b/backend/src/routes/mcpServers.ts index 9826058b..6ab9fdbb 100644 --- a/backend/src/routes/mcpServers.ts +++ b/backend/src/routes/mcpServers.ts @@ -4,9 +4,11 @@ // (bypassing RLS), so every handler MUST filter by `user_id = userId`. import { Router } from "express"; +import { auth as runOAuth } from "@modelcontextprotocol/sdk/client/auth.js"; import { requireAuth } from "../middleware/auth"; import { createServerSupabase } from "../lib/supabase"; import { McpHttpClient } from "../lib/mcp/client"; +import { DbOAuthProvider } from "../lib/mcp/oauth"; export const mcpServersRouter = Router(); @@ -23,6 +25,7 @@ type Body = { url?: unknown; headers?: unknown; enabled?: unknown; + auth_type?: unknown; }; function deriveSlug(name: string): string { @@ -77,10 +80,24 @@ function validateHeaders( } function publicShape>(row: T) { - const { headers, ...rest } = row as T & { headers?: Record }; + const { + headers, + oauth_metadata: _md, + oauth_tokens: tokens, + oauth_code_verifier: _cv, + ...rest + } = row as T & { + headers?: Record; + oauth_metadata?: unknown; + oauth_tokens?: unknown; + oauth_code_verifier?: unknown; + }; return { ...rest, header_keys: headers ? Object.keys(headers) : [], + // Boolean only — never round-trip the actual access token to the + // browser, even to the row's owner. + oauth_authorized: !!tokens, }; } @@ -90,7 +107,7 @@ mcpServersRouter.get("/", requireAuth, async (_req, res) => { const db = createServerSupabase(); const { data, error } = await db .from("user_mcp_servers") - .select("id, slug, name, url, headers, enabled, last_error, created_at, updated_at") + .select("id, slug, name, url, headers, enabled, last_error, auth_type, oauth_tokens, created_at, updated_at") .eq("user_id", userId) .order("created_at", { ascending: true }); if (error) return void res.status(500).json({ detail: error.message }); @@ -123,6 +140,9 @@ mcpServersRouter.post("/", requireAuth, async (req, res) => { const headersOk = validateHeaders(body.headers); if (!headersOk.ok) return void res.status(400).json({ detail: headersOk.error }); + const auth_type = + body.auth_type === "oauth" ? "oauth" : "headers"; + const enabled = body.enabled === false ? false : true; const db = createServerSupabase(); @@ -133,10 +153,11 @@ mcpServersRouter.post("/", requireAuth, async (req, res) => { slug, name, url, - headers: headersOk.value, + headers: auth_type === "oauth" ? {} : headersOk.value, enabled, + auth_type, }) - .select("id, slug, name, url, headers, enabled, last_error, created_at, updated_at") + .select("id, slug, name, url, headers, enabled, last_error, auth_type, oauth_tokens, created_at, updated_at") .single(); if (error) { const status = error.code === "23505" ? 409 : 500; @@ -181,7 +202,7 @@ mcpServersRouter.patch("/:id", requireAuth, async (req, res) => { .update(update) .eq("id", id) .eq("user_id", userId) - .select("id, slug, name, url, headers, enabled, last_error, created_at, updated_at") + .select("id, slug, name, url, headers, enabled, last_error, auth_type, oauth_tokens, created_at, updated_at") .single(); if (error || !data) { return void res.status(404).json({ detail: error?.message ?? "Not found" }); @@ -210,7 +231,7 @@ mcpServersRouter.post("/:id/test", requireAuth, async (req, res) => { const db = createServerSupabase(); const { data: row, error } = await db .from("user_mcp_servers") - .select("url, headers") + .select("url, headers, auth_type, oauth_tokens") .eq("id", id) .eq("user_id", userId) .single(); @@ -218,7 +239,22 @@ mcpServersRouter.post("/:id/test", requireAuth, async (req, res) => { return void res.status(404).json({ detail: "Not found" }); } - const client = new McpHttpClient(row.url, (row.headers ?? {}) as Record); + if (row.auth_type === "oauth" && !row.oauth_tokens) { + return void res.status(200).json({ + ok: false, + error: "Connector is configured for OAuth but not yet signed in.", + }); + } + + const provider = + row.auth_type === "oauth" + ? new DbOAuthProvider(db, id, userId, "use") + : undefined; + const client = new McpHttpClient( + row.url, + (row.headers ?? {}) as Record, + provider, + ); try { await client.connect(); const tools = await client.listTools(); @@ -242,3 +278,55 @@ mcpServersRouter.post("/:id/test", requireAuth, async (req, res) => { await client.close(); } }); + +// POST /user/mcp-servers/:id/oauth/start — discover + DCR + build authorize URL +// +// Returns { authorize_url } so the frontend can open it in a popup. The user +// completes consent at the connector's auth server and is redirected back to +// /mcp/oauth/callback (mounted under mcpOauthRouter), which exchanges the +// code and stores tokens. +mcpServersRouter.post("/:id/oauth/start", requireAuth, async (req, res) => { + const userId = res.locals.userId as string; + const { id } = req.params; + const db = createServerSupabase(); + const { data: row, error } = await db + .from("user_mcp_servers") + .select("id, user_id, url, auth_type") + .eq("id", id) + .eq("user_id", userId) + .single(); + if (error || !row) return void res.status(404).json({ detail: "Not found" }); + if (row.auth_type !== "oauth") { + return void res + .status(400) + .json({ detail: "Connector is not configured for OAuth" }); + } + + const provider = new DbOAuthProvider(db, row.id, userId, "initiate"); + try { + const result = await runOAuth(provider, { serverUrl: row.url }); + if (result === "AUTHORIZED") { + // Already valid (e.g. row had a usable refresh token). Nothing + // for the user to do. + return void res.json({ + authorize_url: null, + already_authorized: true, + }); + } + if (!provider.lastAuthorizeUrl) { + throw new Error("Auth flow returned REDIRECT but no URL"); + } + await db + .from("user_mcp_servers") + .update({ last_error: null }) + .eq("id", id); + res.json({ authorize_url: provider.lastAuthorizeUrl.toString() }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await db + .from("user_mcp_servers") + .update({ last_error: message.slice(0, 1000) }) + .eq("id", id); + res.status(500).json({ detail: message }); + } +}); diff --git a/frontend/src/app/(pages)/account/mcp/page.tsx b/frontend/src/app/(pages)/account/mcp/page.tsx index bd3d063c..13d3ef60 100644 --- a/frontend/src/app/(pages)/account/mcp/page.tsx +++ b/frontend/src/app/(pages)/account/mcp/page.tsx @@ -15,6 +15,7 @@ import { createMcpServer, deleteMcpServer, listMcpServers, + startMcpOauth, testMcpServer, updateMcpServer, type McpServer, @@ -27,12 +28,14 @@ type Draft = { name: string; url: string; headers: DraftHeader[]; + auth_type: "headers" | "oauth"; }; const EMPTY_DRAFT: Draft = { name: "", url: "", headers: [{ key: "", value: "" }], + auth_type: "headers", }; export default function McpServersPage() { @@ -82,14 +85,24 @@ export default function McpServersPage() { } setSaving(true); try { - const created = await createMcpServer({ name, url, headers }); + const created = await createMcpServer({ + name, + url, + headers: draft.auth_type === "oauth" ? {} : headers, + auth_type: draft.auth_type, + }); setDraft(EMPTY_DRAFT); setShowAdd(false); await reload(); - // Auto-discover tools so the user sees the tool list right away - // without an extra Test click. Errors surface inline via the - // server card's last_error / testResults render. - void runAutoTest(created.id); + if (draft.auth_type === "oauth") { + // Discovery + sign-in needs user interaction. Kick the popup + // immediately so it feels like one continuous flow. + void launchOAuth(created.id); + } else { + // Auto-discover tools so the user sees the tool list right + // away without an extra Test click. + void runAutoTest(created.id); + } } catch (err) { setAddError(err instanceof Error ? err.message : "Failed to save"); } finally { @@ -97,6 +110,60 @@ export default function McpServersPage() { } }; + const launchOAuth = async (id: string) => { + try { + const { authorize_url, already_authorized } = await startMcpOauth(id); + if (already_authorized) { + await reload(); + void runAutoTest(id); + return; + } + if (!authorize_url) { + alert("Authorization server did not return a URL."); + return; + } + const popup = window.open( + authorize_url, + "mcp_oauth", + "width=600,height=720,menubar=no,toolbar=no,location=no", + ); + if (!popup) { + alert( + "Couldn't open the sign-in window — check your popup blocker.", + ); + return; + } + // Poll the row until tokens are saved, or until the popup closes + // unfinished. Stop after 5 minutes regardless. + const deadline = Date.now() + 5 * 60 * 1000; + const interval = setInterval(async () => { + try { + const list = await listMcpServers(); + const row = list.find((s) => s.id === id); + if (row?.oauth_authorized) { + clearInterval(interval); + try { + popup.close(); + } catch { + /* ignore */ + } + setServers(list); + void runAutoTest(id); + return; + } + } catch { + /* ignore transient errors */ + } + if (popup.closed || Date.now() > deadline) { + clearInterval(interval); + await reload(); + } + }, 1500); + } catch (err) { + alert(err instanceof Error ? err.message : "Sign-in failed"); + } + }; + const runAutoTest = async (id: string) => { setTesting((s) => ({ ...s, [id]: true })); try { @@ -259,6 +326,7 @@ export default function McpServersPage() { onToggle={() => handleToggleEnabled(s)} onDelete={() => handleDelete(s)} onTest={() => handleTest(s)} + onSignIn={() => launchOAuth(s.id)} /> ))}
    @@ -308,11 +376,6 @@ function AddForm({ setDraft({ ...draft, name: e.target.value }) } /> -

    - Shown in chat when the assistant calls a tool. Don’t - paste tokens here — use the Headers section below - for credentials. -

    @@ -323,11 +386,49 @@ function AddForm({ setDraft({ ...draft, url: e.target.value }) } /> -

    - Streamable-HTTP MCP endpoint. Must be HTTPS (or - http://localhost for local testing). -

    +
    + +
    + + +
    +
    + {draft.auth_type === "headers" && (
    + )} {error && (
    {error}
    )} -

    - By saving, you confirm you trust this server’s operator - with anything Mike sends to it during tool calls. -

    @@ -454,6 +554,7 @@ function ServerCard({ onToggle, onDelete, onTest, + onSignIn, }: { server: McpServer; testing: boolean; @@ -461,10 +562,13 @@ function ServerCard({ onToggle: () => void; onDelete: () => void; onTest: () => void; + onSignIn: () => void; }) { const [showDetails, setShowDetails] = useState(false); const displayName = safeServerName(server.name); const nameWasSanitized = displayName !== server.name.trim(); + const needsSignIn = + server.auth_type === "oauth" && !server.oauth_authorized; return (
    @@ -475,6 +579,19 @@ function ServerCard({

    {displayName}

    + {server.auth_type === "oauth" && ( + + {server.oauth_authorized + ? "OAuth · signed in" + : "OAuth · sign-in required"} + + )} {server.enabled ? ( @@ -486,7 +603,7 @@ function ServerCard({ Disabled )} - {server.last_error && ( + {server.last_error && server.last_error !== "reauth_required" && ( Error @@ -522,18 +639,28 @@ function ServerCard({ )}
    - + {needsSignIn ? ( + + ) : ( + + )} diff --git a/frontend/src/app/lib/mikeApi.ts b/frontend/src/app/lib/mikeApi.ts index 73ec8dc0..abda6082 100644 --- a/frontend/src/app/lib/mikeApi.ts +++ b/frontend/src/app/lib/mikeApi.ts @@ -828,6 +828,8 @@ export interface McpServer { header_keys: string[]; enabled: boolean; last_error: string | null; + auth_type: "headers" | "oauth"; + oauth_authorized: boolean; created_at: string; updated_at: string; } @@ -849,6 +851,7 @@ export async function createMcpServer(payload: { slug?: string; headers?: Record; enabled?: boolean; + auth_type?: "headers" | "oauth"; }): Promise { return apiRequest("/user/mcp-servers", { method: "POST", @@ -857,6 +860,14 @@ export async function createMcpServer(payload: { }); } +export async function startMcpOauth( + id: string, +): Promise<{ authorize_url: string | null; already_authorized?: boolean }> { + return apiRequest(`/user/mcp-servers/${id}/oauth/start`, { + method: "POST", + }); +} + export async function updateMcpServer( id: string, payload: {