From 379a8b1e45c37aade6a8332d4ab1c360c959fe58 Mon Sep 17 00:00:00 2001 From: Jayson Jacobs Date: Sun, 22 Mar 2026 01:33:05 -0600 Subject: [PATCH 1/2] updates for external oauth mcp, including DCR --- README.md | 271 +++- package.json | 8 +- src/controllers/mcp.ts | 293 +++- src/index.ts | 25 +- src/services/dcrClients.ts | 347 ++++ src/services/mcp.ts | 2387 ++++++++++++++++++++++++++-- src/services/mcpErrors.ts | 169 ++ src/services/oauthTokens.ts | 258 ++- src/services/packages.ts | 6 +- src/services/secrets.ts | 58 + src/services/userServerInstalls.ts | 113 ++ src/services/userSessions.ts | 66 + src/utils/mongodb.ts | 6 +- src/utils/ssrf.ts | 93 ++ test/mcp-external-auth.spec.ts | 322 ++++ 15 files changed, 4199 insertions(+), 223 deletions(-) create mode 100644 src/services/dcrClients.ts create mode 100644 src/services/mcpErrors.ts create mode 100644 src/services/userServerInstalls.ts create mode 100644 src/services/userSessions.ts create mode 100644 src/utils/ssrf.ts create mode 100644 test/mcp-external-auth.spec.ts diff --git a/README.md b/README.md index 279a699..9a7c21d 100644 --- a/README.md +++ b/README.md @@ -124,24 +124,39 @@ For Python MCP servers that require Python 3.13+ (for example `klaviyo-mcp-serve POST /packages/install ``` -Request body: +**Node.js stdio** request body: ```json { "name": "package-name", - "version": "1.0.0", // Optional, defaults to "latest" + "version": "1.0.0", "serverName": "unique-server-name", "transportType": "stdio", - "command": "node", // Optional, auto-detected if not provided - "args": ["--option1", "--option2"], // Optional + "command": "node", + "args": ["--option1", "--option2"], "env": { - // Optional "NODE_ENV": "production" - } + }, + "secretName": "API_KEY", + "enabled": true, + "failOnWarning": false } ``` -Streamable HTTP install example: +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `name` | string | Yes | — | npm package name | +| `version` | string | No | `"latest"` | Package version to install | +| `serverName` | string | Yes | — | Unique MCP server identifier | +| `transportType` | string | No | `"stdio"` | `"stdio"` or `"streamable_http"` | +| `command` | string | No | Auto-detected | Command to start the server. If omitted, resolved from the package's `bin` or `main` field | +| `args` | string[] | No | `[]` | Command-line arguments | +| `env` | object | No | `{}` | Environment variables for the server process | +| `secretName` | string | No | — | Secret name to associate with this server | +| `enabled` | boolean | No | `true` | Whether to start the server after installation | +| `failOnWarning` | boolean | No | `false` | Fail installation if npm emits warnings | + +**Streamable HTTP** install example: ```json { @@ -157,7 +172,16 @@ Streamable HTTP install example: } ``` -Python stdio install example: +Additional fields for `streamable_http`: + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `url` | string | Yes | The MCP HTTP endpoint | +| `headers` | object | No | Static HTTP headers to send with requests | +| `sessionId` | string | No | Session ID to resume a previous session | +| `reconnectionOptions` | object | No | SSE reconnection settings (see Server Management) | + +**Python stdio** install example: ```json { @@ -170,12 +194,45 @@ Python stdio install example: } ``` -If `transportType` is omitted, the API defaults to `stdio`. For `streamable_http`, `command`, `args`, and `env` are ignored. -For `runtime: "python"`, `pythonModule` is required and `transportType` must be `stdio`. -Python installs always use a virtual environment at `packages/python/` (or `PYTHON_VENV_DIR` if configured). -Python upgrades run `pip install --upgrade` inside that same virtual environment. -For Python runtime, `installPath` and `venvPath` are the same directory. -If `pipIndexUrl` or `pipExtraIndexUrl` are provided during install, they are persisted and reused for upgrades and update checks. +Python packages with a console script entry point (no `__main__.py`) require the `command` field to point to the console script binary installed in the venv. When `command` is provided, `pythonModule` is still required for metadata purposes but is not used to build the run command: + +```json +{ + "name": "klaviyo-mcp-server", + "serverName": "klaviyo", + "runtime": "python", + "pythonModule": "klaviyo_mcp_server", + "command": "/app/packages/python/klaviyo/bin/klaviyo-mcp-server", + "pipDependencies": ["fastmcp<3"], + "env": { + "PRIVATE_API_KEY": "your-api-key", + "READ_ONLY": "false", + "ALLOW_USER_GENERATED_CONTENT": "false" + } +} +``` + +Additional fields for `runtime: "python"`: + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `runtime` | string | Yes | Must be `"python"` | +| `pythonModule` | string | Yes | Python module name (e.g. `my_mcp_server`). Used with `python -m` unless `command` is provided | +| `pythonArgs` | string[] | No | Additional arguments passed after the module name | +| `command` | string | No | Override the default `python -m ` command. Use for packages that expose a console script entry point instead of `__main__.py` | +| `args` | string[] | No | Arguments for the command when `command` is provided (replaces `pythonArgs`) | +| `pipDependencies` | string[] | No | Additional pip specs installed alongside the main package (e.g. `["fastmcp<3", "requests>=2.28"]`). Persisted and reused during upgrades | +| `pipIndexUrl` | string | No | Custom PyPI index URL for pip | +| `pipExtraIndexUrl` | string | No | Additional PyPI index URL for pip | + +**Python install behavior:** + +- If `transportType` is omitted, the API defaults to `"stdio"`. Python runtime only supports `"stdio"`. +- A virtual environment is created at `packages/python/` (or `PYTHON_VENV_DIR/` if configured). +- Without `command`: the server runs as `/bin/python -u -m [pythonArgs...]` +- With `command`: the server runs as ` [args...]` with the venv environment variables set. +- `pipDependencies` are installed in the same `pip install` invocation as the main package. +- `pipIndexUrl`, `pipExtraIndexUrl`, and `pipDependencies` are persisted on the package record and reused for upgrades and update checks. #### List Installed Packages @@ -229,6 +286,24 @@ Both endpoints return version information and support the `checkUpdates=true` qu DELETE /packages/:name ``` +Removes the package files, stops the associated MCP server, and deletes the server configuration. + +#### Enable a Package + +``` +PUT /packages/:name/enable +``` + +Enables a previously disabled package and starts its associated MCP server. + +#### Disable a Package + +``` +PUT /packages/:name/disable +``` + +Disables a package and stops its associated MCP server without removing it. + #### Check for Package Updates ``` @@ -702,6 +777,9 @@ Parameters: - `headers`: (Optional for streamable_http) Static headers to send with requests - `sessionId`: (Optional for streamable_http) Persisted session ID to resume - `reconnectionOptions`: (Optional for streamable_http) SSE reconnection settings +- `secretNames`: (Optional) Array of secret names this server expects (e.g. `["API_KEY", "API_SECRET"]`). Only these secrets are injected during tool calls. Replaces the legacy `secretName` field. +- `secretName`: (Optional, legacy) Single secret name. Automatically migrated to `secretNames` array on load. +- `startupTimeout`: (Optional) Custom timeout in milliseconds for tool discovery after connection. Defaults to 180,000ms (3 minutes). Maximum 300,000ms (5 minutes). - `enabled`: (Optional) Whether the server should be enabled immediately (defaults to true) Response format: @@ -752,13 +830,14 @@ Request body: ```json { - "command": "./updated/path/to/server.js", // Optional - "args": ["--new-option"], // Optional + "command": "./updated/path/to/server.js", + "args": ["--new-option"], "env": { - // Optional "NODE_ENV": "development" }, - "enabled": false // Optional + "secretNames": ["API_KEY"], + "startupTimeout": 60000, + "enabled": false } ``` @@ -1161,6 +1240,162 @@ The system uses a "declaration" pattern. An MCP server can declare its need for 4. Injects both sets of credentials as hidden parameters into the tool call. * The `mcp-google-workspace` server receives these credentials, creates a temporary authenticated client, and executes the tool. The front-end never handles any tokens. +### Health Check + +``` +GET /healthz +``` + +Returns `{ "status": "ok" }` when the server is running. Useful for container orchestration health probes. + +### Built-in Servers + +MCP API ships with built-in servers that run in-process (no child process spawned). Built-in servers are always connected, cannot be deleted or disabled by users, and appear alongside dynamically registered servers in API responses. + +#### Web Tools (SearXNG) + +**Server name:** `webtools` + +Provides two tools: + +| Tool | Description | +|------|-------------| +| `web_search` | Search the web via a SearXNG instance | +| `get_url_content` | Fetch a URL and convert the page content to markdown (uses Puppeteer) | + +Requires the `SEARXNG_URL` environment variable to be set. If not configured, the `web_search` tool is unavailable. + +### Auto-Install on First Run + +The `INSTALL_ON_START` environment variable configures packages to install automatically on the first application start. The format is a comma-separated list of `repo|serverName` pairs: + +``` +INSTALL_ON_START=@missionsquad/mcp-github|github,@missionsquad/mcp-helper-tools|helper-tools +``` + +Behavior: +- Only runs once (tracked via the `appState` MongoDB collection) +- Skips packages that were previously installed and then uninstalled by the user +- Each entry installs the npm package and registers it as an MCP server with the given name + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `DEBUG` | `false` | Enable debug logging | +| `PORT` | `8080` | HTTP server port | +| `MONGO_USER` | `root` | MongoDB username | +| `MONGO_PASS` | `example` | MongoDB password | +| `MONGO_HOST` | `localhost:27017` | MongoDB host and port | +| `MONGO_DBNAME` | `squad-test` | MongoDB database name for MCP server records and packages | +| `MONGO_REPLICASET` | — | MongoDB replica set name (optional, for replica set connections) | +| `PAYLOAD_LIMIT` | `6mb` | Maximum request body size | +| `SECRETS_KEY` | `secret` | AES-256-GCM encryption key for secrets. **Must be changed in production.** | +| `SECRETS_DBNAME` | `secrets` | MongoDB database name for encrypted secrets | +| `INSTALL_ON_START` | `@missionsquad/mcp-github\|github,@missionsquad/mcp-helper-tools\|helper-tools` | Packages to auto-install on first run (see Auto-Install on First Run) | +| `SEARXNG_URL` | — | SearXNG instance URL for the built-in web search tool | +| `PYTHON_BIN` | — | Path to Python executable. Falls back to `python3` then `python` if not set | +| `PYTHON_VENV_DIR` | `packages/python` | Base directory for Python virtual environments | +| `PIP_INDEX_URL` | — | Custom PyPI index URL for pip | +| `PIP_EXTRA_INDEX_URL` | — | Additional PyPI index URL for pip | +| `GOOGLE_OAUTH_CREDENTIALS` | — | Full Google OAuth credentials JSON (single-line string) for the Stdio OAuth2 flow | + +## Installation Examples + +### Node.js Package (Auto-Detected Entry Point) + +The simplest case. The API installs the npm package and resolves the entry point from the package's `bin` or `main` field: + +```json +POST /packages/install + +{ + "name": "@missionsquad/mcp-github", + "serverName": "github" +} +``` + +### Node.js Package (Custom Command) + +When you need to specify the command and arguments explicitly: + +```json +POST /packages/install + +{ + "name": "@modelcontextprotocol/server-filesystem", + "serverName": "filesystem", + "command": "node", + "args": ["./packages/modelcontextprotocol-server-filesystem/node_modules/@modelcontextprotocol/server-filesystem/dist/index.js", "/data"], + "env": { + "NODE_ENV": "production" + } +} +``` + +### Python Package (Standard `python -m` Entry) + +For Python packages that support `python -m `: + +```json +POST /packages/install + +{ + "name": "my-python-mcp", + "serverName": "python-mcp", + "runtime": "python", + "pythonModule": "my_python_mcp" +} +``` + +The server will run as: `/bin/python -u -m my_python_mcp` + +### Python Package (Console Script Entry Point) + +Some Python packages expose a console script instead of supporting `python -m`. Use the `command` field to point to the installed console script binary in the venv: + +```json +POST /packages/install + +{ + "name": "klaviyo-mcp-server", + "version": "0.3.0", + "serverName": "klaviyo", + "runtime": "python", + "pythonModule": "klaviyo_mcp_server", + "command": "/app/packages/python/klaviyo/bin/klaviyo-mcp-server", + "pipDependencies": ["fastmcp<3"], + "env": { + "PRIVATE_API_KEY": "your-api-key", + "READ_ONLY": "false", + "ALLOW_USER_GENERATED_CONTENT": "false" + } +} +``` + +Notes: +- `command` overrides the default `python -m ` invocation +- `pipDependencies` installs additional pip specs alongside the main package (useful for pinning transitive dependency versions) +- The venv path follows the pattern `/`, so the console script binary is at `/bin/` + +### Streamable HTTP Package + +For remote MCP servers accessible over HTTP: + +```json +POST /packages/install + +{ + "name": "remote-mcp-package", + "serverName": "remote-server", + "transportType": "streamable_http", + "url": "https://example.com/mcp", + "headers": { + "X-Custom-Header": "value" + } +} +``` + ## Security Considerations - The `SECRETS_KEY` environment variable is used to encrypt all secrets. Keep this secure and unique for each deployment. diff --git a/package.json b/package.json index f6c7b23..cdc4eb3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@missionsquad/mcp-api", - "version": "1.10.5", + "version": "1.11.0", "description": "MCP Servers exposed via HTTP API", "main": "dist/index.js", "repository": "missionsquad/mcp-api", @@ -10,7 +10,11 @@ "start": "node --experimental-require-module dist/index.js", "build": "rm -rf dist && tsc", "dev": "ts-node-dev --respawn --transpile-only src/index.ts", - "test": "rm -rf data-test && mkdir -p data-test && jest --config jest.config.json" + "test": "rm -rf data-test && mkdir -p data-test && jest --config jest.config.json", + "mcp:semantics": "ts-node ./scripts/mcp-tool-semantics.ts ./scripts/atlassian-tools.json", + "mcp:semantics:sentence": "ts-node ./scripts/mcp-tool-semantics.ts ./scripts/atlassian-tools.json --style sentence", + "mcp:semantics:json": "ts-node ./scripts/mcp-tool-semantics.ts ./scripts/atlassian-tools.json --format json", + "mcp:semantics:out": "ts-node ./scripts/mcp-tool-semantics.ts ./scripts/atlassian-tools.json --out ./generated/jira-tool-semantics.md" }, "keywords": [ "node", diff --git a/src/controllers/mcp.ts b/src/controllers/mcp.ts index 60b94ff..a3b4728 100644 --- a/src/controllers/mcp.ts +++ b/src/controllers/mcp.ts @@ -1,15 +1,24 @@ import { Express, NextFunction, Request, RequestHandler, Response } from 'express' import { MCPService } from '../services/mcp' -import type { AddServerInput, UpdateServerInput } from '../services/mcp' +import type { + AddServerInput, + DiscoverExternalAuthorizationInput, + SaveUserServerSecretsInput, + UpdateServerInput +} from '../services/mcp' import { MongoConnectionParams } from '../utils/mongodb' import { Resource } from '..' import { log } from '../utils/general' import { Secrets } from '../services/secrets' import type { McpOAuthTokenInput } from '../services/oauthTokens' import type { McpOAuthTokens } from '../services/oauthTokens' +import type { McpUserSessions } from '../services/userSessions' +import type { InstallUserServerInput, UpdateUserServerInstallInput, McpUserServerInstalls } from '../services/userServerInstalls' +import { McpValidationError, toMcpErrorResponse } from '../services/mcpErrors' +import type { ExternalOAuthProvisioningContext } from '../services/dcrClients' export interface ToolCallRequest { - username?: string + username: string serverName: string methodName: string args: Record @@ -32,7 +41,19 @@ export type AddServerRequest = AddServerInput export type UpdateServerRequest = UpdateServerInput -export type UpdateServerOAuthRequest = Omit +export type UpdateServerOAuthRequest = Omit & { username: string } +export interface ResolveExternalOAuthClientRequest { + username: string + oauthProvisioningContext: ExternalOAuthProvisioningContext +} + +export const requireUsername = (username: string | undefined, context: string): string => { + const normalized = username?.trim() + if (!normalized) { + throw new McpValidationError(`username is required for ${context}`) + } + return normalized +} export class MCPController implements Resource { private app: Express @@ -42,15 +63,28 @@ export class MCPController implements Resource { app, mongoParams, secretsService, - oauthTokensService + oauthTokensService, + userSessionsService, + userServerInstalls, + dcrClients }: { app: Express mongoParams: MongoConnectionParams secretsService: Secrets oauthTokensService?: McpOAuthTokens + userSessionsService?: McpUserSessions + userServerInstalls: McpUserServerInstalls + dcrClients?: import('../services/dcrClients').McpDcrClients }) { this.app = app - this.mcpService = new MCPService({ mongoParams, secretsService, oauthTokensService }) + this.mcpService = new MCPService({ + mongoParams, + secretsService, + oauthTokensService, + userSessionsService, + userServerInstalls, + dcrClients + }) } /** @@ -73,9 +107,22 @@ export class MCPController implements Resource { this.app.put('/mcp/servers/:name', this.updateServer.bind(this)) this.app.post('/mcp/servers/:name/oauth', this.updateServerOAuth.bind(this)) this.app.delete('/mcp/servers/:name', this.deleteServer.bind(this)) + this.app.get('/mcp/servers/:name/delete-impact', this.getSharedServerDeleteImpact.bind(this)) this.app.put('/mcp/servers/:name/enable', this.enableServer.bind(this)) this.app.put('/mcp/servers/:name/disable', this.disableServer.bind(this)) this.app.get('/mcp/tools', this.getTools.bind(this)) + this.app.get('/mcp/user/tools', this.getUserTools.bind(this)) + this.app.get('/mcp/user/servers', this.getUserServers.bind(this)) + this.app.get('/mcp/user/servers/:name', this.getUserServer.bind(this)) + this.app.get('/mcp/user/servers/:name/install', this.getUserServerInstall.bind(this)) + this.app.get('/mcp/user/servers/:name/tools', this.getUserServerTools.bind(this)) + this.app.post('/mcp/user/servers/:name/install', this.installUserServer.bind(this)) + this.app.put('/mcp/user/servers/:name/install', this.updateUserServerInstall.bind(this)) + this.app.delete('/mcp/user/servers/:name/install', this.uninstallUserServer.bind(this)) + this.app.post('/mcp/user/servers/:name/secrets', this.saveUserServerSecrets.bind(this)) + this.app.post('/mcp/user/servers/:name/refresh', this.refreshUserServer.bind(this)) + this.app.post('/mcp/user/servers/:name/oauth-client/resolve', this.resolveExternalOAuthClient.bind(this)) + this.app.post('/mcp/external/discover', this.discoverExternalAuthorization.bind(this)) this.app.post('/secrets/set', this.setSecret.bind(this)) this.app.post('/secrets/delete', this.deleteSecret.bind(this)) } @@ -85,6 +132,15 @@ export class MCPController implements Resource { const servers = Object.values(this.mcpService.servers).map(server => { const base = { name: server.name, + displayName: server.displayName, + description: server.description, + source: server.source, + authMode: server.authMode, + oauthTemplate: server.oauthTemplate, + secretFields: server.secretFields, + homepageUrl: server.homepageUrl, + repositoryUrl: server.repositoryUrl, + licenseName: server.licenseName, transportType: server.transportType, secretNames: server.secretNames, // Only return new format (migration already happened) // secretName is intentionally excluded from response @@ -108,7 +164,6 @@ export class MCPController implements Resource { ...base, url: server.url, headers: server.headers, - sessionId: server.sessionId, reconnectionOptions: server.reconnectionOptions } }) @@ -134,11 +189,77 @@ export class MCPController implements Resource { } } + private async getUserTools(req: Request, res: Response): Promise { + try { + const username = requireUsername(typeof req.query.username === 'string' ? req.query.username : undefined, 'listing user tools') + const tools = await this.mcpService.getUserTools(username) + res.json({ success: true, tools }) + } catch (error) { + const response = toMcpErrorResponse(error) + res.status(response.statusCode).json(response.body) + } + } + + private async getUserServers(req: Request, res: Response): Promise { + try { + const username = requireUsername(typeof req.query.username === 'string' ? req.query.username : undefined, 'listing user servers') + const servers = await this.mcpService.getUserServers(username) + res.json({ success: true, servers }) + } catch (error) { + const response = toMcpErrorResponse(error) + res.status(response.statusCode).json(response.body) + } + } + + private async getUserServer(req: Request, res: Response): Promise { + try { + const username = requireUsername(typeof req.query.username === 'string' ? req.query.username : undefined, 'getting a user server') + const server = await this.mcpService.getUserServer(username, req.params.name) + if (!server) { + res.status(404).json({ success: false, error: `Server ${req.params.name} not found` }) + return + } + res.json({ success: true, server }) + } catch (error) { + const response = toMcpErrorResponse(error) + res.status(response.statusCode).json(response.body) + } + } + + private async getUserServerInstall(req: Request, res: Response): Promise { + try { + const username = requireUsername( + typeof req.query.username === 'string' ? req.query.username : undefined, + 'getting a user server install' + ) + const install = await this.mcpService.getUserServerInstallDetails(username, req.params.name) + if (!install) { + res.status(404).json({ success: false, error: `Server ${req.params.name} is not installed for user ${username}` }) + return + } + res.json({ success: true, install }) + } catch (error) { + const response = toMcpErrorResponse(error) + res.status(response.statusCode).json(response.body) + } + } + + private async getUserServerTools(req: Request, res: Response): Promise { + try { + const username = requireUsername(typeof req.query.username === 'string' ? req.query.username : undefined, 'getting user server tools') + const tools = await this.mcpService.getUserServerTools(username, req.params.name) + res.json({ success: true, tools }) + } catch (error) { + const response = toMcpErrorResponse(error) + res.status(response.statusCode).json(response.body) + } + } + private async callTool(req: Request, res: Response, next: NextFunction): Promise { try { const body = req.body as ToolCallRequest let { serverName, methodName, args } = body - const username = body.username ?? 'default' + const username = requireUsername(body.username, 'tool calls') // 1. Get the server's configuration from the MCPService const server = await this.mcpService.getServer(serverName) @@ -174,7 +295,8 @@ export class MCPController implements Resource { res.json({ success: true, data: result }) } catch (error) { log({ level: 'error', msg: `error calling tool: ${(error as Error).message}` }) - res.status(500).json({ success: false, error: (error as Error).message }) + const response = toMcpErrorResponse(error) + res.status(response.statusCode).json(response.body) } } @@ -182,12 +304,13 @@ export class MCPController implements Resource { try { const body = req.body as SetSecretRequest const { secretName, secretValue } = body - const username = body.username ?? 'default' + const username = requireUsername(body.username, 'setting secrets') await this.mcpService.setSecret(username, secretName, secretValue) log({ level: 'info', msg: `set secret ${secretName}` }) res.json({ success: true }) } catch (error) { - res.status(500).json({ success: false, error: (error as Error).message }) + const response = toMcpErrorResponse(error) + res.status(response.statusCode).json(response.body) } } @@ -195,11 +318,117 @@ export class MCPController implements Resource { try { const body = req.body as DeleteSecretRequest const { secretName } = body - const username = body.username ?? 'default' + const username = requireUsername(body.username, 'deleting secrets') await this.mcpService.deleteSecret(username, secretName) res.json({ success: true }) } catch (error) { - res.status(500).json({ success: false, error: (error as Error).message }) + const response = toMcpErrorResponse(error) + res.status(response.statusCode).json(response.body) + } + } + + private async installUserServer(req: Request, res: Response): Promise { + try { + const body = req.body as Partial + const username = requireUsername(body.username, 'installing a user server') + const server = await this.mcpService.installUserServer({ + serverName: req.params.name, + username, + enabled: body.enabled === true, + oauthClientId: body.oauthClientId, + oauthClientSecret: body.oauthClientSecret, + oauthScopes: body.oauthScopes + }) + res.json({ success: true, server }) + } catch (error) { + const response = toMcpErrorResponse(error) + res.status(response.statusCode).json(response.body) + } + } + + private async updateUserServerInstall(req: Request, res: Response): Promise { + try { + const body = req.body as Partial + const username = requireUsername(body.username, 'updating a user server install') + const server = await this.mcpService.updateUserServerInstall({ + serverName: req.params.name, + username, + enabled: body.enabled === true, + oauthClientId: body.oauthClientId, + oauthClientSecret: body.oauthClientSecret, + oauthScopes: body.oauthScopes + }) + res.json({ success: true, server }) + } catch (error) { + const response = toMcpErrorResponse(error) + res.status(response.statusCode).json(response.body) + } + } + + private async uninstallUserServer(req: Request, res: Response): Promise { + try { + const body = req.body as { username?: string } + const username = requireUsername(body.username, 'uninstalling a user server') + await this.mcpService.uninstallUserServer(req.params.name, username) + res.json({ success: true }) + } catch (error) { + const response = toMcpErrorResponse(error) + res.status(response.statusCode).json(response.body) + } + } + + private async saveUserServerSecrets(req: Request, res: Response): Promise { + try { + const body = req.body as SaveUserServerSecretsInput + const username = requireUsername(body.username, 'saving user server secrets') + await this.mcpService.saveUserServerSecrets({ + serverName: req.params.name, + username, + secrets: body.secrets + }) + res.json({ success: true }) + } catch (error) { + const response = toMcpErrorResponse(error) + res.status(response.statusCode).json(response.body) + } + } + + private async refreshUserServer(req: Request, res: Response): Promise { + try { + const body = req.body as { username?: string } + const username = requireUsername(body.username, 'refreshing a user server') + const server = await this.mcpService.refreshUserServer(req.params.name, username) + res.json({ success: true, server }) + } catch (error) { + const response = toMcpErrorResponse(error) + res.status(response.statusCode).json(response.body) + } + } + + private async discoverExternalAuthorization(req: Request, res: Response): Promise { + try { + const body = req.body as DiscoverExternalAuthorizationInput + const result = await this.mcpService.discoverExternalAuthorization(body) + res.json({ success: true, ...result }) + } catch (error) { + const response = toMcpErrorResponse(error) + res.status(response.statusCode).json(response.body) + } + } + + private async resolveExternalOAuthClient(req: Request, res: Response): Promise { + try { + const body = req.body as ResolveExternalOAuthClientRequest + const username = requireUsername(body.username, 'resolving external OAuth client') + const client = await this.mcpService.resolveExternalOAuthClientContext({ + serverName: req.params.name, + username, + oauthProvisioningContext: body.oauthProvisioningContext + }) + res.json({ success: true, client }) + } catch (error) { + const response = toMcpErrorResponse(error) + res.status(response.statusCode).json(response.body) } } @@ -207,9 +436,15 @@ export class MCPController implements Resource { try { const body = req.body as AddServerRequest const result = await this.mcpService.addServer(body) + if (body.source === 'external' && body.username) { + const userServer = await this.mcpService.getUserServer(body.username, body.name) + res.json({ success: true, server: userServer ?? result }) + return + } res.json({ success: true, server: result }) } catch (error) { - res.status(500).json({ success: false, error: (error as Error).message }) + const response = toMcpErrorResponse(error) + res.status(response.statusCode).json(response.body) } } @@ -220,7 +455,8 @@ export class MCPController implements Resource { const result = await this.mcpService.updateServer(name, body) res.json({ success: true, server: result }) } catch (error) { - res.status(500).json({ success: false, error: (error as Error).message }) + const response = toMcpErrorResponse(error) + res.status(response.statusCode).json(response.body) } } @@ -228,10 +464,13 @@ export class MCPController implements Resource { try { const name = req.params.name const body = req.body as UpdateServerOAuthRequest - const result = await this.mcpService.updateServerOAuthTokens(name, body) + const username = requireUsername(body.username, 'OAuth token update') + const { username: _username, ...tokenInput } = body + const result = await this.mcpService.updateServerOAuthTokens(name, username, tokenInput) res.json({ success: true, server: result }) } catch (error) { - res.status(500).json({ success: false, error: (error as Error).message }) + const response = toMcpErrorResponse(error) + res.status(response.statusCode).json(response.body) } } @@ -241,7 +480,18 @@ export class MCPController implements Resource { await this.mcpService.deleteServer(name) res.json({ success: true }) } catch (error) { - res.status(500).json({ success: false, error: (error as Error).message }) + const response = toMcpErrorResponse(error) + res.status(response.statusCode).json(response.body) + } + } + + private async getSharedServerDeleteImpact(req: Request, res: Response): Promise { + try { + const impact = await this.mcpService.getSharedServerDeleteImpact(req.params.name) + res.json({ success: true, impact }) + } catch (error) { + const response = toMcpErrorResponse(error) + res.status(response.statusCode).json(response.body) } } @@ -255,7 +505,8 @@ export class MCPController implements Resource { } res.json({ success: true, server }) } catch (error) { - res.status(500).json({ success: false, error: (error as Error).message }) + const response = toMcpErrorResponse(error) + res.status(response.statusCode).json(response.body) } } @@ -265,7 +516,8 @@ export class MCPController implements Resource { const server = await this.mcpService.enableServer(name) res.json({ success: true, server }) } catch (error) { - res.status(500).json({ success: false, error: (error as Error).message }) + const response = toMcpErrorResponse(error) + res.status(response.statusCode).json(response.body) } } @@ -275,7 +527,8 @@ export class MCPController implements Resource { const server = await this.mcpService.disableServer(name) res.json({ success: true, server }) } catch (error) { - res.status(500).json({ success: false, error: (error as Error).message }) + const response = toMcpErrorResponse(error) + res.status(response.statusCode).json(response.body) } } diff --git a/src/index.ts b/src/index.ts index 2eb5921..02a3f75 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,9 @@ import { AuthController } from './controllers/auth' import { Secrets } from './services/secrets' import { registerBuiltInServers } from './builtin-servers' import { McpOAuthTokens } from './services/oauthTokens' +import { McpUserSessions } from './services/userSessions' +import { McpUserServerInstalls } from './services/userServerInstalls' +import { McpDcrClients } from './services/dcrClients' export type Resource = { init: () => Promise @@ -48,9 +51,27 @@ export class API { // Initialize OAuth token store const oauthTokensService = new McpOAuthTokens({ mongoParams }) await oauthTokensService.init() - + + // Initialize user sessions store + const userSessionsService = new McpUserSessions({ mongoParams }) + await userSessionsService.init() + + const userServerInstalls = new McpUserServerInstalls({ mongoParams }) + await userServerInstalls.init() + + const dcrClients = new McpDcrClients({ mongoParams }) + await dcrClients.init() + // Initialize MCP controller - const mcpController = new MCPController({ app, mongoParams, secretsService, oauthTokensService }) + const mcpController = new MCPController({ + app, + mongoParams, + secretsService, + oauthTokensService, + userSessionsService, + userServerInstalls, + dcrClients + }) await mcpController.init() mcpController.registerRoutes() this.resources.push(mcpController) diff --git a/src/services/dcrClients.ts b/src/services/dcrClients.ts new file mode 100644 index 0000000..7a0ab3b --- /dev/null +++ b/src/services/dcrClients.ts @@ -0,0 +1,347 @@ +import { randomUUID } from 'crypto' +import { env } from '../env' +import { log, sleep } from '../utils/general' +import { IndexDefinition, MongoConnectionParams, MongoDBClient } from '../utils/mongodb' +import { SecretEncryptor } from '../utils/secretEncryptor' +import { McpValidationError } from './mcpErrors' + +export type SupportedTokenEndpointAuthMethod = 'none' | 'client_secret_post' | 'client_secret_basic' + +export interface ExternalOAuthProvisioningContext { + publicApiOrigin: string + redirectUri: string + clientMetadataUrl: string + clientName: string +} + +export interface McpDcrClientRegistrationRecord { + issuer: string + publicApiOrigin: string + redirectUri: string + clientName: string + registrationEndpoint: string + tokenEndpointAuthMethod: SupportedTokenEndpointAuthMethod + clientId: string + clientSecret?: string + clientIdIssuedAt?: number + clientSecretExpiresAt?: number + registrationAccessToken?: string + registrationClientUri?: string + grantTypes: string[] + responseTypes: string[] + scope?: string + createdAt: Date + updatedAt: Date +} + +interface StoredMcpDcrClientRegistrationRecord extends Omit { + clientSecret?: string + registrationAccessToken?: string +} + +interface McpDcrProvisioningLockRecord { + issuer: string + publicApiOrigin: string + redirectUri: string + lockOwner: string + expiresAt: Date + createdAt: Date +} + +interface DynamicClientRegistrationRequest { + client_name: string + grant_types: string[] + response_types: string[] + redirect_uris: string[] + token_endpoint_auth_method: SupportedTokenEndpointAuthMethod +} + +interface DynamicClientRegistrationResponse { + client_id?: string + client_secret?: string + client_id_issued_at?: number + client_secret_expires_at?: number + registration_access_token?: string + registration_client_uri?: string + token_endpoint_auth_method?: string + [key: string]: unknown +} + +const registrationIndexes: IndexDefinition[] = [ + { name: 'issuer_public_origin_redirect', key: { issuer: 1, publicApiOrigin: 1, redirectUri: 1 }, unique: true } +] + +const lockIndexes: IndexDefinition[] = [ + { name: 'issuer_public_origin_redirect', key: { issuer: 1, publicApiOrigin: 1, redirectUri: 1 }, unique: true }, + { name: 'expiresAt_ttl', key: { expiresAt: 1 }, expireAfterSeconds: 0 } +] + +const DCR_LOCK_DURATION_MS = 30_000 +const DCR_LOCK_WAIT_TIMEOUT_MS = 30_000 +const DCR_LOCK_WAIT_INTERVAL_MS = 500 + +const SUPPORTED_AUTH_METHODS: SupportedTokenEndpointAuthMethod[] = [ + 'none', + 'client_secret_post', + 'client_secret_basic' +] + +const isSupportedAuthMethod = (value: string): value is SupportedTokenEndpointAuthMethod => + SUPPORTED_AUTH_METHODS.includes(value as SupportedTokenEndpointAuthMethod) + +export const normalizeTokenEndpointAuthMethods = ( + methods?: string[] +): SupportedTokenEndpointAuthMethod[] => { + const input = methods && methods.length > 0 ? methods : ['client_secret_basic'] + return input.filter(isSupportedAuthMethod) +} + +export const resolvePreferredTokenEndpointAuthMethod = ( + methods?: string[] +): SupportedTokenEndpointAuthMethod => { + const normalized = normalizeTokenEndpointAuthMethods(methods) + for (const method of SUPPORTED_AUTH_METHODS) { + if (normalized.includes(method)) { + return method + } + } + throw new McpValidationError( + 'Dynamic client registration requires one of these token endpoint auth methods: none, client_secret_post, client_secret_basic' + ) +} + +const isClientSecretExpired = (record: McpDcrClientRegistrationRecord): boolean => { + if (record.clientSecretExpiresAt === undefined || record.clientSecretExpiresAt === 0) { + return false + } + return Date.now() >= record.clientSecretExpiresAt * 1000 +} + +export class McpDcrClients { + private encryptor: SecretEncryptor + private registrationDbClient: MongoDBClient + private lockDbClient: MongoDBClient + + constructor({ mongoParams }: { mongoParams: MongoConnectionParams }) { + this.encryptor = new SecretEncryptor(env.SECRETS_KEY) + this.registrationDbClient = new MongoDBClient(mongoParams, registrationIndexes) + this.lockDbClient = new MongoDBClient(mongoParams, lockIndexes) + } + + public async init(): Promise { + await this.registrationDbClient.connect('mcpDcrClients') + await this.lockDbClient.connect('mcpDcrProvisioningLocks') + } + + public async stop(): Promise { + await this.registrationDbClient.disconnect() + await this.lockDbClient.disconnect() + } + + public async getRegistration(input: { + issuer: string + publicApiOrigin: string + redirectUri: string + }): Promise { + const record = await this.registrationDbClient.findOne(input) + if (!record) { + return null + } + + return { + ...record, + clientSecret: record.clientSecret ? this.encryptor.decrypt(record.clientSecret) : undefined, + registrationAccessToken: record.registrationAccessToken + ? this.encryptor.decrypt(record.registrationAccessToken) + : undefined + } + } + + public async getOrRegisterClient(input: { + issuer: string + registrationEndpoint: string + tokenEndpointAuthMethodsSupported?: string[] + oauthProvisioningContext: ExternalOAuthProvisioningContext + }): Promise { + const identity = { + issuer: input.issuer, + publicApiOrigin: input.oauthProvisioningContext.publicApiOrigin, + redirectUri: input.oauthProvisioningContext.redirectUri + } + + const existing = await this.getRegistration(identity) + if (existing && !isClientSecretExpired(existing)) { + return existing + } + + const lockOwner = randomUUID() + const acquired = await this.tryAcquireLock(identity, lockOwner) + if (!acquired) { + return this.waitForProvisionedClient(identity) + } + + try { + const afterLock = await this.getRegistration(identity) + if (afterLock && !isClientSecretExpired(afterLock)) { + return afterLock + } + + return this.registerNewClient(input) + } finally { + await this.releaseLock(identity, lockOwner) + } + } + + public async invalidateRegistration(input: { + issuer: string + publicApiOrigin: string + redirectUri: string + }): Promise { + await this.registrationDbClient.delete(input, false) + } + + private async tryAcquireLock( + identity: { issuer: string; publicApiOrigin: string; redirectUri: string }, + lockOwner: string + ): Promise { + try { + await this.lockDbClient.insert({ + ...identity, + lockOwner, + createdAt: new Date(), + expiresAt: new Date(Date.now() + DCR_LOCK_DURATION_MS) + }) + return true + } catch (error) { + const code = (error as { code?: number })?.code + if (code === 11000) { + return false + } + throw error + } + } + + private async releaseLock( + identity: { issuer: string; publicApiOrigin: string; redirectUri: string }, + lockOwner: string + ): Promise { + await this.lockDbClient.delete({ ...identity, lockOwner }, false) + } + + private async waitForProvisionedClient(identity: { + issuer: string + publicApiOrigin: string + redirectUri: string + }): Promise { + const deadline = Date.now() + DCR_LOCK_WAIT_TIMEOUT_MS + while (Date.now() < deadline) { + const record = await this.getRegistration(identity) + if (record && !isClientSecretExpired(record)) { + return record + } + await sleep(DCR_LOCK_WAIT_INTERVAL_MS) + } + throw new McpValidationError(`Timed out waiting for DCR registration for issuer ${identity.issuer}`) + } + + private async registerNewClient(input: { + issuer: string + registrationEndpoint: string + tokenEndpointAuthMethodsSupported?: string[] + oauthProvisioningContext: ExternalOAuthProvisioningContext + }): Promise { + const tokenEndpointAuthMethod = resolvePreferredTokenEndpointAuthMethod( + input.tokenEndpointAuthMethodsSupported + ) + const requestBody: DynamicClientRegistrationRequest = { + client_name: input.oauthProvisioningContext.clientName, + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + redirect_uris: [input.oauthProvisioningContext.redirectUri], + token_endpoint_auth_method: tokenEndpointAuthMethod + } + + const response = await fetch(input.registrationEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json' + }, + body: JSON.stringify(requestBody) + }) + + const responseText = await response.text() + let parsedResponse: DynamicClientRegistrationResponse + try { + parsedResponse = JSON.parse(responseText) as DynamicClientRegistrationResponse + } catch { + throw new McpValidationError( + `Dynamic client registration for issuer ${input.issuer} returned a non-JSON response` + ) + } + + if (!response.ok) { + throw new McpValidationError( + `Dynamic client registration failed for issuer ${input.issuer}: ${response.status} ${responseText}` + ) + } + + if (!parsedResponse.client_id) { + throw new McpValidationError(`Dynamic client registration for issuer ${input.issuer} did not return client_id`) + } + + const effectiveAuthMethodRaw = parsedResponse.token_endpoint_auth_method ?? tokenEndpointAuthMethod + if (!isSupportedAuthMethod(effectiveAuthMethodRaw)) { + throw new McpValidationError( + `Dynamic client registration for issuer ${input.issuer} returned unsupported token_endpoint_auth_method ${String(effectiveAuthMethodRaw)}` + ) + } + + if ( + (effectiveAuthMethodRaw === 'client_secret_post' || effectiveAuthMethodRaw === 'client_secret_basic') && + !parsedResponse.client_secret + ) { + throw new McpValidationError( + `Dynamic client registration for issuer ${input.issuer} requires client_secret for ${effectiveAuthMethodRaw}` + ) + } + + const now = new Date() + const record: McpDcrClientRegistrationRecord = { + issuer: input.issuer, + publicApiOrigin: input.oauthProvisioningContext.publicApiOrigin, + redirectUri: input.oauthProvisioningContext.redirectUri, + clientName: input.oauthProvisioningContext.clientName, + registrationEndpoint: input.registrationEndpoint, + tokenEndpointAuthMethod: effectiveAuthMethodRaw, + clientId: parsedResponse.client_id, + clientSecret: parsedResponse.client_secret, + clientIdIssuedAt: parsedResponse.client_id_issued_at, + clientSecretExpiresAt: parsedResponse.client_secret_expires_at, + registrationAccessToken: typeof parsedResponse.registration_access_token === 'string' ? parsedResponse.registration_access_token : undefined, + registrationClientUri: typeof parsedResponse.registration_client_uri === 'string' ? parsedResponse.registration_client_uri : undefined, + grantTypes: requestBody.grant_types, + responseTypes: requestBody.response_types, + createdAt: now, + updatedAt: now + } + + await this.registrationDbClient.upsert( + { + ...record, + clientSecret: record.clientSecret ? this.encryptor.encrypt(record.clientSecret) : undefined, + registrationAccessToken: record.registrationAccessToken + ? this.encryptor.encrypt(record.registrationAccessToken) + : undefined + }, + { + issuer: record.issuer, + publicApiOrigin: record.publicApiOrigin, + redirectUri: record.redirectUri + } + ) + + log({ level: 'info', msg: `Provisioned DCR client ${record.clientId} for issuer ${record.issuer}` }) + return record + } +} diff --git a/src/services/mcp.ts b/src/services/mcp.ts index c5cab9f..d258978 100644 --- a/src/services/mcp.ts +++ b/src/services/mcp.ts @@ -6,17 +6,41 @@ import { StreamableHTTPError } from '@modelcontextprotocol/sdk/client/streamableHttp.js' import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' -import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js' +import { UnauthorizedError, type OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js' import { RequestOptions } from '@modelcontextprotocol/sdk/shared/protocol.js' import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' import { CallToolResultSchema, ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js' import { Resource } from '..' import { BuiltInServer, BuiltInServerRegistry } from '../builtin-servers' -import { log, sanitizeString } from '../utils/general' +import { log, retryWithExponentialBackoff, sanitizeString } from '../utils/general' import { IndexDefinition, MongoConnectionParams, MongoDBClient } from '../utils/mongodb' import { Secrets } from './secrets' import { McpOAuthClientProvider, McpOAuthTokens } from './oauthTokens' import type { McpOAuthTokenInput } from './oauthTokens' +import { McpUserSessions } from './userSessions' +import { + ExternalOAuthProvisioningContext, + McpDcrClients, + SupportedTokenEndpointAuthMethod, + normalizeTokenEndpointAuthMethods +} from './dcrClients' +import { + InstallUserServerInput, + McpUserExternalServerInstallRecord, + McpUserServerInstalls, + UpdateUserServerInstallInput, + UserInstallAuthState +} from './userServerInstalls' +import { + McpValidationError, + McpReauthRequiredError, + McpServerDisabledError, + McpServerNotFoundError, + McpAuthNotConnectedError, + McpDiscoveryFailedError, + McpServerAlreadyExistsError +} from './mcpErrors' +import { validateExternalMcpUrl } from '../utils/ssrf' export interface MCPConnection { client: Client @@ -42,6 +66,70 @@ export type ToolsList = { }[] export type MCPTransportType = 'stdio' | 'streamable_http' +export type McpServerSource = 'platform' | 'external' +export type McpServerAuthMode = 'none' | 'oauth2' + +export interface McpExternalSecretField { + name: string + label: string + description: string + required: boolean + inputType: 'password' +} + +export interface McpExternalOAuthTemplate { + authorizationServerIssuer: string + authorizationServerMetadataUrl: string + resourceMetadataUrl: string + resourceUri: string + authorizationEndpoint: string + tokenEndpoint: string + scopesSupported?: string[] + challengedScopes?: string[] + codeChallengeMethodsSupported: string[] + pkceRequired: boolean + discoveryMode: 'auto' | 'manual' + registrationMode: 'cimd' | 'dcr' | 'manual' + manualClientCredentialsAllowed?: boolean + clientIdMetadataDocumentSupported?: boolean + registrationEndpoint?: string + tokenEndpointAuthMethodsSupported?: SupportedTokenEndpointAuthMethod[] +} + +export interface DiscoverExternalAuthorizationInput { + url: string +} + +export interface SuccessfulAuthorizationServerMetadataDocument { + url: string + document: Record +} + +export interface DiscoverAuthorizationServerMetadataResult { + documents: SuccessfulAuthorizationServerMetadataDocument[] + attemptedUrls: string[] +} + +export interface DiscoveredAuthorizationServer { + issuer: string + authorizationServerMetadataUrl: string + authorizationEndpoint: string + tokenEndpoint: string + scopesSupported?: string[] + codeChallengeMethodsSupported: string[] + clientIdMetadataDocumentSupported?: boolean + registrationEndpoint?: string + tokenEndpointAuthMethodsSupported?: SupportedTokenEndpointAuthMethod[] +} + +export interface DiscoverExternalAuthorizationResult { + serverUrl: string + resourceMetadataUrl: string + resourceUri: string + challengedScopes?: string[] + authorizationServers: DiscoveredAuthorizationServer[] + recommendedRegistrationMode: 'cimd' | 'dcr' | 'manual' +} export type StdioServerConfig = { transportType: 'stdio' @@ -54,12 +142,26 @@ export type StreamableHttpServerConfig = { transportType: 'streamable_http' url: string headers?: Record - sessionId?: string reconnectionOptions?: StreamableHTTPReconnectionOptions } type MCPServerBase = { + id?: string name: string + displayName?: string + description?: string + source?: McpServerSource + authMode?: McpServerAuthMode + oauthTemplate?: McpExternalOAuthTemplate + secretFields?: McpExternalSecretField[] + homepageUrl?: string + repositoryUrl?: string + licenseName?: string + catalogProvider?: 'glama' | 'manual' + catalogId?: string + createdBy?: string + createdAt?: Date + updatedAt?: Date secretName?: string // ← KEEP for backward compatibility secretNames?: string[] // ← ADD new property status: 'connected' | 'connecting' | 'disconnected' | 'error' @@ -90,15 +192,33 @@ type MCPServerRecord = MCPServerBase & { } export type AddServerInput = { + id?: string name: string + username?: string + displayName?: string + description?: string + source?: McpServerSource transportType?: MCPTransportType command?: string args?: string[] env?: Record url?: string headers?: Record - sessionId?: string reconnectionOptions?: StreamableHTTPReconnectionOptions + authMode?: McpServerAuthMode + oauthTemplate?: McpExternalOAuthTemplate + secretFields?: McpExternalSecretField[] + homepageUrl?: string + repositoryUrl?: string + licenseName?: string + catalogProvider?: 'glama' | 'manual' + catalogId?: string + oauthClientConfig?: { + clientId: string + clientSecret?: string + scopes?: string[] + } + oauthProvisioningContext?: ExternalOAuthProvisioningContext secretName?: string // ← KEEP for backward compatibility secretNames?: string[] // ← ADD new property enabled?: boolean @@ -106,22 +226,118 @@ export type AddServerInput = { } export type UpdateServerInput = { + username?: string + displayName?: string + description?: string + source?: McpServerSource transportType?: MCPTransportType command?: string args?: string[] env?: Record url?: string headers?: Record - sessionId?: string reconnectionOptions?: StreamableHTTPReconnectionOptions + authMode?: McpServerAuthMode + oauthTemplate?: McpExternalOAuthTemplate + secretFields?: McpExternalSecretField[] + homepageUrl?: string + repositoryUrl?: string + licenseName?: string + catalogProvider?: 'glama' | 'manual' + catalogId?: string secretName?: string // ← KEEP for backward compatibility secretNames?: string[] // ← ADD new property enabled?: boolean startupTimeout?: number } +export interface SaveUserServerSecretsInput { + serverName: string + username: string + secrets: Array<{ + name: string + value: string + }> +} + +export interface UserVisibleMcpServer { + name: string + displayName: string + description: string + source: McpServerSource + transportType: MCPTransportType + url?: string + authMode?: McpServerAuthMode + installed: boolean + enabled: boolean + authState?: UserInstallAuthState + authRequired: boolean + secretFields: McpExternalSecretField[] + secretNames?: string[] + configuredSecretNames?: string[] + toolsList?: ToolsList + homepageUrl?: string + repositoryUrl?: string + licenseName?: string + canInstall: boolean + canUninstall: boolean + canConfigure: boolean + canManagePlatformServer: boolean + statusMessage?: string + oauthTemplate?: McpExternalOAuthTemplate + oauthClientConfig?: { + clientId?: string + scopes?: string[] + } + command?: string + args?: string[] + env?: Record + headers?: Record + logs?: string[] +} + +export interface UserServerInstallDetails { + serverName: string + username: string + enabled: boolean + authState: UserInstallAuthState + oauthClientId?: string + oauthClientSecret?: string + oauthScopes?: string[] +} + +export interface SharedServerDeleteImpact { + serverName: string + source: McpServerSource + authMode: McpServerAuthMode + transportType: MCPTransportType + installedUsers: number + connectedAuthUsers: number + oauthTokenUsers: number + usersWithSavedSecrets: number + activeSessionUsers: number +} + +export interface ResolveExternalOAuthClientInput { + serverName: string + username: string + oauthProvisioningContext: ExternalOAuthProvisioningContext +} + +export interface ResolvedExternalOAuthClientContext { + registrationMode: 'cimd' | 'dcr' | 'manual' + clientId: string + clientSecret?: string + tokenEndpointAuthMethod: SupportedTokenEndpointAuthMethod +} + export const buildServerKey = (server: { name: string }): string => sanitizeString(server.name) +export type UserServerKey = `${string}:${string}` + +export const buildUserServerKey = (username: string, serverName: string): UserServerKey => + `${username}:${sanitizeString(serverName)}` + export const assertTransportConfigCompatible = (config: { transportType: MCPTransportType command?: string @@ -129,7 +345,7 @@ export const assertTransportConfigCompatible = (config: { env?: Record url?: string headers?: Record - sessionId?: string + sessionId?: unknown reconnectionOptions?: StreamableHTTPReconnectionOptions }): void => { if (config.transportType === 'streamable_http') { @@ -138,6 +354,9 @@ export const assertTransportConfigCompatible = (config: { if (hasStdioFields) { throw new Error('Streamable HTTP servers cannot define stdio fields (command, args, env).') } + if (config.sessionId !== undefined) { + throw new Error('Streamable HTTP server definitions cannot define sessionId; sessions are persisted per user.') + } return } @@ -168,7 +387,10 @@ const extractHttpStatusFromError = (error: unknown): number | undefined => { return undefined } -const shouldFallbackToSse = (error: unknown): boolean => { +export const shouldFallbackToSse = (error: unknown): boolean => { + if (error instanceof StreamableHTTPError && error.code === -1) { + return /Unexpected content type:/i.test(error.message) + } const status = extractHttpStatusFromError(error) return status === 400 || status === 404 || status === 405 } @@ -189,6 +411,231 @@ const stripAuthorizationHeaders = ( return next } +const DISCOVERY_REQUEST_TIMEOUT_MS = 5000 +const DISCOVERY_MAX_ATTEMPTS = 3 +const DISCOVERY_BASE_DELAY_MS = 500 +const DISCOVERY_TOTAL_BUDGET_MS = 20000 +const REQUIRED_PKCE_CHALLENGE_METHOD = 'S256' +const RESOURCE_URI_BACKFILL_FAILURE_COOLDOWN_MS = 15 * 60 * 1000 + +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value) + +const toOptionalStringArray = (value: unknown): string[] | undefined => { + if (!Array.isArray(value)) { + return undefined + } + const normalized = value + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter((item) => item.length > 0) + return normalized.length > 0 ? normalized : undefined +} + +const getOptionalString = (value: unknown): string | undefined => + typeof value === 'string' ? value : undefined + +export const canonicalizeExternalOAuthResourceUri = (input: string): string => { + const parsed = new URL(input) + parsed.search = '' + parsed.hash = '' + return parsed.toString() +} + +export const buildExternalUrlWithSecretQueryParams = ( + baseUrl: string, + secretValues: Record +): string => { + const resolvedUrl = new URL(baseUrl) + for (const [name, value] of Object.entries(secretValues)) { + if (!value.trim()) { + continue + } + resolvedUrl.searchParams.set(name, value) + } + return resolvedUrl.toString() +} + +const getKnownProviderUrlError = (value: string): string | null => { + try { + const parsed = new URL(value) + const pathname = parsed.pathname.replace(/\/+$/, '') || '/' + if (parsed.origin === 'https://mcp.zapier.com' && pathname !== '/api/v1/connect') { + return 'Zapier requires the Integration URL ending in /api/v1/connect. Do not use https://mcp.zapier.com/mcp.' + } + return null + } catch { + return null + } +} + +const buildSecretFieldLabel = (name: string): string => name + +const buildExtractedExternalUrlSecretMetadata = ( + inputUrl: string, + existingFields: McpExternalSecretField[] | undefined +): { + sanitizedUrl: string + secretFields: McpExternalSecretField[] + secrets: Array<{ name: string; value: string }> +} => { + const parsed = new URL(inputUrl) + const secretFields = [...(existingFields ?? [])] + const secretsByName = new Map() + + for (const [name, value] of parsed.searchParams.entries()) { + if (!/^[A-Za-z_][A-Za-z0-9_]{0,49}$/.test(name)) { + throw new McpValidationError(`Invalid query parameter name for secret extraction: ${name}`) + } + secretsByName.set(name, value) + if (!secretFields.some((field) => field.name === name)) { + secretFields.push({ + name, + label: buildSecretFieldLabel(name), + description: `Extracted from URL query parameter "${name}"`, + required: true, + inputType: 'password' + }) + } + } + + parsed.search = '' + parsed.hash = '' + + return { + sanitizedUrl: parsed.toString(), + secretFields, + secrets: Array.from(secretsByName.entries()).map(([name, value]) => ({ name, value })) + } +} + +const hasUsablePrimaryAuthorizationMetadata = (document: Record): boolean => { + const authorizationEndpoint = getOptionalString(document.authorization_endpoint) + const tokenEndpoint = getOptionalString(document.token_endpoint) + const codeChallengeMethodsSupported = toOptionalStringArray(document.code_challenge_methods_supported) ?? [] + return ( + typeof authorizationEndpoint === 'string' && + typeof tokenEndpoint === 'string' && + codeChallengeMethodsSupported.includes(REQUIRED_PKCE_CHALLENGE_METHOD) + ) +} + +export const selectPrimaryAuthorizationServerMetadataDocument = ( + documents: SuccessfulAuthorizationServerMetadataDocument[] +): SuccessfulAuthorizationServerMetadataDocument | undefined => + documents.find((candidate) => hasUsablePrimaryAuthorizationMetadata(candidate.document)) + +export const buildMergedAuthorizationServerResult = ({ + issuer, + scopesSupported, + primaryDocuments, + compatibilityDocuments +}: { + issuer: string + scopesSupported?: string[] + primaryDocuments: SuccessfulAuthorizationServerMetadataDocument[] + compatibilityDocuments: SuccessfulAuthorizationServerMetadataDocument[] +}): DiscoveredAuthorizationServer => { + const primaryIndex = primaryDocuments.findIndex((candidate) => hasUsablePrimaryAuthorizationMetadata(candidate.document)) + if (primaryIndex === -1) { + throw new Error(`Issuer ${issuer} does not include a usable primary authorization metadata document`) + } + + const primaryDocument = primaryDocuments[primaryIndex] + const issuerCompatibilityDocuments = primaryDocuments.slice(primaryIndex + 1) + const orderedCompatibilityDocuments = [...issuerCompatibilityDocuments, ...compatibilityDocuments] + + const authorizationEndpoint = primaryDocument.document.authorization_endpoint as string + const tokenEndpoint = primaryDocument.document.token_endpoint as string + const codeChallengeMethodsSupported = toOptionalStringArray( + primaryDocument.document.code_challenge_methods_supported + ) as string[] + + const primaryRegistrationEndpoint = getOptionalString(primaryDocument.document.registration_endpoint) + const compatibilityRegistrationEndpoint = orderedCompatibilityDocuments + .map((candidate) => getOptionalString(candidate.document.registration_endpoint)) + .find((candidate): candidate is string => typeof candidate === 'string') + const primaryTokenEndpointAuthMethodsSupported = normalizeTokenEndpointAuthMethods( + toOptionalStringArray(primaryDocument.document.token_endpoint_auth_methods_supported) + ) + const compatibilityTokenEndpointAuthMethodsSupported = orderedCompatibilityDocuments + .map((candidate) => + normalizeTokenEndpointAuthMethods(toOptionalStringArray(candidate.document.token_endpoint_auth_methods_supported)) + ) + .find((candidate) => candidate.length > 0) + const mergedTokenEndpointAuthMethodsSupported = + primaryTokenEndpointAuthMethodsSupported.length > 0 + ? primaryTokenEndpointAuthMethodsSupported + : compatibilityTokenEndpointAuthMethodsSupported + const clientIdMetadataDocumentSupported = + primaryDocument.document.client_id_metadata_document_supported === true || + orderedCompatibilityDocuments.some((candidate) => candidate.document.client_id_metadata_document_supported === true) + + return { + issuer, + authorizationServerMetadataUrl: primaryDocument.url, + authorizationEndpoint, + tokenEndpoint, + scopesSupported, + codeChallengeMethodsSupported, + ...(clientIdMetadataDocumentSupported ? { clientIdMetadataDocumentSupported: true } : {}), + ...(primaryRegistrationEndpoint ?? compatibilityRegistrationEndpoint + ? { registrationEndpoint: primaryRegistrationEndpoint ?? compatibilityRegistrationEndpoint } + : {}), + ...(mergedTokenEndpointAuthMethodsSupported && mergedTokenEndpointAuthMethodsSupported.length > 0 + ? { tokenEndpointAuthMethodsSupported: mergedTokenEndpointAuthMethodsSupported } + : {}) + } +} + +export const parseWwwAuthenticateHeader = ( + headerValue: string | null +): { resourceMetadataUrl?: string; challengedScopes?: string[] } => { + if (!headerValue) { + return {} + } + + const resourceMetadataMatch = /resource_metadata="([^"]+)"/i.exec(headerValue) + const scopeMatch = /scope="([^"]+)"/i.exec(headerValue) + + return { + ...(resourceMetadataMatch?.[1] ? { resourceMetadataUrl: resourceMetadataMatch[1] } : {}), + ...(scopeMatch?.[1] + ? { + challengedScopes: scopeMatch[1] + .split(/\s+/) + .map((scope) => scope.trim()) + .filter(Boolean) + } + : {}) + } +} + +export const buildProtectedResourceMetadataCandidates = (serverUrl: URL): string[] => { + const candidates: string[] = [] + if (serverUrl.pathname !== '/') { + candidates.push(new URL(`/.well-known/oauth-protected-resource${serverUrl.pathname}`, serverUrl.origin).toString()) + } + candidates.push(new URL('/.well-known/oauth-protected-resource', serverUrl.origin).toString()) + return Array.from(new Set(candidates)) +} + +export const buildAuthorizationServerMetadataCandidates = (issuerUrl: URL): string[] => { + const hasPath = issuerUrl.pathname !== '/' + if (!hasPath) { + return [ + new URL('/.well-known/oauth-authorization-server', issuerUrl.origin).toString(), + new URL('/.well-known/openid-configuration', issuerUrl.origin).toString() + ] + } + + return [ + new URL(`/.well-known/oauth-authorization-server${issuerUrl.pathname}`, issuerUrl.origin).toString(), + new URL(`/.well-known/openid-configuration${issuerUrl.pathname}`, issuerUrl.origin).toString(), + new URL(`${issuerUrl.pathname.replace(/\/+$/, '')}/.well-known/openid-configuration`, issuerUrl.origin).toString() + ] +} + const buildRequestInit = (headers?: Record): RequestInit | undefined => { if (!headers) { return undefined @@ -199,14 +646,16 @@ const buildRequestInit = (headers?: Record): RequestInit | undef export type TransportFactoryOptions = { requestInit?: RequestInit authProvider?: OAuthClientProvider + sessionId?: string + url?: string } export const createTransport = (server: MCPServer, options: TransportFactoryOptions = {}): Transport => { if (server.transportType === 'streamable_http') { const requestInit = options.requestInit ?? buildRequestInit(server.headers) - return new StreamableHTTPClientTransport(new URL(server.url), { + return new StreamableHTTPClientTransport(new URL(options.url ?? server.url), { requestInit, - sessionId: server.sessionId, + sessionId: options.sessionId, reconnectionOptions: server.reconnectionOptions, authProvider: options.authProvider }) @@ -225,7 +674,7 @@ const createSseTransport = ( options: TransportFactoryOptions = {} ): Transport => { const requestInit = options.requestInit ?? buildRequestInit(server.headers) - return new SSEClientTransport(new URL(server.url), { + return new SSEClientTransport(new URL(options.url ?? server.url), { requestInit, authProvider: options.authProvider }) @@ -238,27 +687,431 @@ const globalEnv = { ...(process.env.PATH ? { PATH: process.env.PATH } : {}) } +export interface UserConnection { + username: string + serverName: string + client: Client + transport: Transport + sessionId?: string + status: 'connected' | 'connecting' | 'disconnected' | 'error' + logs?: string[] + eventHandlers?: { + transportErrorHandler?: (error: Error) => void + transportCloseHandler?: () => void + } +} + +export type UserConnectionTeardownReason = + | 'shutdown' + | 'oauth_updated' + | 'session_expired' + | 'server_updated' + | 'server_disabled' + | 'server_deleted' + +export type UserConnectionTeardownPolicy = { + terminateSession: boolean + clearPersistedSession: boolean +} + +export const resolveUserConnectionTeardownPolicy = ( + reason: UserConnectionTeardownReason +): UserConnectionTeardownPolicy => { + switch (reason) { + case 'server_updated': + case 'server_disabled': + case 'server_deleted': + return { terminateSession: true, clearPersistedSession: true } + case 'oauth_updated': + return { terminateSession: false, clearPersistedSession: true } + case 'session_expired': + case 'shutdown': + return { terminateSession: false, clearPersistedSession: false } + } +} + +const normalizeExternalAuthError = ( + error: unknown, + username: string, + serverName: string +): unknown => { + if (error instanceof McpReauthRequiredError) { + return error + } + if (error instanceof UnauthorizedError) { + return new McpReauthRequiredError({ + serverName, + username, + message: error.message + }) + } + return error +} + export class MCPService implements Resource { public servers: Record = {} + public userConnections: Record = {} private list: MCPServer[] = [] private serverKeys: string[] = [] + private resourceUriBackfillInFlight = new Map>() + private resourceUriBackfillCooldownUntil = new Map() private mcpDBClient: MongoDBClient public secretsService: Secrets private oauthTokensService?: McpOAuthTokens + private userSessionsService?: McpUserSessions + private dcrClients?: McpDcrClients + public userServerInstalls: McpUserServerInstalls + private mongoParams: MongoConnectionParams private packageService?: any // Will be set after initialization to avoid circular dependency constructor({ mongoParams, secretsService, - oauthTokensService + oauthTokensService, + userSessionsService, + userServerInstalls, + dcrClients }: { mongoParams: MongoConnectionParams secretsService: Secrets oauthTokensService?: McpOAuthTokens + userSessionsService?: McpUserSessions + userServerInstalls: McpUserServerInstalls + dcrClients?: McpDcrClients }) { + this.mongoParams = mongoParams this.mcpDBClient = new MongoDBClient(mongoParams, mcpIndexes) this.secretsService = secretsService this.oauthTokensService = oauthTokensService + this.userSessionsService = userSessionsService + this.userServerInstalls = userServerInstalls + this.dcrClients = dcrClients + } + + private async fetchDiscoveryResponse(url: string, discoveryDeadlineMs: number): Promise { + const result = await retryWithExponentialBackoff( + async () => { + const remainingBudgetMs = discoveryDeadlineMs - Date.now() + if (remainingBudgetMs <= 0) { + throw new Error('Discovery budget exceeded') + } + + return fetch(url, { + method: 'GET', + redirect: 'follow', + headers: { + Accept: 'application/json, text/plain, */*' + }, + signal: AbortSignal.timeout(Math.min(DISCOVERY_REQUEST_TIMEOUT_MS, remainingBudgetMs)) + }) + }, + () => undefined, + DISCOVERY_MAX_ATTEMPTS, + DISCOVERY_BASE_DELAY_MS + ) + + if (isRecord(result) && 'error' in result && result.error instanceof Error) { + throw result.error + } + + return result as Response + } + + private async fetchDiscoveryJsonDocument( + url: string, + discoveryDeadlineMs: number + ): Promise<{ response: Response; document: Record | null }> { + await validateExternalMcpUrl(url) + const response = await this.fetchDiscoveryResponse(url, discoveryDeadlineMs) + const bodyText = await response.text() + if (!bodyText) { + return { response, document: null } + } + + try { + const parsed = JSON.parse(bodyText) + return { + response, + document: isRecord(parsed) ? parsed : null + } + } catch { + return { response, document: null } + } + } + + private async fetchProtectedResourceMetadataDocument( + resourceMetadataUrl: string, + discoveryDeadlineMs: number + ): Promise> { + const { response, document } = await this.fetchDiscoveryJsonDocument(resourceMetadataUrl, discoveryDeadlineMs) + if (response.status === 404 || response.status === 405) { + throw new Error(`Protected resource metadata not found at ${resourceMetadataUrl} (${response.status})`) + } + if (response.status >= 400) { + throw new Error(`Protected resource metadata request failed at ${resourceMetadataUrl} (${response.status})`) + } + if (!document) { + throw new Error(`Protected resource metadata at ${resourceMetadataUrl} was not valid JSON`) + } + + const authorizationServers = toOptionalStringArray(document.authorization_servers) + if (!authorizationServers || authorizationServers.length === 0) { + throw new Error(`Protected resource metadata at ${resourceMetadataUrl} did not include authorization_servers`) + } + + return document + } + + private resolveProtectedResourceUri(metadata: Record, transportUrl: string): string { + return typeof metadata.resource === 'string' + ? metadata.resource + : canonicalizeExternalOAuthResourceUri(transportUrl) + } + + private async discoverProtectedResourceMetadata( + serverUrl: URL, + discoveryDeadlineMs: number + ): Promise<{ + resourceMetadataUrl: string + resourceUri: string + challengedScopes?: string[] + metadata: Record + attemptedUrls: string[] + }> { + const attemptedUrls: string[] = [] + let challengedScopes: string[] | undefined + let resourceMetadataUrlFromChallenge: string | undefined + + try { + const probeResponse = await this.fetchDiscoveryResponse(serverUrl.toString(), discoveryDeadlineMs) + if (probeResponse.status === 401) { + const parsedHeader = parseWwwAuthenticateHeader(probeResponse.headers.get('www-authenticate')) + challengedScopes = parsedHeader.challengedScopes + resourceMetadataUrlFromChallenge = parsedHeader.resourceMetadataUrl + } + } catch (error) { + log({ + level: 'warn', + msg: `Unauthenticated external MCP discovery probe failed for ${serverUrl.toString()}; continuing with RFC 9728 fallback`, + error + }) + } + + const candidateUrls = [ + ...(resourceMetadataUrlFromChallenge ? [resourceMetadataUrlFromChallenge] : []), + ...buildProtectedResourceMetadataCandidates(serverUrl) + ] + + let lastCause = 'No protected resource metadata candidate succeeded' + for (const candidateUrl of Array.from(new Set(candidateUrls))) { + attemptedUrls.push(candidateUrl) + try { + const document = await this.fetchProtectedResourceMetadataDocument(candidateUrl, discoveryDeadlineMs) + + return { + resourceMetadataUrl: candidateUrl, + resourceUri: this.resolveProtectedResourceUri(document, serverUrl.toString()), + challengedScopes, + metadata: document, + attemptedUrls + } + } catch (error) { + lastCause = error instanceof Error ? error.message : String(error) + } + } + + throw new McpDiscoveryFailedError( + 'Unable to discover Protected Resource Metadata for the external MCP server', + attemptedUrls, + lastCause + ) + } + + private async discoverAuthorizationServerMetadata( + issuer: string, + discoveryDeadlineMs: number + ): Promise { + const issuerUrl = await validateExternalMcpUrl(issuer) + const candidateUrls = buildAuthorizationServerMetadataCandidates(issuerUrl) + const attemptedUrls: string[] = [] + const documents: SuccessfulAuthorizationServerMetadataDocument[] = [] + let lastCause = 'No authorization server metadata candidate succeeded' + + for (const candidateUrl of candidateUrls) { + attemptedUrls.push(candidateUrl) + try { + const { response, document } = await this.fetchDiscoveryJsonDocument(candidateUrl, discoveryDeadlineMs) + if (response.status === 404 || response.status === 405) { + lastCause = `Authorization server metadata not found at ${candidateUrl} (${response.status})` + continue + } + if (response.status >= 400) { + lastCause = `Authorization server metadata request failed at ${candidateUrl} (${response.status})` + continue + } + if (!document) { + lastCause = `Authorization server metadata at ${candidateUrl} was not valid JSON` + continue + } + + documents.push({ + url: candidateUrl, + document + }) + } catch (error) { + lastCause = error instanceof Error ? error.message : String(error) + } + } + + if (documents.length > 0) { + return { + documents, + attemptedUrls + } + } + + throw new McpDiscoveryFailedError( + `Unable to discover authorization server metadata for issuer ${issuer}`, + attemptedUrls, + lastCause + ) + } + + private async discoverCompatibilityAuthorizationMetadata( + resourceUri: string, + issuer: string, + discoveryDeadlineMs: number + ): Promise { + if (new URL(resourceUri).origin === new URL(issuer).origin) { + return [] + } + + const resourceOriginUrl = await validateExternalMcpUrl(new URL(resourceUri).origin) + const candidateUrls = buildAuthorizationServerMetadataCandidates(resourceOriginUrl) + const documents: SuccessfulAuthorizationServerMetadataDocument[] = [] + + for (const candidateUrl of candidateUrls) { + try { + const { response, document } = await this.fetchDiscoveryJsonDocument(candidateUrl, discoveryDeadlineMs) + if (response.status >= 400 || !document) { + continue + } + + const hasCompatibleField = + typeof document.registration_endpoint === 'string' || + normalizeTokenEndpointAuthMethods(toOptionalStringArray(document.token_endpoint_auth_methods_supported)).length > 0 || + document.client_id_metadata_document_supported === true + + if (!hasCompatibleField) { + continue + } + + documents.push({ + url: candidateUrl, + document + }) + } catch { + continue + } + } + + return documents + } + + private buildMergedAuthorizationServer( + issuer: string, + scopesSupported: string[] | undefined, + primaryDocuments: SuccessfulAuthorizationServerMetadataDocument[], + compatibilityDocuments: SuccessfulAuthorizationServerMetadataDocument[] + ): DiscoveredAuthorizationServer { + return buildMergedAuthorizationServerResult({ + issuer, + scopesSupported, + primaryDocuments, + compatibilityDocuments + }) + } + + public async discoverExternalAuthorization( + input: DiscoverExternalAuthorizationInput + ): Promise { + const knownProviderUrlError = getKnownProviderUrlError(input.url) + if (knownProviderUrlError) { + throw new McpValidationError(knownProviderUrlError) + } + const sanitizedInputUrl = buildExtractedExternalUrlSecretMetadata(input.url, undefined).sanitizedUrl + const serverUrl = await validateExternalMcpUrl(sanitizedInputUrl) + const discoveryDeadlineMs = Date.now() + DISCOVERY_TOTAL_BUDGET_MS + const { resourceMetadataUrl, resourceUri, challengedScopes, metadata } = await this.discoverProtectedResourceMetadata( + serverUrl, + discoveryDeadlineMs + ) + + const authorizationServers = toOptionalStringArray(metadata.authorization_servers) + if (!authorizationServers || authorizationServers.length === 0) { + throw new McpDiscoveryFailedError( + 'Protected Resource Metadata did not include any authorization server issuers', + [resourceMetadataUrl], + 'authorization_servers was empty' + ) + } + + const scopesSupported = toOptionalStringArray(metadata.scopes_supported) + const discoveredAuthorizationServers: DiscoveredAuthorizationServer[] = [] + const attemptedIssuerMetadataUrls: string[] = [] + const invalidIssuerMessages: string[] = [] + + for (const issuer of authorizationServers) { + try { + const { documents, attemptedUrls } = + await this.discoverAuthorizationServerMetadata(issuer, discoveryDeadlineMs) + attemptedIssuerMetadataUrls.push(...attemptedUrls) + + const primaryDocument = selectPrimaryAuthorizationServerMetadataDocument(documents) + if (!primaryDocument) { + invalidIssuerMessages.push(`Issuer ${issuer} does not advertise PKCE ${REQUIRED_PKCE_CHALLENGE_METHOD}`) + continue + } + + const compatibilityDocuments = await this.discoverCompatibilityAuthorizationMetadata( + resourceUri, + issuer, + discoveryDeadlineMs + ) + + discoveredAuthorizationServers.push( + this.buildMergedAuthorizationServer(issuer, scopesSupported, documents, compatibilityDocuments) + ) + } catch (error) { + if (error instanceof McpDiscoveryFailedError) { + attemptedIssuerMetadataUrls.push(...error.details?.attemptedUrls ?? []) + invalidIssuerMessages.push(error.message) + continue + } + throw error + } + } + + if (discoveredAuthorizationServers.length === 0) { + throw new McpDiscoveryFailedError( + 'Unable to discover a usable authorization server for the external MCP server', + [resourceMetadataUrl, ...attemptedIssuerMetadataUrls], + invalidIssuerMessages.join('; ') + ) + } + + return { + serverUrl: serverUrl.toString(), + resourceMetadataUrl, + resourceUri, + ...(challengedScopes && challengedScopes.length > 0 ? { challengedScopes } : {}), + authorizationServers: discoveredAuthorizationServers, + recommendedRegistrationMode: discoveredAuthorizationServers.some((server) => server.clientIdMetadataDocumentSupported === true) + ? 'cimd' + : discoveredAuthorizationServers.some((server) => typeof server.registrationEndpoint === 'string') + ? 'dcr' + : 'manual' + } } /** @@ -330,7 +1183,6 @@ export class MCPService implements Resource { transportType: 'streamable_http', url: server.url, headers: server.headers, - sessionId: server.sessionId ?? undefined, reconnectionOptions: server.reconnectionOptions } @@ -386,67 +1238,176 @@ export class MCPService implements Resource { return normalized } + private shouldBackfillExternalOAuthResourceUri(server: MCPServerRecord): boolean { + return ( + (server.source ?? 'platform') === 'external' && + (server.authMode ?? 'none') === 'oauth2' && + server.transportType !== 'stdio' && + typeof server.url === 'string' && + !!server.oauthTemplate && + typeof server.oauthTemplate.resourceUri !== 'string' + ) + } + + private scheduleExternalOAuthResourceUriBackfill(server: MCPServerRecord): void { + if (!this.shouldBackfillExternalOAuthResourceUri(server)) { + return + } + + const cooldownUntil = this.resourceUriBackfillCooldownUntil.get(server.name) + if (typeof cooldownUntil === 'number' && cooldownUntil > Date.now()) { + return + } + if (this.resourceUriBackfillInFlight.has(server.name)) { + return + } + + const task = this.backfillExternalOAuthResourceUri(server) + .catch((error) => { + this.resourceUriBackfillCooldownUntil.set(server.name, Date.now() + RESOURCE_URI_BACKFILL_FAILURE_COOLDOWN_MS) + log({ + level: 'warn', + msg: `Failed to backfill external OAuth resourceUri for server ${server.name}: ${ + error instanceof Error ? error.message : String(error) + }` + }) + }) + .finally(() => { + this.resourceUriBackfillInFlight.delete(server.name) + }) + + this.resourceUriBackfillInFlight.set(server.name, task) + } + + private async backfillExternalOAuthResourceUri(server: MCPServerRecord): Promise { + if (!server.url || !server.oauthTemplate) { + return + } + + const resourceUri = await this.resolveAuthoritativeResourceUri( + server.url, + server.oauthTemplate.discoveryMode, + server.oauthTemplate.resourceMetadataUrl + ) + const persistedOauthTemplate: McpExternalOAuthTemplate = { + ...server.oauthTemplate, + resourceUri + } + + await this.mcpDBClient.update( + { + oauthTemplate: persistedOauthTemplate, + updatedAt: new Date() + }, + { name: server.name } + ) + + const serverKey = buildServerKey({ name: server.name }) + if (this.servers[serverKey]?.transportType === 'streamable_http' && this.servers[serverKey].oauthTemplate) { + this.servers[serverKey].oauthTemplate = persistedOauthTemplate + } + + this.resourceUriBackfillCooldownUntil.delete(server.name) + } + + private normalizeExternalOAuthTemplateForRuntime(server: MCPServerRecord): McpExternalOAuthTemplate | undefined { + if (!server.oauthTemplate) { + return server.oauthTemplate + } + if ((server.source ?? 'platform') !== 'external' || (server.authMode ?? 'none') !== 'oauth2' || !server.url) { + return server.oauthTemplate + } + + return { + ...server.oauthTemplate, + resourceUri: + server.oauthTemplate.resourceUri ?? canonicalizeExternalOAuthResourceUri(server.url) + } + } + private async normalizeServerRecord(server: MCPServerRecord): Promise { const withSecrets = await this.migrateServerSecrets(server) - return this.migrateServerTransport(withSecrets) + if (this.shouldBackfillExternalOAuthResourceUri(withSecrets)) { + this.scheduleExternalOAuthResourceUriBackfill(withSecrets) + } + + const withRuntimeOauthTemplate: MCPServerRecord = { + ...withSecrets, + oauthTemplate: this.normalizeExternalOAuthTemplateForRuntime(withSecrets) + } + return this.migrateServerTransport(withRuntimeOauthTemplate) } - private async buildTransportOptions(server: MCPServer): Promise { + private async buildTransportOptions(server: MCPServer, username?: string): Promise { if (server.transportType !== 'streamable_http') { return {} } - if (!this.oauthTokensService) { - return {} + const runtimeUrl = + this.resolveServerSource(server) === 'external' && username + ? buildExternalUrlWithSecretQueryParams( + server.url, + await this.secretsService.getUserServerSecrets(server.name, username) + ) + : undefined + + if (!this.oauthTokensService || !username) { + return runtimeUrl ? { url: runtimeUrl } : {} } - const record = await this.oauthTokensService.getTokenRecord(server.name) + const record = await this.oauthTokensService.getTokenRecord(server.name, username) if (!record) { - return {} + return runtimeUrl ? { url: runtimeUrl } : {} } const authProvider = new McpOAuthClientProvider({ serverName: server.name, + username, tokenStore: this.oauthTokensService, - record + record, + tokenEndpoint: server.oauthTemplate?.tokenEndpoint ?? new URL('/token', server.url).toString(), + resource: server.oauthTemplate?.resourceUri ?? canonicalizeExternalOAuthResourceUri(server.url), + issuer: server.oauthTemplate?.authorizationServerIssuer, + registrationEndpoint: server.oauthTemplate?.registrationEndpoint, + tokenEndpointAuthMethodsSupported: server.oauthTemplate?.tokenEndpointAuthMethodsSupported, + dcrClients: this.dcrClients }) const sanitizedHeaders = stripAuthorizationHeaders(server.headers) return { authProvider, - requestInit: buildRequestInit(sanitizedHeaders ?? {}) + requestInit: buildRequestInit(sanitizedHeaders ?? {}), + ...(runtimeUrl ? { url: runtimeUrl } : {}) } } - private async persistSessionId( - serverKey: string, - server: MCPServer, + private async persistUserSessionId( + serverName: string, + username: string, transport: StreamableHTTPClientTransport ): Promise { - if (server.transportType !== 'streamable_http') { - return - } const nextSessionId = transport.sessionId - if (nextSessionId === server.sessionId) { + const userKey = buildUserServerKey(username, serverName) + const userConn = this.userConnections[userKey] + if (userConn && nextSessionId === userConn.sessionId) { return } - server.sessionId = nextSessionId - const current = this.servers[serverKey] - if (current && current.transportType === 'streamable_http') { - current.sessionId = nextSessionId + if (userConn) { + userConn.sessionId = nextSessionId + } + if (this.userSessionsService) { + await this.userSessionsService.upsertSession(serverName, username, nextSessionId) } - await this.mcpDBClient.update({ sessionId: nextSessionId }, { name: server.name }) } - private async clearServerSessionId(serverKey: string, server: MCPServer): Promise { - if (server.transportType !== 'streamable_http') { - return + private async clearUserSessionId(serverName: string, username: string): Promise { + const userKey = buildUserServerKey(username, serverName) + const userConn = this.userConnections[userKey] + if (userConn) { + userConn.sessionId = undefined } - server.sessionId = undefined - const current = this.servers[serverKey] - if (current && current.transportType === 'streamable_http') { - current.sessionId = undefined + if (this.userSessionsService) { + await this.userSessionsService.clearSession(serverName, username) } - await this.mcpDBClient.update({ sessionId: undefined }, { name: server.name }) } /** @@ -455,6 +1416,11 @@ export class MCPService implements Resource { private builtInToMCPServer(builtInServer: BuiltInServer): MCPServer { return { name: builtInServer.externalName, // Use external name for public-facing API + displayName: builtInServer.externalName, + description: '', + source: 'platform', + authMode: 'none', + secretFields: [], transportType: 'stdio', command: 'built-in', // Special marker args: [], @@ -469,6 +1435,228 @@ export class MCPService implements Resource { } } + private resolveServerSource(server: MCPServer): McpServerSource { + return server.source ?? 'platform' + } + + private assertAbsoluteUrl(value: string, fieldName: string): void { + try { + new URL(value) + } catch { + throw new McpValidationError(`${fieldName} must be a valid absolute URL`) + } + } + + private assertAbsoluteHttpsUrl(value: string, fieldName: string): void { + let parsed: URL + try { + parsed = new URL(value) + } catch { + throw new McpValidationError(`${fieldName} must be a valid absolute HTTPS URL`) + } + if (parsed.protocol !== 'https:') { + throw new McpValidationError(`${fieldName} must use https`) + } + } + + private async resolveAuthoritativeResourceUri( + transportUrl: string, + discoveryMode: McpExternalOAuthTemplate['discoveryMode'], + resourceMetadataUrl?: string + ): Promise { + if (discoveryMode !== 'auto') { + return canonicalizeExternalOAuthResourceUri(transportUrl) + } + if (!resourceMetadataUrl) { + throw new McpValidationError('oauthTemplate.resourceMetadataUrl is required in discovery mode') + } + + const discoveryDeadlineMs = Date.now() + DISCOVERY_TOTAL_BUDGET_MS + const metadata = await this.fetchProtectedResourceMetadataDocument(resourceMetadataUrl, discoveryDeadlineMs) + return this.resolveProtectedResourceUri(metadata, transportUrl) + } + + private validateExternalOAuthTemplate( + authMode: McpServerAuthMode, + oauthTemplate?: McpExternalOAuthTemplate, + transportUrl?: string + ): void { + if (authMode !== 'oauth2') { + return + } + + if (!oauthTemplate) { + throw new McpValidationError('oauthTemplate is required when authMode is oauth2') + } + + this.assertAbsoluteUrl(oauthTemplate.authorizationEndpoint, 'oauthTemplate.authorizationEndpoint') + this.assertAbsoluteUrl(oauthTemplate.tokenEndpoint, 'oauthTemplate.tokenEndpoint') + if (!oauthTemplate.resourceUri) { + throw new McpValidationError('oauthTemplate.resourceUri is required for external OAuth servers') + } + + if (oauthTemplate.pkceRequired !== true) { + throw new McpValidationError('oauthTemplate.pkceRequired must be true for external OAuth servers') + } + if (!oauthTemplate.codeChallengeMethodsSupported.includes(REQUIRED_PKCE_CHALLENGE_METHOD)) { + throw new McpValidationError( + `oauthTemplate.codeChallengeMethodsSupported must include ${REQUIRED_PKCE_CHALLENGE_METHOD}` + ) + } + + if (oauthTemplate.discoveryMode === 'auto') { + this.assertAbsoluteUrl( + oauthTemplate.authorizationServerMetadataUrl, + 'oauthTemplate.authorizationServerMetadataUrl' + ) + this.assertAbsoluteUrl(oauthTemplate.resourceMetadataUrl, 'oauthTemplate.resourceMetadataUrl') + if (!oauthTemplate.authorizationServerIssuer.trim()) { + throw new McpValidationError('oauthTemplate.authorizationServerIssuer is required in discovery mode') + } + if ( + oauthTemplate.registrationMode === 'dcr' && + !oauthTemplate.registrationEndpoint + ) { + throw new McpValidationError('oauthTemplate.registrationEndpoint is required when registrationMode is dcr') + } + if ( + oauthTemplate.registrationMode === 'dcr' && + normalizeTokenEndpointAuthMethods(oauthTemplate.tokenEndpointAuthMethodsSupported).length === 0 + ) { + throw new McpValidationError( + 'oauthTemplate.tokenEndpointAuthMethodsSupported must include a supported auth method for dcr' + ) + } + } else { + this.assertAbsoluteHttpsUrl(oauthTemplate.authorizationEndpoint, 'oauthTemplate.authorizationEndpoint') + this.assertAbsoluteHttpsUrl(oauthTemplate.tokenEndpoint, 'oauthTemplate.tokenEndpoint') + if (oauthTemplate.registrationMode !== 'manual') { + throw new McpValidationError('Manual OAuth template mode must use manual registration mode') + } + if (!transportUrl) { + throw new McpValidationError('External OAuth transport url is required for manual mode validation') + } + if (oauthTemplate.resourceUri !== canonicalizeExternalOAuthResourceUri(transportUrl)) { + throw new McpValidationError('oauthTemplate.resourceUri must equal the canonicalized transport url in manual mode') + } + } + + if ( + oauthTemplate.registrationMode === 'cimd' && + oauthTemplate.clientIdMetadataDocumentSupported !== true + ) { + throw new McpValidationError( + 'oauthTemplate.clientIdMetadataDocumentSupported must be true when registrationMode is cimd' + ) + } + if (oauthTemplate.registrationMode === 'dcr' && oauthTemplate.discoveryMode !== 'auto') { + throw new McpValidationError('DCR requires discovery-backed OAuth metadata') + } + } + + private async normalizeExternalOAuthTemplateForPersistence( + authMode: McpServerAuthMode, + transportUrl: string, + oauthTemplate?: McpExternalOAuthTemplate + ): Promise { + if (authMode !== 'oauth2') { + return oauthTemplate + } + + if (!oauthTemplate) { + throw new McpValidationError('oauthTemplate is required when authMode is oauth2') + } + + if (oauthTemplate.discoveryMode === 'auto') { + this.assertAbsoluteUrl( + oauthTemplate.authorizationServerMetadataUrl, + 'oauthTemplate.authorizationServerMetadataUrl' + ) + this.assertAbsoluteUrl(oauthTemplate.resourceMetadataUrl, 'oauthTemplate.resourceMetadataUrl') + if (!oauthTemplate.authorizationServerIssuer.trim()) { + throw new McpValidationError('oauthTemplate.authorizationServerIssuer is required in discovery mode') + } + } + + const normalizedTemplate: McpExternalOAuthTemplate = { + ...oauthTemplate, + resourceUri: await this.resolveAuthoritativeResourceUri( + transportUrl, + oauthTemplate.discoveryMode, + oauthTemplate.resourceMetadataUrl + ) + } + + this.validateExternalOAuthTemplate(authMode, normalizedTemplate, transportUrl) + return normalizedTemplate + } + + private resolveAuthState(server: MCPServer, install?: McpUserExternalServerInstallRecord): UserInstallAuthState | undefined { + if (this.resolveServerSource(server) !== 'external') { + return 'not_required' + } + + if ((server.authMode ?? 'none') === 'none') { + return 'not_required' + } + + return install?.authState ?? 'not_connected' + } + + private toUserVisibleServer( + server: MCPServer, + install?: McpUserExternalServerInstallRecord | null, + configuredSecretNames: string[] = [] + ): UserVisibleMcpServer { + const source = this.resolveServerSource(server) + const authMode = server.authMode ?? 'none' + const installed = source === 'platform' ? true : install != null + const enabled = source === 'platform' ? server.enabled : install?.enabled === true + const authState = this.resolveAuthState(server, install ?? undefined) + const authRequired = authMode === 'oauth2' + + return { + name: server.name, + displayName: server.displayName ?? server.name, + description: server.description ?? '', + source, + transportType: server.transportType, + authMode, + installed, + enabled, + authState, + authRequired, + secretFields: server.secretFields ?? [], + secretNames: server.secretNames ?? (server.secretName ? [server.secretName] : []), + configuredSecretNames, + toolsList: server.toolsList, + homepageUrl: server.homepageUrl, + repositoryUrl: server.repositoryUrl, + licenseName: server.licenseName, + canInstall: source === 'external' && !installed, + canUninstall: source === 'external' && installed, + canConfigure: + (source === 'external' && installed) || + ((server.secretNames?.length ?? 0) > 0 || (server.secretName ? 1 : 0) > 0), + canManagePlatformServer: source === 'platform', + statusMessage: install?.lastAuthError, + url: server.transportType === 'streamable_http' ? server.url : undefined, + oauthTemplate: server.oauthTemplate, + oauthClientConfig: + source === 'external' + ? { + clientId: install?.oauthClientId, + scopes: install?.oauthScopes + } + : undefined, + command: server.transportType === 'stdio' ? server.command : undefined, + args: server.transportType === 'stdio' ? server.args : undefined, + env: server.transportType === 'stdio' ? server.env : undefined, + headers: server.transportType === 'streamable_http' ? server.headers : undefined, + logs: server.logs + } + } + public async init() { await this.mcpDBClient.connect('mcp') @@ -512,9 +1700,27 @@ export class MCPService implements Resource { for (const server of this.list) { try { - await this.connectToServer(server) - // Now awaiting the fetchToolsForServer call to catch errors - await this.fetchToolsForServer(server) + if (server.transportType === 'streamable_http') { + // Streamable HTTP servers use lazy per-user connections. + // Register the server definition but do not eagerly connect. + const serverKey = buildServerKey(server) + this.servers[serverKey] = { + ...server, + status: 'disconnected', + logs: [] + } + if (!this.serverKeys.includes(serverKey)) { + this.serverKeys.push(serverKey) + } + log({ + level: 'info', + msg: `[${server.name}] Registered streamable HTTP server (lazy per-user connection).` + }) + } else { + await this.connectToServer(server) + // Now awaiting the fetchToolsForServer call to catch errors + await this.fetchToolsForServer(server) + } } catch (error) { log({ level: 'error', @@ -526,7 +1732,157 @@ export class MCPService implements Resource { log({ level: 'info', msg: `MCPService initialized with ${this.list.length} servers` }) } - private async connectToServer(server: MCPServer, allowSessionRetry = true) { + public async getUserServers(username: string): Promise { + const sharedServers = Object.values(this.servers) + const installs = await this.userServerInstalls.listInstallsForUser(username) + const installsByServer = new Map(installs.map((install) => [install.serverName, install])) + const configuredSecretNamesEntries = await Promise.all( + sharedServers.map(async (server) => [ + server.name, + await this.secretsService.listSecretNamesByServerPrefix(server.name, username) + ] as const) + ) + const configuredSecretNamesByServer = new Map(configuredSecretNamesEntries) + + return sharedServers.flatMap((server) => { + const source = this.resolveServerSource(server) + if (source !== 'external') { + if (server.enabled !== true) return [] + return [this.toUserVisibleServer(server, undefined, configuredSecretNamesByServer.get(server.name) ?? [])] + } + + if (server.enabled !== true) { + return [] + } + + const install = installsByServer.get(server.name) + return [this.toUserVisibleServer(server, install, configuredSecretNamesByServer.get(server.name) ?? [])] + }) + } + + public async getUserServer(username: string, serverName: string): Promise { + const server = await this.getServer(serverName) + if (!server) { + return null + } + + const install = this.resolveServerSource(server) === 'external' + ? await this.userServerInstalls.getInstall(serverName, username) + : undefined + const configuredSecretNames = await this.secretsService.listSecretNamesByServerPrefix(serverName, username) + + return this.toUserVisibleServer(server, install, configuredSecretNames) + } + + public async getUserServerInstallDetails( + username: string, + serverName: string + ): Promise { + const install = await this.userServerInstalls.getInstall(serverName, username) + if (!install) { + return null + } + + return { + serverName: install.serverName, + username: install.username, + enabled: install.enabled, + authState: install.authState, + oauthClientId: install.oauthClientId, + oauthClientSecret: install.oauthClientSecret, + oauthScopes: install.oauthScopes + } + } + + public async resolveExternalOAuthClientContext( + input: ResolveExternalOAuthClientInput + ): Promise { + const server = await this.getServer(input.serverName) + if (!server) { + throw new McpServerNotFoundError(input.serverName) + } + if (this.resolveServerSource(server) !== 'external' || server.authMode !== 'oauth2' || !server.oauthTemplate) { + throw new McpValidationError(`Server ${input.serverName} is not an external OAuth server`) + } + + const registrationMode = server.oauthTemplate.registrationMode + if (registrationMode === 'manual') { + const install = await this.userServerInstalls.getInstall(input.serverName, input.username) + if (!install?.oauthClientId) { + throw new McpValidationError(`Manual OAuth clientId is not configured for user ${input.username}`) + } + return { + registrationMode, + clientId: install.oauthClientId, + clientSecret: install.oauthClientSecret, + tokenEndpointAuthMethod: install.oauthClientSecret ? 'client_secret_post' : 'none' + } + } + + if (registrationMode === 'cimd') { + return { + registrationMode, + clientId: input.oauthProvisioningContext.clientMetadataUrl, + tokenEndpointAuthMethod: 'none' + } + } + + if (!this.dcrClients || !server.oauthTemplate.registrationEndpoint) { + throw new McpValidationError(`DCR client provisioning is not available for server ${input.serverName}`) + } + + const registration = await this.dcrClients.getOrRegisterClient({ + issuer: server.oauthTemplate.authorizationServerIssuer, + registrationEndpoint: server.oauthTemplate.registrationEndpoint, + tokenEndpointAuthMethodsSupported: server.oauthTemplate.tokenEndpointAuthMethodsSupported, + oauthProvisioningContext: input.oauthProvisioningContext + }) + + return { + registrationMode, + clientId: registration.clientId, + clientSecret: registration.clientSecret, + tokenEndpointAuthMethod: registration.tokenEndpointAuthMethod + } + } + + public async getUserTools(username: string): Promise { + const servers = await this.getUserServers(username) + + return servers + .filter((server) => { + if (server.source !== 'external') { + return server.enabled === true + } + + return server.installed === true && server.enabled === true + }) + .map((server) => ({ + [server.name]: [...(server.toolsList ?? [])] + })) + } + + public async getUserServerTools(username: string, serverName: string): Promise { + const server = await this.getServer(serverName) + if (!server) { + throw new McpServerNotFoundError(serverName) + } + + if (this.resolveServerSource(server) === 'external') { + const install = await this.userServerInstalls.getInstall(serverName, username) + if (!install || install.enabled !== true) { + throw new McpValidationError(`Server ${serverName} is not installed for user ${username}`) + } + if ((server.authMode ?? 'none') === 'oauth2' && install.authState !== 'connected') { + throw new McpAuthNotConnectedError(serverName, username) + } + await this.refreshUserServer(serverName, username) + } + + return this.servers[buildServerKey(server)]?.toolsList ?? [] + } + + private async connectToServer(server: MCPServer) { const serverKey = buildServerKey(server) const transportErrorHandler = async (error: Error) => { log({ level: 'error', msg: `${serverKey} transport error: ${error.message}`, error }) @@ -626,118 +1982,248 @@ export class MCPService implements Resource { await this.servers[serverKey].connection!.client.connect(this.servers[serverKey].connection!.transport) this.servers[serverKey].status = 'connected' - if (server.transportType === 'streamable_http' && transport instanceof StreamableHTTPClientTransport) { - await this.persistSessionId(serverKey, server, transport) - } log({ level: 'info', msg: `[${server.name}] Connected successfully. Will fetch tool list.` }) } catch (error) { + log({ + level: 'error', + msg: `[${server.name}] Failed to start or connect to server.`, + error: error + }) + + if (this.servers[serverKey]) { + this.servers[serverKey].status = 'error' + } + + // Attempt to install missing package if PackageService is available + let installSuccess = false + if (server.transportType === 'stdio' && this.packageService) { + log({ level: 'info', msg: `Attempting to install missing package for server ${server.name}` }) + installSuccess = await this.packageService.installMissingPackage(server.name) + } + + // If installation failed or no PackageService, mark stdio servers as disabled + if (server.transportType === 'stdio' && !installSuccess) { + // Mark server as disabled in the database + server.enabled = false + await this.mcpDBClient.update(server, { name: server.name }) + log({ level: 'info', msg: `Server ${server.name} has been disabled due to startup failure` }) + } + } + } + + /** + * Establishes a per-user connection to a streamable HTTP server. + * Uses persisted session ID and OAuth tokens for the given user. + */ + public async connectUserToServer( + username: string, + server: MCPServer, + allowSessionRetry = true + ): Promise { + if (server.transportType !== 'streamable_http') { + throw new Error(`connectUserToServer is only for streamable_http servers, got ${server.transportType}`) + } + + await validateExternalMcpUrl(server.url) + + const userKey = buildUserServerKey(username, server.name) + + // Check if already connected + const existing = this.userConnections[userKey] + if (existing && existing.status === 'connected') { + return existing + } + + // Load persisted session for this user + let sessionId: string | undefined + if (this.userSessionsService) { + const sessionRecord = await this.userSessionsService.getSession(server.name, username) + sessionId = sessionRecord?.sessionId ?? undefined + } + + const transportErrorHandler = async (error: Error) => { + log({ level: 'error', msg: `[${username}:${server.name}] transport error: ${error.message}`, error }) + const conn = this.userConnections[userKey] + if (conn) { + conn.logs?.push(error.message) + } + } + + const transportCloseHandler = async () => { + log({ level: 'info', msg: `[${username}:${server.name}] Transport closed.` }) + const conn = this.userConnections[userKey] + if (conn) { + conn.status = 'disconnected' + } + } + + try { + const client = new Client( + { name: 'MSQStdioClient', version: '1.0.0' }, + { capabilities: { prompts: {}, resources: {}, tools: {} } } + ) + const transportOptions = await this.buildTransportOptions(server, username) + const transport = createTransport(server, { ...transportOptions, sessionId }) + + const userConn: UserConnection = { + username, + serverName: server.name, + client, + transport, + sessionId, + status: 'connecting', + logs: [], + eventHandlers: { + transportErrorHandler, + transportCloseHandler + } + } + this.userConnections[userKey] = userConn + + transport.onerror = transportErrorHandler + transport.onclose = transportCloseHandler + + log({ + level: 'info', + msg: `[${username}:${server.name}] Connecting to streamable HTTP server at ${server.url}` + }) + + await client.connect(transport) + userConn.status = 'connected' + + if (transport instanceof StreamableHTTPClientTransport) { + await this.persistUserSessionId(server.name, username, transport) + } + log({ level: 'info', msg: `[${username}:${server.name}] Connected successfully.` }) + + return userConn + } catch (error) { + // Session expired — retry without sessionId if ( - server.transportType === 'streamable_http' && - server.sessionId && + sessionId && allowSessionRetry && extractHttpStatusFromError(error) === 404 ) { log({ level: 'warn', - msg: `[${server.name}] Streamable HTTP session expired. Clearing sessionId and retrying without it.` + msg: `[${username}:${server.name}] Session expired. Clearing and retrying.` }) - await this.clearServerSessionId(serverKey, server) - try { - await this.teardownServerConnection(serverKey) - } catch (cleanupError) { - log({ - level: 'warn', - msg: `[${server.name}] Failed to close transport after session expiration: ${ - (cleanupError as Error).message - }` - }) - } - const retryServer: MCPServer = { - ...server, - sessionId: undefined - } - await this.connectToServer(retryServer, false) - return + await this.clearUserSessionId(server.name, username) + await this.teardownUserConnection(userKey, 'session_expired') + return this.connectUserToServer(username, server, false) } - if (server.transportType === 'streamable_http' && shouldFallbackToSse(error)) { + // Fallback to SSE + if (shouldFallbackToSse(error)) { log({ level: 'warn', - msg: `[${server.name}] Streamable HTTP initialize failed with legacy status. Falling back to SSE transport.` + msg: `[${username}:${server.name}] Streamable HTTP failed. Falling back to SSE.` }) - try { - if (this.servers[serverKey]?.connection?.transport) { - this.servers[serverKey].connection!.transport.onerror = undefined - this.servers[serverKey].connection!.transport.onclose = undefined - await this.servers[serverKey].connection!.transport.close() + // Clean up failed transport + const failedConn = this.userConnections[userKey] + if (failedConn?.transport) { + failedConn.transport.onerror = undefined + failedConn.transport.onclose = undefined + try { + await failedConn.transport.close() + } catch { + // ignore cleanup errors } - } catch (cleanupError) { - log({ - level: 'warn', - msg: `[${server.name}] Failed to close Streamable HTTP transport during fallback: ${ - (cleanupError as Error).message - }` - }) } const fallbackClient = new Client( { name: 'MSQStdioClient', version: '1.0.0' }, { capabilities: { prompts: {}, resources: {}, tools: {} } } ) - const transportOptions = await this.buildTransportOptions(server) + const transportOptions = await this.buildTransportOptions(server, username) const fallbackTransport = createSseTransport(server, transportOptions) fallbackTransport.onerror = transportErrorHandler fallbackTransport.onclose = transportCloseHandler - this.servers[serverKey].connection = { + const userConn: UserConnection = { + username, + serverName: server.name, client: fallbackClient, - transport: fallbackTransport + transport: fallbackTransport, + status: 'connecting', + logs: [], + eventHandlers: { + transportErrorHandler, + transportCloseHandler + } } + this.userConnections[userKey] = userConn try { await fallbackClient.connect(fallbackTransport) - this.servers[serverKey].status = 'connected' + userConn.status = 'connected' log({ level: 'info', - msg: `[${server.name}] Connected successfully using SSE fallback. Will fetch tool list.` + msg: `[${username}:${server.name}] Connected via SSE fallback.` }) - return + return userConn } catch (fallbackError) { log({ level: 'error', - msg: `[${server.name}] SSE fallback connection failed.`, + msg: `[${username}:${server.name}] SSE fallback also failed.`, error: fallbackError }) } } log({ - level: 'error', - msg: `[${server.name}] Failed to start or connect to server.`, - error: error + level: 'error', + msg: `[${username}:${server.name}] Failed to connect.`, + error }) - if (this.servers[serverKey]) { - this.servers[serverKey].status = 'error' + const conn = this.userConnections[userKey] + if (conn) { + conn.status = 'error' } - // Attempt to install missing package if PackageService is available - let installSuccess = false - if (server.transportType === 'stdio' && this.packageService) { - log({ level: 'info', msg: `Attempting to install missing package for server ${server.name}` }) - installSuccess = await this.packageService.installMissingPackage(server.name) - } + throw normalizeExternalAuthError(error, username, server.name) + } + } - // If installation failed or no PackageService, mark stdio servers as disabled - if (server.transportType === 'stdio' && !installSuccess) { - // Mark server as disabled in the database - server.enabled = false - await this.mcpDBClient.update(server, { name: server.name }) - log({ level: 'info', msg: `Server ${server.name} has been disabled due to startup failure` }) + private async teardownUserConnection( + userKey: UserServerKey, + reason: UserConnectionTeardownReason = 'shutdown' + ): Promise { + const conn = this.userConnections[userKey] + if (!conn) { + return + } + + const policy = resolveUserConnectionTeardownPolicy(reason) + const { transport, client, eventHandlers } = conn + if (eventHandlers?.transportErrorHandler) { + transport.onerror = undefined + } + if (eventHandlers?.transportCloseHandler) { + transport.onclose = undefined + } + + if (policy.clearPersistedSession) { + await this.clearUserSessionId(conn.serverName, conn.username) + } + + if (policy.terminateSession && isStreamableHTTPTransport(transport)) { + try { + await transport.terminateSession() + } catch (error) { + log({ + level: 'warn', + msg: `[${userKey}] Failed to terminate HTTP session: ${(error as Error).message}` + }) } } + + await transport.close() + await client.close() + delete this.userConnections[userKey] } private async teardownServerConnection(serverKey: string): Promise { @@ -846,8 +2332,7 @@ export class MCPService implements Resource { if (builtInRegistry.isBuiltInByExternalName(serverName)) { const builtInServer = builtInRegistry.getByExternalName(serverName) if (!builtInServer) { - log({ level: 'error', msg: `Built-in server with external name ${serverName} not found in registry` }) - return undefined + throw new McpServerNotFoundError(serverName) } log({ level: 'info', msg: `Calling built-in tool - ${serverName}:${methodName}` }) @@ -872,12 +2357,51 @@ export class MCPService implements Resource { const server = Object.values(this.servers).find(server => server.name === serverName) if (!server) { log({ level: 'error', msg: `Server ${serverName} not found` }) - return undefined + throw new McpServerNotFoundError(serverName) + } + if (!server.enabled) { + log({ level: 'error', msg: `Server ${serverName} is disabled` }) + throw new McpServerDisabledError(serverName) } - if (server.status != 'connected') { - log({ level: 'error', msg: `Server ${serverName} not connected. Status: ${server.status}` }) - return undefined + + // Resolve the correct client based on transport type + let callClient: Client + + if (server.transportType === 'streamable_http') { + // Per-user connection for streamable HTTP servers + const userKey = buildUserServerKey(username, serverName) + let userConn = this.userConnections[userKey] + + // Lazy connection: connect on first use + if (!userConn || userConn.status !== 'connected') { + try { + userConn = await this.connectUserToServer(username, server) + } catch (error) { + log({ + level: 'error', + msg: `[${username}:${serverName}] Failed to establish user connection for tool call.`, + error + }) + const normalized = normalizeExternalAuthError(error, username, serverName) + if (normalized instanceof McpReauthRequiredError) { + await this.userServerInstalls.setAuthState(serverName, username, 'reauth_required', normalized.message) + } + throw normalized + } + } + + callClient = userConn.client + } else { + // Shared connection for stdio servers + if (server.status !== 'connected') { + throw new Error(`Server ${serverName} not connected. Status: ${server.status}`) + } + if (!server.connection) { + throw new Error(`Server ${serverName} has no connection`) + } + callClient = server.connection.client } + const allSecrets = await this.secretsService.getSecrets(username) if (allSecrets != null) { // Get server's declared secret names from metadata @@ -920,12 +2444,23 @@ export class MCPService implements Resource { if (server.startupTimeout) { requestOptions.timeout = server.startupTimeout } - const toolResponse = await server.connection!.client.callTool( - { name: methodName, arguments: args }, - CallToolResultSchema, - requestOptions - ) + const toolResponse = await callClient + .callTool( + { name: methodName, arguments: args }, + CallToolResultSchema, + requestOptions + ) + .catch(async error => { + const normalized = normalizeExternalAuthError(error, username, serverName) + if (normalized instanceof McpReauthRequiredError) { + await this.userServerInstalls.setAuthState(serverName, username, 'reauth_required', normalized.message) + } + throw normalized + }) log({ level: 'info', msg: `Tool called - ${serverName}:${methodName}` }) + if (this.resolveServerSource(server) === 'external' && (server.authMode ?? 'none') === 'oauth2') { + await this.userServerInstalls.setAuthState(serverName, username, 'connected') + } if (Array.isArray(toolResponse.content)) { toolResponse.content = toolResponse.content.map(item => { return item @@ -934,6 +2469,211 @@ export class MCPService implements Resource { return toolResponse } + public async installUserServer( + input: Omit + ): Promise { + const server = await this.getServer(input.serverName) + if (!server) { + throw new McpServerNotFoundError(input.serverName) + } + if (this.resolveServerSource(server) !== 'external') { + throw new McpValidationError(`Server ${input.serverName} is not an external server`) + } + + if ( + server.authMode === 'oauth2' && + (server.oauthTemplate?.registrationMode === 'cimd' || server.oauthTemplate?.registrationMode === 'dcr') && + server.oauthTemplate?.manualClientCredentialsAllowed !== true && + (input.oauthClientId || input.oauthClientSecret) + ) { + throw new McpValidationError( + `Server ${input.serverName} uses automatic client registration and does not accept manual client credential overrides` + ) + } + + const install = await this.userServerInstalls.upsertInstall({ + ...input, + authMode: server.authMode ?? 'none' + }) + return this.toUserVisibleServer( + server, + install, + await this.secretsService.listSecretNamesByServerPrefix(input.serverName, input.username) + ) + } + + public async updateUserServerInstall( + input: Omit + ): Promise { + const server = await this.getServer(input.serverName) + if (!server) { + throw new McpServerNotFoundError(input.serverName) + } + if (this.resolveServerSource(server) !== 'external') { + throw new McpValidationError(`Server ${input.serverName} is not an external server`) + } + + const existing = await this.userServerInstalls.getInstall(input.serverName, input.username) + if (!existing) { + throw new McpValidationError(`Server ${input.serverName} is not installed for user ${input.username}`) + } + + if ( + server.authMode === 'oauth2' && + (server.oauthTemplate?.registrationMode === 'cimd' || server.oauthTemplate?.registrationMode === 'dcr') && + server.oauthTemplate?.manualClientCredentialsAllowed !== true && + (input.oauthClientId || input.oauthClientSecret) + ) { + throw new McpValidationError( + `Server ${input.serverName} uses automatic client registration and does not accept manual client credential overrides` + ) + } + + const oauthConfigChanged = + input.oauthClientId !== existing.oauthClientId || + input.oauthClientSecret !== existing.oauthClientSecret || + JSON.stringify(input.oauthScopes ?? []) !== JSON.stringify(existing.oauthScopes ?? []) + + const install = await this.userServerInstalls.upsertInstall({ + ...input, + authMode: server.authMode ?? 'none' + }) + if (oauthConfigChanged) { + if (this.oauthTokensService) { + await this.oauthTokensService.deleteTokenRecord(input.serverName, input.username) + } + if (this.userSessionsService) { + await this.userSessionsService.deleteSession(input.serverName, input.username) + } + const userKey = buildUserServerKey(input.username, input.serverName) + if (this.userConnections[userKey]) { + await this.teardownUserConnection(userKey, 'oauth_updated') + } + await this.userServerInstalls.setAuthState(input.serverName, input.username, 'not_connected') + return this.toUserVisibleServer(server, { + ...install, + authState: 'not_connected', + lastAuthError: undefined + }, await this.secretsService.listSecretNamesByServerPrefix(input.serverName, input.username)) + } + + return this.toUserVisibleServer( + server, + install, + await this.secretsService.listSecretNamesByServerPrefix(input.serverName, input.username) + ) + } + + public async uninstallUserServer(serverName: string, username: string): Promise { + await this.userServerInstalls.deleteInstall(serverName, username) + if (this.oauthTokensService) { + await this.oauthTokensService.deleteTokenRecord(serverName, username) + } + if (this.userSessionsService) { + await this.userSessionsService.deleteSession(serverName, username) + } + await this.secretsService.deleteSecretsByServerPrefix(serverName, username) + + const userKey = buildUserServerKey(username, serverName) + if (this.userConnections[userKey]) { + await this.teardownUserConnection(userKey, 'server_deleted') + } + } + + public async saveUserServerSecrets(input: SaveUserServerSecretsInput): Promise { + const server = await this.getServer(input.serverName) + if (!server) { + throw new McpServerNotFoundError(input.serverName) + } + if (this.resolveServerSource(server) === 'external') { + const install = await this.userServerInstalls.getInstall(input.serverName, input.username) + if (!install) { + throw new McpValidationError(`Server ${input.serverName} is not installed for user ${input.username}`) + } + } + + const allowedSecretNames = new Set([ + ...(server.secretFields ?? []).map((field) => field.name), + ...(server.secretNames ?? []), + ...(server.secretName ? [server.secretName] : []) + ]) + for (const secret of input.secrets) { + if (!allowedSecretNames.has(secret.name)) { + throw new McpValidationError(`Secret ${secret.name} is not declared for server ${input.serverName}`) + } + if (!secret.value || secret.value.trim() === '') { + throw new McpValidationError(`Secret ${secret.name} must be a non-empty string`) + } + } + + await this.secretsService.saveUserServerSecrets(input) + } + + public async refreshUserServer(serverName: string, username: string): Promise { + const server = await this.getServer(serverName) + if (!server) { + throw new McpServerNotFoundError(serverName) + } + if (this.resolveServerSource(server) !== 'external') { + return this.toUserVisibleServer( + server, + undefined, + await this.secretsService.listSecretNamesByServerPrefix(serverName, username) + ) + } + if (server.transportType !== 'streamable_http') { + return this.toUserVisibleServer( + server, + await this.userServerInstalls.getInstall(serverName, username), + await this.secretsService.listSecretNamesByServerPrefix(serverName, username) + ) + } + + const install = await this.userServerInstalls.getInstall(serverName, username) + if (!install || install.enabled !== true) { + throw new McpValidationError(`Server ${serverName} is not installed for user ${username}`) + } + + await validateExternalMcpUrl(server.url) + + try { + const userConn = await this.connectUserToServer(username, server) + const tools = await userConn.client.request({ method: 'tools/list' }, ListToolsResultSchema, { + timeout: server.startupTimeout || 180000, + maxTotalTimeout: 300000 + }) + + const serverKey = buildServerKey(server) + if (this.servers[serverKey]) { + this.servers[serverKey].toolsList = tools.tools + } + await this.mcpDBClient.update({ toolsList: tools.tools, updatedAt: new Date() }, { name: serverName }) + + if ((server.authMode ?? 'none') === 'oauth2') { + await this.userServerInstalls.setAuthState(serverName, username, 'connected') + } + + return this.toUserVisibleServer( + { + ...server, + toolsList: tools.tools + }, + { + ...install, + authState: (server.authMode ?? 'none') === 'oauth2' ? 'connected' : install.authState, + lastAuthError: undefined + }, + await this.secretsService.listSecretNamesByServerPrefix(serverName, username) + ) + } catch (error) { + const normalized = normalizeExternalAuthError(error, username, serverName) + if (normalized instanceof McpReauthRequiredError) { + await this.userServerInstalls.setAuthState(serverName, username, 'reauth_required', normalized.message) + } + throw normalized + } + } + public async setSecret(username: string, secretName: string, secretValue: string) { await this.secretsService.updateSecret({ username, secretName, secretValue, action: 'update' }) } @@ -942,8 +2682,40 @@ export class MCPService implements Resource { await this.secretsService.updateSecret({ username, secretName, secretValue: '', action: 'delete' }) } + public async getSharedServerDeleteImpact(name: string): Promise { + const server = await this.getServer(name) + if (!server) { + throw new McpServerNotFoundError(name) + } + + const installs = this.resolveServerSource(server) === 'external' + ? await this.userServerInstalls.listInstallsForServer(name) + : [] + const tokenRecords = this.oauthTokensService + ? await this.oauthTokensService.listTokenRecordsForServer(name) + : [] + const sessions = this.userSessionsService + ? await this.userSessionsService.listSessionsForServer(name) + : [] + const secretUsernames = await this.secretsService.listUsernamesByServerPrefix(name) + + return { + serverName: name, + source: this.resolveServerSource(server), + authMode: server.authMode ?? 'none', + transportType: server.transportType, + installedUsers: installs.length, + connectedAuthUsers: installs.filter((install) => install.authState === 'connected').length, + oauthTokenUsers: tokenRecords.length, + usersWithSavedSecrets: secretUsernames.length, + activeSessionUsers: sessions.filter((session) => typeof session.sessionId === 'string' && session.sessionId.length > 0).length + } + } + public async addServer(serverData: AddServerInput): Promise { const transportType: MCPTransportType = serverData.transportType ?? 'stdio' + const source: McpServerSource = serverData.source ?? 'platform' + const sharedSessionId = (serverData as AddServerInput & { sessionId?: unknown }).sessionId assertTransportConfigCompatible({ transportType, @@ -952,11 +2724,29 @@ export class MCPService implements Resource { env: serverData.env, url: serverData.url, headers: serverData.headers, - sessionId: serverData.sessionId, + sessionId: sharedSessionId, reconnectionOptions: serverData.reconnectionOptions }) - const { name, secretName, secretNames, enabled = true, startupTimeout } = serverData + const { + name, + secretName, + secretNames, + enabled = true, + startupTimeout, + displayName, + description, + authMode, + oauthTemplate, + secretFields, + homepageUrl, + repositoryUrl, + licenseName, + catalogProvider, + catalogId, + username, + oauthProvisioningContext + } = serverData // Normalize: prefer secretNames, but handle secretName for backward compat let finalSecretNames = secretNames @@ -964,6 +2754,57 @@ export class MCPService implements Resource { finalSecretNames = [secretName] } + const extractedUrlSecrets = + source === 'external' && transportType === 'streamable_http' && serverData.url + ? buildExtractedExternalUrlSecretMetadata(serverData.url, secretFields) + : undefined + const effectiveUrl = extractedUrlSecrets?.sanitizedUrl ?? serverData.url + const effectiveSecretFields = extractedUrlSecrets?.secretFields ?? secretFields + + const normalizedOauthTemplate = + source === 'external' && transportType === 'streamable_http' && effectiveUrl + ? await this.normalizeExternalOAuthTemplateForPersistence(authMode ?? 'none', effectiveUrl, oauthTemplate) + : oauthTemplate + + if (source === 'external') { + if (!username?.trim()) { + throw new McpValidationError('username is required when creating external servers') + } + if (transportType !== 'streamable_http') { + throw new McpValidationError('External servers must use streamable_http transport') + } + if (!effectiveUrl) { + throw new McpValidationError('External servers require a url') + } + const knownProviderUrlError = getKnownProviderUrlError(effectiveUrl) + if (knownProviderUrlError) { + throw new McpValidationError(knownProviderUrlError) + } + await validateExternalMcpUrl(effectiveUrl) + this.validateExternalOAuthTemplate(authMode ?? 'none', normalizedOauthTemplate, effectiveUrl) + if ( + (normalizedOauthTemplate?.registrationMode === 'cimd' || normalizedOauthTemplate?.registrationMode === 'dcr') && + normalizedOauthTemplate?.manualClientCredentialsAllowed !== true && + serverData.oauthClientConfig + ) { + throw new McpValidationError('Automatically registered external servers must not store manual client credentials') + } + if ( + (normalizedOauthTemplate?.registrationMode === 'cimd' || normalizedOauthTemplate?.registrationMode === 'dcr') && + !oauthProvisioningContext + ) { + throw new McpValidationError('oauthProvisioningContext is required for CIMD and DCR external servers') + } + if ((effectiveSecretFields ?? []).length > 5) { + throw new McpValidationError('External servers may declare at most 5 secret fields') + } + for (const field of effectiveSecretFields ?? []) { + if (!/^[A-Za-z_][A-Za-z0-9_]{0,49}$/.test(field.name)) { + throw new McpValidationError(`Invalid secret field name: ${field.name}`) + } + } + } + // Prevent adding servers that conflict with built-in external names const builtInRegistry = BuiltInServerRegistry.getInstance() if (builtInRegistry.isBuiltInByExternalName(name)) { @@ -973,23 +2814,74 @@ export class MCPService implements Resource { // Check if server with this name already exists const existingServer = await this.mcpDBClient.findOne({ name }) if (existingServer) { - throw new Error(`Server with name ${name} already exists`) + if (source === 'external' && username) { + const normalizedExistingServer = await this.normalizeServerRecord({ + ...existingServer, + status: existingServer.status ?? 'disconnected', + enabled: existingServer.enabled !== false + }) + const existingUserVisibleServer = await this.getUserServer(username, name) + throw new McpServerAlreadyExistsError( + name, + existingUserVisibleServer ?? + this.toUserVisibleServer( + normalizedExistingServer, + await this.userServerInstalls.getInstall(name, username), + await this.secretsService.listSecretNamesByServerPrefix(name, username) + ) + ) + } + throw new McpServerAlreadyExistsError(name, undefined) + } + + if ( + source === 'external' && + authMode === 'oauth2' && + normalizedOauthTemplate?.registrationMode === 'dcr' + ) { + if (!this.dcrClients || !oauthProvisioningContext || !normalizedOauthTemplate.registrationEndpoint) { + throw new McpValidationError(`DCR provisioning is not available for server ${name}`) + } + await this.dcrClients.getOrRegisterClient({ + issuer: normalizedOauthTemplate.authorizationServerIssuer, + registrationEndpoint: normalizedOauthTemplate.registrationEndpoint, + tokenEndpointAuthMethodsSupported: normalizedOauthTemplate.tokenEndpointAuthMethodsSupported, + oauthProvisioningContext + }) } let server: MCPServer if (transportType === 'streamable_http') { - if (!serverData.url) { + if (!effectiveUrl) { throw new Error('Streamable HTTP servers require a url.') } - new URL(serverData.url) + if (source === 'external') { + await validateExternalMcpUrl(effectiveUrl) + } else { + new URL(effectiveUrl) + } server = { + id: serverData.id, name, + displayName: displayName ?? name, + description: description ?? '', + source, + authMode: source === 'external' ? authMode ?? 'none' : 'none', + oauthTemplate: normalizedOauthTemplate, + secretFields: effectiveSecretFields ?? [], + homepageUrl, + repositoryUrl, + licenseName, + catalogProvider, + catalogId, + createdBy: username, + createdAt: new Date(), + updatedAt: new Date(), transportType: 'streamable_http', - url: serverData.url, + url: effectiveUrl, headers: serverData.headers, - sessionId: serverData.sessionId, reconnectionOptions: serverData.reconnectionOptions, secretNames: finalSecretNames, // Use normalized value secretName: undefined, // Don't save old format for new servers @@ -1003,7 +2895,21 @@ export class MCPService implements Resource { } server = { + id: serverData.id, name, + displayName: displayName ?? name, + description: description ?? '', + source, + authMode: 'none', + secretFields: secretFields ?? [], + homepageUrl, + repositoryUrl, + licenseName, + catalogProvider, + catalogId, + createdBy: username, + createdAt: new Date(), + updatedAt: new Date(), transportType: 'stdio', command: serverData.command, args: serverData.args ?? [], @@ -1017,8 +2923,38 @@ export class MCPService implements Resource { } await this.mcpDBClient.insert(server) - await this.connectToServer(server) - this.fetchToolsForServer(server) + + if (source === 'external' && username) { + await this.userServerInstalls.upsertInstall({ + serverName: name, + username, + enabled, + authMode: authMode ?? 'none', + oauthClientId: serverData.oauthClientConfig?.clientId, + oauthClientSecret: serverData.oauthClientConfig?.clientSecret, + oauthScopes: serverData.oauthClientConfig?.scopes + }) + if (extractedUrlSecrets && extractedUrlSecrets.secrets.length > 0) { + await this.secretsService.saveUserServerSecrets({ + serverName: name, + username, + secrets: extractedUrlSecrets.secrets + }) + } + } + + if (server.transportType === 'streamable_http') { + // Streamable HTTP: register definition, connections are lazy per-user + const serverKey = buildServerKey(server) + this.servers[serverKey] = { ...server, status: 'disconnected', logs: [] } + if (!this.serverKeys.includes(serverKey)) { + this.serverKeys.push(serverKey) + } + log({ level: 'info', msg: `[${server.name}] Added streamable HTTP server (lazy per-user connection).` }) + } else { + await this.connectToServer(server) + this.fetchToolsForServer(server) + } return server } @@ -1027,6 +2963,7 @@ export class MCPService implements Resource { name: string, serverData: UpdateServerInput ): Promise { + const sharedSessionId = (serverData as UpdateServerInput & { sessionId?: unknown }).sessionId // Prevent updating built-in servers const builtInRegistry = BuiltInServerRegistry.getInstance() if (builtInRegistry.isBuiltInByExternalName(name)) { @@ -1052,7 +2989,7 @@ export class MCPService implements Resource { env: serverData.env, url: serverData.url, headers: serverData.headers, - sessionId: serverData.sessionId, + sessionId: sharedSessionId, reconnectionOptions: serverData.reconnectionOptions }) @@ -1079,7 +3016,22 @@ export class MCPService implements Resource { } const baseServer: Omit & { status: MCPServerBase['status'] } = { + id: existingServer.id, name, + displayName: serverData.displayName ?? existingServer.displayName, + description: serverData.description ?? existingServer.description, + source: serverData.source ?? existingServer.source, + authMode: serverData.authMode ?? existingServer.authMode, + oauthTemplate: serverData.oauthTemplate ?? existingServer.oauthTemplate, + secretFields: serverData.secretFields ?? existingServer.secretFields, + homepageUrl: serverData.homepageUrl ?? existingServer.homepageUrl, + repositoryUrl: serverData.repositoryUrl ?? existingServer.repositoryUrl, + licenseName: serverData.licenseName ?? existingServer.licenseName, + catalogProvider: serverData.catalogProvider ?? existingServer.catalogProvider, + catalogId: serverData.catalogId ?? existingServer.catalogId, + createdBy: existingServer.createdBy, + createdAt: existingServer.createdAt, + updatedAt: new Date(), secretNames: finalSecretNames ?? existingServer.secretNames, secretName: undefined, // Remove old format when updating status: existingServer.status, @@ -1091,25 +3043,41 @@ export class MCPService implements Resource { eventHandlers: existingServer.eventHandlers } + const extractedUpdatedUrlSecrets = + (baseServer.source ?? 'platform') === 'external' && nextTransportType === 'streamable_http' && (serverData.url ?? (existingServer.transportType === 'streamable_http' ? existingServer.url : undefined)) + ? buildExtractedExternalUrlSecretMetadata( + serverData.url ?? (existingServer.transportType === 'streamable_http' ? existingServer.url : ''), + serverData.secretFields ?? existingServer.secretFields + ) + : undefined + let updatedServer: MCPServer if (nextTransportType === 'streamable_http') { const url = - serverData.url ?? (existingServer.transportType === 'streamable_http' ? existingServer.url : undefined) + extractedUpdatedUrlSecrets?.sanitizedUrl ?? + serverData.url ?? + (existingServer.transportType === 'streamable_http' ? existingServer.url : undefined) if (!url) { throw new Error('Streamable HTTP servers require a url.') } - new URL(url) + if ((baseServer.source ?? 'platform') === 'external') { + const knownProviderUrlError = getKnownProviderUrlError(url) + if (knownProviderUrlError) { + throw new McpValidationError(knownProviderUrlError) + } + await validateExternalMcpUrl(url) + } else { + new URL(url) + } updatedServer = { ...baseServer, transportType: 'streamable_http', url, + secretFields: extractedUpdatedUrlSecrets?.secretFields ?? baseServer.secretFields, headers: serverData.headers ?? (existingServer.transportType === 'streamable_http' ? existingServer.headers : undefined), - sessionId: - serverData.sessionId ?? - (existingServer.transportType === 'streamable_http' ? existingServer.sessionId : undefined), reconnectionOptions: serverData.reconnectionOptions ?? (existingServer.transportType === 'streamable_http' ? existingServer.reconnectionOptions : undefined) @@ -1130,11 +3098,38 @@ export class MCPService implements Resource { } } + if ((updatedServer.source ?? 'platform') === 'external' && updatedServer.transportType === 'streamable_http') { + await validateExternalMcpUrl(updatedServer.url) + const normalizedOauthTemplate = await this.normalizeExternalOAuthTemplateForPersistence( + updatedServer.authMode ?? 'none', + updatedServer.url, + updatedServer.oauthTemplate + ) + this.validateExternalOAuthTemplate(updatedServer.authMode ?? 'none', normalizedOauthTemplate, updatedServer.url) + updatedServer = { + ...updatedServer, + oauthTemplate: normalizedOauthTemplate + } + } + await this.mcpDBClient.update(updatedServer, { name }) - // Otherwise, restart the server with the new configuration - // Stop the existing server if it's running + // Stop existing connections and restart const serverKey = buildServerKey(existingServer) + const sanitizedName = sanitizeString(name) + + // Tear down all user connections for this server + for (const userKey of Object.keys(this.userConnections) as UserServerKey[]) { + if (userKey.endsWith(`:${sanitizedName}`)) { + try { + await this.teardownUserConnection(userKey, 'server_updated') + } catch (error) { + log({ level: 'error', msg: `Error stopping user connection ${userKey}: ${error}` }) + } + } + } + + // Tear down shared connection if (this.servers[serverKey]) { try { await this.teardownServerConnection(serverKey) @@ -1144,22 +3139,31 @@ export class MCPService implements Resource { } } - // Start the updated server (connectToServer will respect the enabled flag) - await this.connectToServer(updatedServer) - this.fetchToolsForServer(updatedServer) + if (updatedServer.transportType === 'streamable_http') { + // Streamable HTTP: register definition, connections are lazy per-user + this.servers[serverKey] = { ...updatedServer, status: 'disconnected', logs: [] } + if (!this.serverKeys.includes(serverKey)) { + this.serverKeys.push(serverKey) + } + } else { + // Stdio: eagerly connect shared connection + await this.connectToServer(updatedServer) + this.fetchToolsForServer(updatedServer) + } return updatedServer } public async updateServerOAuthTokens( name: string, - input: Omit + username: string, + input: Omit ): Promise { if (!this.oauthTokensService) { throw new Error('OAuth token storage is not configured') } if (!input.accessToken || !input.clientId || !input.redirectUri) { - throw new Error('Missing required OAuth fields: accessToken, clientId, redirectUri') + throw new McpValidationError('Missing required OAuth fields: accessToken, clientId, redirectUri') } const server = await this.getServer(name) @@ -1174,15 +3178,36 @@ export class MCPService implements Resource { await this.oauthTokensService.upsertTokenRecord({ ...input, tokenType, - serverName: name + serverName: name, + username }) + // Tear down existing user connection so it reconnects with new tokens + const userKey = buildUserServerKey(username, server.name) + if (this.userConnections[userKey]) { + try { + await this.teardownUserConnection(userKey, 'oauth_updated') + } catch (error) { + log({ + level: 'warn', + msg: `[${username}:${name}] Failed to teardown user connection during OAuth update: ${(error as Error).message}` + }) + } + } + + // Strip Authorization headers from shared server config if present const sanitizedHeaders = server.headers ? stripAuthorizationHeaders(server.headers) ?? {} : undefined - const updated = await this.updateServer(name, { headers: sanitizedHeaders }) - if (!updated) { - throw new Error(`Failed to update server ${name} after OAuth token update`) + if (sanitizedHeaders) { + await this.mcpDBClient.update({ headers: sanitizedHeaders }, { name }) + const serverKey = buildServerKey(server) + if (this.servers[serverKey] && this.servers[serverKey].transportType === 'streamable_http') { + ;(this.servers[serverKey] as MCPServerBase & StreamableHttpServerConfig).headers = sanitizedHeaders + } } - return updated + + await this.userServerInstalls.setAuthState(name, username, 'connected') + + return server } public async deleteServer(name: string): Promise { @@ -1203,20 +3228,43 @@ export class MCPService implements Resource { enabled: existingRecord.enabled !== false }) - // Stop the server if it's running const serverKey = buildServerKey(existingServer) + const sanitizedName = sanitizeString(name) + + // Tear down all user connections for this server + for (const userKey of Object.keys(this.userConnections) as UserServerKey[]) { + if (userKey.endsWith(`:${sanitizedName}`)) { + try { + await this.teardownUserConnection(userKey, 'server_deleted') + } catch (error) { + log({ level: 'error', msg: `Error stopping user connection ${userKey}: ${error}` }) + } + } + } + + // Stop the shared server connection if running if (this.servers[serverKey]) { try { await this.teardownServerConnection(serverKey) - - // Finally delete the server reference delete this.servers[serverKey] } catch (error) { log({ level: 'error', msg: `Error stopping server ${name}: ${error}` }) } } + const installs = await this.userServerInstalls.listInstallsForServer(name) + await this.mcpDBClient.delete({ name }, false) + await this.userServerInstalls.deleteInstallsByServer(name) + if (this.oauthTokensService) { + await this.oauthTokensService.deleteTokensByServer(name) + } + if (this.userSessionsService) { + await this.userSessionsService.deleteSessionsByServer(name) + } + for (const install of installs) { + await this.secretsService.deleteSecretsByServerPrefix(name, install.username) + } } public async getServer(name: string): Promise { @@ -1260,14 +3308,23 @@ export class MCPService implements Resource { const updatedServer: MCPServer = { ...server, enabled: true } await this.mcpDBClient.update(updatedServer, { name }) - // If server is not running, start it const serverKey = buildServerKey(updatedServer) // Update the enabled status in the in-memory server object if (this.servers[serverKey]) { this.servers[serverKey].enabled = true } - if ( + + if (updatedServer.transportType === 'streamable_http') { + // Streamable HTTP: just mark as enabled, connections are lazy per-user + if (!this.servers[serverKey]) { + this.servers[serverKey] = { ...updatedServer, status: 'disconnected', logs: [] } + if (!this.serverKeys.includes(serverKey)) { + this.serverKeys.push(serverKey) + } + } + log({ level: 'info', msg: `[${name}] Enabled streamable HTTP server (lazy per-user connection).` }) + } else if ( !this.servers[serverKey] || this.servers[serverKey].status === 'disconnected' || this.servers[serverKey].status === 'error' @@ -1298,13 +3355,27 @@ export class MCPService implements Resource { const updatedServer: MCPServer = { ...server, enabled: false } await this.mcpDBClient.update(updatedServer, { name }) - // If server is running, stop it const serverKey = buildServerKey(updatedServer) + const sanitizedName = sanitizeString(name) // Update the enabled status in the in-memory server object if (this.servers[serverKey]) { this.servers[serverKey].enabled = false } + + // Tear down all user connections for this server + for (const userKey of Object.keys(this.userConnections) as UserServerKey[]) { + if (userKey.endsWith(`:${sanitizedName}`)) { + try { + await this.teardownUserConnection(userKey, 'server_disabled') + log({ level: 'info', msg: `User connection ${userKey} stopped due to server disable` }) + } catch (error) { + log({ level: 'error', msg: `Error stopping user connection ${userKey}: ${error}` }) + } + } + } + + // Tear down shared connection (stdio servers) if (this.servers[serverKey] && this.servers[serverKey].status !== 'disconnected') { try { await this.teardownServerConnection(serverKey) @@ -1325,6 +3396,17 @@ export class MCPService implements Resource { const builtInRegistry = BuiltInServerRegistry.getInstance() await builtInRegistry.stopAll() + // Tear down all per-user connections + for (const userKey of Object.keys(this.userConnections) as UserServerKey[]) { + try { + await this.teardownUserConnection(userKey, 'shutdown') + log({ level: 'info', msg: `User connection ${userKey} stopped` }) + } catch (error) { + log({ level: 'error', msg: `${userKey} error stopping user connection: ${error}` }) + } + } + + // Tear down shared (stdio) server connections for (const serverKey in this.servers) { const serverStatus = this.servers[serverKey].status if (serverStatus != 'disconnected') { @@ -1341,5 +3423,12 @@ export class MCPService implements Resource { if (this.oauthTokensService) { await this.oauthTokensService.stop() } + if (this.userSessionsService) { + await this.userSessionsService.stop() + } + if (this.dcrClients) { + await this.dcrClients.stop() + } + await this.userServerInstalls.stop() } } diff --git a/src/services/mcpErrors.ts b/src/services/mcpErrors.ts new file mode 100644 index 0000000..ccf2e89 --- /dev/null +++ b/src/services/mcpErrors.ts @@ -0,0 +1,169 @@ +export type McpErrorCode = + | 'invalid_request' + | 'server_not_found' + | 'server_disabled' + | 'auth_not_connected' + | 'reauth_required' + | 'server_already_exists' + | 'discovery_failed' + +export type McpErrorResponseBody = { + success: false + error: string + code?: McpErrorCode | string + reauthRequired?: boolean + serverName?: string + username?: string + authorizationUrl?: string + server?: unknown + attemptedUrls?: string[] + cause?: string +} + +export class McpApiError extends Error { + public readonly code: McpErrorCode | string + public readonly statusCode: number + public readonly details?: Omit + + constructor({ + message, + code, + statusCode, + details + }: { + message: string + code: McpErrorCode | string + statusCode: number + details?: Omit + }) { + super(message) + this.name = new.target.name + this.code = code + this.statusCode = statusCode + this.details = details + } +} + +export class McpValidationError extends McpApiError { + constructor(message: string) { + super({ message, code: 'invalid_request', statusCode: 400 }) + } +} + +export class McpServerNotFoundError extends McpApiError { + constructor(serverName: string) { + super({ + message: `Server ${serverName} not found`, + code: 'server_not_found', + statusCode: 404, + details: { serverName } + }) + } +} + +export class McpServerDisabledError extends McpApiError { + constructor(serverName: string) { + super({ + message: `Server ${serverName} is disabled`, + code: 'server_disabled', + statusCode: 409, + details: { serverName } + }) + } +} + +export class McpAuthNotConnectedError extends McpApiError { + constructor(serverName: string, username: string) { + super({ + message: `OAuth is not connected for user ${username} on server ${serverName}`, + code: 'auth_not_connected', + statusCode: 409, + details: { serverName, username } + }) + } +} + +export class McpReauthRequiredError extends McpApiError { + constructor({ + serverName, + username, + authorizationUrl, + message + }: { + serverName: string + username: string + authorizationUrl?: string + message?: string + }) { + super({ + message: + message ?? + `OAuth re-authorization required for user ${username} on external MCP server ${serverName}.`, + code: 'reauth_required', + statusCode: 401, + details: { + reauthRequired: true, + serverName, + username, + authorizationUrl + } + }) + } +} + +export class McpServerAlreadyExistsError extends McpApiError { + constructor(serverName: string, server: unknown) { + super({ + message: `Server ${serverName} already exists`, + code: 'server_already_exists', + statusCode: 409, + details: { + serverName, + server + } + }) + } +} + +export class McpDiscoveryFailedError extends McpApiError { + constructor(message: string, attemptedUrls: string[], cause?: string) { + super({ + message, + code: 'discovery_failed', + statusCode: 400, + details: { + attemptedUrls, + ...(cause ? { cause } : {}) + } + }) + } +} + +export const isMcpApiError = (error: unknown): error is McpApiError => error instanceof McpApiError + +export const toMcpErrorResponse = ( + error: unknown +): { + statusCode: number + body: McpErrorResponseBody +} => { + if (isMcpApiError(error)) { + return { + statusCode: error.statusCode, + body: { + success: false, + error: error.message, + code: error.code, + ...(error.details ?? {}) + } + } + } + + return { + statusCode: 500, + body: { + success: false, + error: error instanceof Error ? error.message : 'Unknown MCP error' + } + } +} diff --git a/src/services/oauthTokens.ts b/src/services/oauthTokens.ts index d62dd85..cdc660e 100644 --- a/src/services/oauthTokens.ts +++ b/src/services/oauthTokens.ts @@ -7,9 +7,12 @@ import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth. import { IndexDefinition, MongoConnectionParams, MongoDBClient } from '../utils/mongodb' import { SecretEncryptor } from '../utils/secretEncryptor' import { env } from '../env' +import { McpReauthRequiredError } from './mcpErrors' +import type { McpDcrClients, SupportedTokenEndpointAuthMethod } from './dcrClients' export interface McpOAuthTokenRecord { serverName: string + username: string tokenType: string accessToken: string refreshToken?: string @@ -19,12 +22,15 @@ export interface McpOAuthTokenRecord { clientSecret?: string redirectUri: string codeVerifier?: string + tokenEndpointAuthMethod: SupportedTokenEndpointAuthMethod + registrationMode: 'cimd' | 'dcr' | 'manual' createdAt: Date updatedAt: Date } export interface McpOAuthTokenInput { serverName: string + username: string tokenType: string accessToken: string refreshToken?: string @@ -34,10 +40,13 @@ export interface McpOAuthTokenInput { clientSecret?: string redirectUri: string codeVerifier?: string + tokenEndpointAuthMethod: SupportedTokenEndpointAuthMethod + registrationMode: 'cimd' | 'dcr' | 'manual' } export interface McpOAuthTokenDecrypted { serverName: string + username: string tokenType: string accessToken: string refreshToken?: string @@ -47,11 +56,15 @@ export interface McpOAuthTokenDecrypted { clientSecret?: string redirectUri: string codeVerifier?: string + tokenEndpointAuthMethod: SupportedTokenEndpointAuthMethod + registrationMode: 'cimd' | 'dcr' | 'manual' createdAt: Date updatedAt: Date } -const oauthTokenIndexes: IndexDefinition[] = [{ name: 'serverName', key: { serverName: 1 }, unique: true }] +const oauthTokenIndexes: IndexDefinition[] = [ + { name: 'serverName_username', key: { serverName: 1, username: 1 }, unique: true } +] export class McpOAuthTokens { private encryptor: SecretEncryptor @@ -66,8 +79,8 @@ export class McpOAuthTokens { await this.dbClient.connect('mcpOAuthTokens') } - public async getTokenRecord(serverName: string): Promise { - const record = await this.dbClient.findOne({ serverName }) + public async getTokenRecord(serverName: string, username: string): Promise { + const record = await this.dbClient.findOne({ serverName, username }) if (!record) { return null } @@ -82,7 +95,7 @@ export class McpOAuthTokens { } public async upsertTokenRecord(input: McpOAuthTokenInput): Promise { - const existing = await this.getTokenRecord(input.serverName) + const existing = await this.getTokenRecord(input.serverName, input.username) const now = new Date() const expiresAt = input.expiresIn !== undefined ? new Date(now.getTime() + input.expiresIn * 1000) : existing?.expiresAt @@ -90,6 +103,7 @@ export class McpOAuthTokens { const refreshTokenValue = input.refreshToken ?? existing?.refreshToken const record: McpOAuthTokenRecord = { serverName: input.serverName, + username: input.username, tokenType: input.tokenType, accessToken: this.encryptor.encrypt(input.accessToken), refreshToken: refreshTokenValue ? this.encryptor.encrypt(refreshTokenValue) : undefined, @@ -107,17 +121,19 @@ export class McpOAuthTokens { : existing?.codeVerifier ? this.encryptor.encrypt(existing.codeVerifier) : undefined, + tokenEndpointAuthMethod: input.tokenEndpointAuthMethod ?? existing?.tokenEndpointAuthMethod ?? 'none', + registrationMode: input.registrationMode ?? existing?.registrationMode ?? 'manual', createdAt: existing?.createdAt ?? now, updatedAt: now } - await this.dbClient.upsert(record, { serverName: input.serverName }) + await this.dbClient.upsert(record, { serverName: input.serverName, username: input.username }) } - public async saveTokens(serverName: string, tokens: OAuthTokens): Promise { - const existing = await this.getTokenRecord(serverName) + public async saveTokens(serverName: string, username: string, tokens: OAuthTokens): Promise { + const existing = await this.getTokenRecord(serverName, username) if (!existing) { - throw new Error(`OAuth token record not found for server ${serverName}`) + throw new Error(`OAuth token record not found for server ${serverName} and user ${username}`) } const expiresAt = @@ -125,6 +141,7 @@ export class McpOAuthTokens { const updated: McpOAuthTokenRecord = { serverName, + username, tokenType: tokens.token_type, accessToken: this.encryptor.encrypt(tokens.access_token), refreshToken: tokens.refresh_token @@ -138,27 +155,113 @@ export class McpOAuthTokens { clientSecret: existing.clientSecret ? this.encryptor.encrypt(existing.clientSecret) : undefined, redirectUri: existing.redirectUri, codeVerifier: existing.codeVerifier ? this.encryptor.encrypt(existing.codeVerifier) : undefined, + tokenEndpointAuthMethod: existing.tokenEndpointAuthMethod, + registrationMode: existing.registrationMode, createdAt: existing.createdAt, updatedAt: new Date() } - await this.dbClient.upsert(updated, { serverName }) + await this.dbClient.upsert(updated, { serverName, username }) } - public async saveCodeVerifier(serverName: string, codeVerifier: string): Promise { - const existing = await this.getTokenRecord(serverName) + public async saveCodeVerifier(serverName: string, username: string, codeVerifier: string): Promise { + const existing = await this.getTokenRecord(serverName, username) if (!existing) { - throw new Error(`OAuth token record not found for server ${serverName}`) + throw new Error(`OAuth token record not found for server ${serverName} and user ${username}`) } await this.dbClient.update( { codeVerifier: this.encryptor.encrypt(codeVerifier), updatedAt: new Date() }, - { serverName } + { serverName, username } ) } + public async deleteTokenRecord(serverName: string, username: string): Promise { + await this.dbClient.delete({ serverName, username }, false) + } + + public async deleteTokensByServer(serverName: string): Promise { + await this.dbClient.delete({ serverName }) + } + + public async listTokenRecordsForServer(serverName: string): Promise { + const records = await this.dbClient.find({ serverName }) + return records.map((record) => ({ + ...record, + accessToken: this.encryptor.decrypt(record.accessToken), + refreshToken: record.refreshToken ? this.encryptor.decrypt(record.refreshToken) : undefined, + clientSecret: record.clientSecret ? this.encryptor.decrypt(record.clientSecret) : undefined, + codeVerifier: record.codeVerifier ? this.encryptor.decrypt(record.codeVerifier) : undefined + })) + } + + public async refreshTokenRecord(input: { + serverName: string + username: string + tokenEndpoint: string + resource?: string + }): Promise { + const existing = await this.getTokenRecord(input.serverName, input.username) + if (!existing?.refreshToken) { + throw new Error(`OAuth refresh token not found for server ${input.serverName} and user ${input.username}`) + } + + const params = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: existing.refreshToken, + client_id: existing.clientId + }) + if (input.resource) { + params.set('resource', input.resource) + } + + const headers: Record = { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json' + } + + if (existing.tokenEndpointAuthMethod === 'client_secret_post') { + if (!existing.clientSecret) { + throw new Error(`OAuth client_secret is required for refresh method ${existing.tokenEndpointAuthMethod}`) + } + params.set('client_secret', existing.clientSecret) + } else if (existing.tokenEndpointAuthMethod === 'client_secret_basic') { + if (!existing.clientSecret) { + throw new Error(`OAuth client_secret is required for refresh method ${existing.tokenEndpointAuthMethod}`) + } + headers.Authorization = `Basic ${Buffer.from(`${existing.clientId}:${existing.clientSecret}`, 'utf8').toString('base64')}` + } + + const response = await fetch(input.tokenEndpoint, { + method: 'POST', + headers, + body: params + }) + + const responseText = await response.text() + let parsed: OAuthTokens | { error?: string; error_description?: string } + try { + parsed = JSON.parse(responseText) as OAuthTokens | { error?: string; error_description?: string } + } catch { + throw new Error(`OAuth token refresh failed with non-JSON response: ${response.status}`) + } + + if (!response.ok) { + const errorCode = (parsed as { error?: string }).error || `HTTP_${response.status}` + const errorDescription = (parsed as { error_description?: string }).error_description + throw new Error(`OAuth token refresh failed: ${errorCode}${errorDescription ? ` ${errorDescription}` : ''}`) + } + + await this.saveTokens(input.serverName, input.username, parsed as OAuthTokens) + const updated = await this.getTokenRecord(input.serverName, input.username) + if (!updated) { + throw new Error(`OAuth token record not found after refresh for server ${input.serverName}`) + } + return updated + } + public async stop(): Promise { await this.dbClient.disconnect() } @@ -166,24 +269,52 @@ export class McpOAuthTokens { export class McpOAuthClientProvider implements OAuthClientProvider { private serverName: string + private username: string private tokenStore: McpOAuthTokens private tokensSnapshot: OAuthTokens private clientInfo: OAuthClientInformation private clientMetadataValue: OAuthClientMetadata private redirectUri: string + private tokenEndpoint: string + private resource?: string + private issuer?: string + private registrationEndpoint?: string + private tokenEndpointAuthMethodsSupported?: SupportedTokenEndpointAuthMethod[] + private dcrClients?: McpDcrClients constructor({ serverName, + username, tokenStore, - record + record, + tokenEndpoint, + resource, + issuer, + registrationEndpoint, + tokenEndpointAuthMethodsSupported, + dcrClients }: { serverName: string + username: string tokenStore: McpOAuthTokens record: McpOAuthTokenDecrypted + tokenEndpoint: string + resource?: string + issuer?: string + registrationEndpoint?: string + tokenEndpointAuthMethodsSupported?: SupportedTokenEndpointAuthMethod[] + dcrClients?: McpDcrClients }) { this.serverName = serverName + this.username = username this.tokenStore = tokenStore this.redirectUri = record.redirectUri + this.tokenEndpoint = tokenEndpoint + this.resource = resource + this.issuer = issuer + this.registrationEndpoint = registrationEndpoint + this.tokenEndpointAuthMethodsSupported = tokenEndpointAuthMethodsSupported + this.dcrClients = dcrClients this.clientInfo = { client_id: record.clientId, client_secret: record.clientSecret @@ -216,34 +347,113 @@ export class McpOAuthClientProvider implements OAuthClientProvider { return this.clientInfo } - tokens(): OAuthTokens { - return this.tokensSnapshot - } - async saveTokens(tokens: OAuthTokens): Promise { const nextTokens: OAuthTokens = { ...tokens, refresh_token: tokens.refresh_token ?? this.tokensSnapshot.refresh_token } this.tokensSnapshot = nextTokens - await this.tokenStore.saveTokens(this.serverName, nextTokens) + await this.tokenStore.saveTokens(this.serverName, this.username, nextTokens) } async redirectToAuthorization(authorizationUrl: URL): Promise { - throw new Error( - `OAuth re-authorization required. Complete OAuth in MissionSquad and retry. Authorization URL: ${authorizationUrl.toString()}` - ) + throw new McpReauthRequiredError({ + serverName: this.serverName, + username: this.username, + authorizationUrl: authorizationUrl.toString(), + message: `OAuth re-authorization required. Complete OAuth in MissionSquad and retry.` + }) } async saveCodeVerifier(codeVerifier: string): Promise { - await this.tokenStore.saveCodeVerifier(this.serverName, codeVerifier) + await this.tokenStore.saveCodeVerifier(this.serverName, this.username, codeVerifier) + } + + private async refreshTokensIfNeeded(): Promise { + let record = await this.tokenStore.getTokenRecord(this.serverName, this.username) + if (!record) { + throw new Error(`OAuth token record not found for server ${this.serverName} and user ${this.username}`) + } + if (!record.expiresAt || record.expiresAt.getTime() > Date.now()) { + return record + } + if (!record.refreshToken) { + throw new McpReauthRequiredError({ + serverName: this.serverName, + username: this.username, + message: 'OAuth token expired and no refresh token is available. Reconnect the server.' + }) + } + + try { + record = await this.tokenStore.refreshTokenRecord({ + serverName: this.serverName, + username: this.username, + tokenEndpoint: this.tokenEndpoint, + resource: this.resource + }) + return record + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (/invalid_grant|invalid_token/i.test(message)) { + throw new McpReauthRequiredError({ + serverName: this.serverName, + username: this.username, + message: 'OAuth refresh token is no longer valid. Reconnect the server.' + }) + } + if (/invalid_client/i.test(message)) { + if (record.registrationMode === 'dcr' && this.dcrClients && this.issuer && this.registrationEndpoint) { + await this.dcrClients.invalidateRegistration({ + issuer: this.issuer, + publicApiOrigin: new URL(record.redirectUri).origin, + redirectUri: record.redirectUri + }) + await this.dcrClients.getOrRegisterClient({ + issuer: this.issuer, + registrationEndpoint: this.registrationEndpoint, + tokenEndpointAuthMethodsSupported: this.tokenEndpointAuthMethodsSupported, + oauthProvisioningContext: { + publicApiOrigin: new URL(record.redirectUri).origin, + redirectUri: record.redirectUri, + clientMetadataUrl: '', + clientName: 'MissionSquad' + } + }) + } + throw new McpReauthRequiredError({ + serverName: this.serverName, + username: this.username, + message: 'OAuth client registration is no longer valid. Reconnect the server.' + }) + } + throw error + } } async codeVerifier(): Promise { - const record = await this.tokenStore.getTokenRecord(this.serverName) + const record = await this.tokenStore.getTokenRecord(this.serverName, this.username) if (!record?.codeVerifier) { throw new Error('PKCE code verifier not available for this OAuth session.') } return record.codeVerifier } + + async tokens(): Promise { + const record = await this.refreshTokensIfNeeded() + this.clientInfo = { + client_id: record.clientId, + client_secret: record.clientSecret + } + const expiresIn = record.expiresAt + ? Math.max(0, Math.floor((record.expiresAt.getTime() - Date.now()) / 1000)) + : undefined + this.tokensSnapshot = { + access_token: record.accessToken, + token_type: record.tokenType, + expires_in: expiresIn, + scope: record.scopes ? record.scopes.join(' ') : undefined + } + return this.tokensSnapshot + } } diff --git a/src/services/packages.ts b/src/services/packages.ts index 517a249..f54c45a 100644 --- a/src/services/packages.ts +++ b/src/services/packages.ts @@ -46,7 +46,6 @@ export interface InstallPackageRequest { env?: Record url?: string headers?: Record - sessionId?: string reconnectionOptions?: StreamableHTTPReconnectionOptions secretName?: string enabled?: boolean @@ -298,7 +297,6 @@ export class PackageService { env: envVars, url, headers, - sessionId, reconnectionOptions, secretName, enabled = true, @@ -320,7 +318,7 @@ export class PackageService { if (resolvedTransportType !== 'stdio') { return { success: false, error: 'Python runtime only supports stdio transport.' } } - if (url || headers || sessionId || reconnectionOptions) { + if (url || headers || reconnectionOptions) { return { success: false, error: 'Streamable HTTP fields are not allowed for python runtime.' } } @@ -390,7 +388,6 @@ export class PackageService { env: envVars, url, headers, - sessionId, reconnectionOptions }) @@ -485,7 +482,6 @@ export class PackageService { transportType: 'streamable_http', url: url!, headers, - sessionId, reconnectionOptions, secretName, enabled diff --git a/src/services/secrets.ts b/src/services/secrets.ts index 152e4dd..d68ee87 100644 --- a/src/services/secrets.ts +++ b/src/services/secrets.ts @@ -2,6 +2,7 @@ import { IndexDefinition, MongoConnectionParams, MongoDBClient } from '../utils/ import { SecretEncryptor } from '../utils/secretEncryptor' import { env } from '../env' import { log } from '../utils/general' +import type { SaveUserServerSecretsInput } from './mcp' export interface UserSecret { username: string @@ -86,4 +87,61 @@ export class Secrets { } return true } + + public async saveUserServerSecrets(input: SaveUserServerSecretsInput): Promise { + for (const secret of input.secrets) { + const encryptedSecretValue = this.secrets.encrypt(secret.value) + await this.secretsDBClient.upsert( + { + username: input.username, + key: `${input.serverName}.${secret.name}`, + value: encryptedSecretValue + }, + { + username: input.username, + key: `${input.serverName}.${secret.name}` + } + ) + } + } + + public async deleteSecretsByServerPrefix(serverName: string, username: string): Promise { + await this.secretsDBClient.delete({ + username, + key: { $regex: `^${serverName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\.` } + } as any) + } + + public async listSecretNamesByServerPrefix(serverName: string, username: string): Promise { + const records = await this.secretsDBClient.find({ + username, + key: { $regex: `^${serverName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\.` } + } as any) + + return records.map((record) => record.key.slice(serverName.length + 1)) + } + + public async getUserServerSecrets(serverName: string, username: string): Promise> { + const records = await this.secretsDBClient.find({ + username, + key: { $regex: `^${serverName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\.` } + } as any) + + return records.reduce((acc, record) => { + const secretName = record.key.slice(serverName.length + 1) + if (!secretName) { + return acc + } + acc[secretName] = this.secrets.decrypt(record.value) + return acc + }, {} as Record) + } + + public async listUsernamesByServerPrefix(serverName: string): Promise { + const records = await this.secretsDBClient.find({ + key: { $regex: `^${serverName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\.` } + } as any) + + return Array.from(new Set(records.map((record) => record.username).filter(Boolean))) + } } diff --git a/src/services/userServerInstalls.ts b/src/services/userServerInstalls.ts new file mode 100644 index 0000000..08dfe50 --- /dev/null +++ b/src/services/userServerInstalls.ts @@ -0,0 +1,113 @@ +import { IndexDefinition, MongoConnectionParams, MongoDBClient } from '../utils/mongodb' + +export type UserInstallAuthState = 'not_required' | 'not_connected' | 'connected' | 'reauth_required' | 'error' + +export interface InstallUserServerInput { + serverName: string + username: string + enabled: boolean + authMode: 'none' | 'oauth2' + oauthClientId?: string + oauthClientSecret?: string + oauthScopes?: string[] +} + +export interface UpdateUserServerInstallInput extends InstallUserServerInput {} + +export interface McpUserExternalServerInstallRecord { + serverName: string + username: string + enabled: boolean + authState: UserInstallAuthState + oauthClientId?: string + oauthClientSecret?: string + oauthScopes?: string[] + lastAuthError?: string + createdAt: Date + updatedAt: Date +} + +const installIndexes: IndexDefinition[] = [ + { name: 'serverName_username', key: { serverName: 1, username: 1 }, unique: true }, + { name: 'username', key: { username: 1 } }, + { name: 'serverName', key: { serverName: 1 } } +] + +export const resolveInitialUserInstallAuthState = ( + existingAuthState: UserInstallAuthState | undefined, + authMode: InstallUserServerInput['authMode'] +): UserInstallAuthState => existingAuthState ?? (authMode === 'oauth2' ? 'not_connected' : 'not_required') + +export class McpUserServerInstalls { + private dbClient: MongoDBClient + + constructor({ mongoParams }: { mongoParams: MongoConnectionParams }) { + this.dbClient = new MongoDBClient(mongoParams, installIndexes) + } + + public async init(): Promise { + await this.dbClient.connect('mcpUserServerInstalls') + } + + public async getInstall(serverName: string, username: string): Promise { + return this.dbClient.findOne({ serverName, username }) + } + + public async listInstallsForUser(username: string): Promise { + return this.dbClient.find({ username }) + } + + public async listInstallsForServer(serverName: string): Promise { + return this.dbClient.find({ serverName }) + } + + public async upsertInstall(input: InstallUserServerInput): Promise { + const existing = await this.getInstall(input.serverName, input.username) + const now = new Date() + const authState = resolveInitialUserInstallAuthState(existing?.authState, input.authMode) + + const record: McpUserExternalServerInstallRecord = { + serverName: input.serverName, + username: input.username, + enabled: input.enabled, + authState, + oauthClientId: input.oauthClientId ?? existing?.oauthClientId, + oauthClientSecret: input.oauthClientSecret ?? existing?.oauthClientSecret, + oauthScopes: input.oauthScopes ?? existing?.oauthScopes, + lastAuthError: existing?.lastAuthError, + createdAt: existing?.createdAt ?? now, + updatedAt: now + } + + await this.dbClient.upsert(record, { serverName: input.serverName, username: input.username }) + return record + } + + public async deleteInstall(serverName: string, username: string): Promise { + await this.dbClient.delete({ serverName, username }, false) + } + + public async deleteInstallsByServer(serverName: string): Promise { + await this.dbClient.delete({ serverName }, true) + } + + public async setAuthState( + serverName: string, + username: string, + authState: UserInstallAuthState, + lastAuthError?: string + ): Promise { + await this.dbClient.update( + { + authState, + lastAuthError, + updatedAt: new Date() + }, + { serverName, username } + ) + } + + public async stop(): Promise { + await this.dbClient.disconnect() + } +} diff --git a/src/services/userSessions.ts b/src/services/userSessions.ts new file mode 100644 index 0000000..5fec231 --- /dev/null +++ b/src/services/userSessions.ts @@ -0,0 +1,66 @@ +import { IndexDefinition, MongoConnectionParams, MongoDBClient } from '../utils/mongodb' + +export interface McpUserSessionRecord { + serverName: string + username: string + sessionId?: string + updatedAt: Date +} + +const sessionIndexes: IndexDefinition[] = [ + { name: 'serverName_username', key: { serverName: 1, username: 1 }, unique: true } +] + +export class McpUserSessions { + private dbClient: MongoDBClient + + constructor({ mongoParams }: { mongoParams: MongoConnectionParams }) { + this.dbClient = new MongoDBClient(mongoParams, sessionIndexes) + } + + public async init(): Promise { + await this.dbClient.connect('mcpUserSessions') + } + + public async getSession(serverName: string, username: string): Promise { + return this.dbClient.findOne({ serverName, username }) + } + + public async upsertSession(serverName: string, username: string, sessionId: string | undefined): Promise { + await this.dbClient.upsert( + { + serverName, + username, + sessionId, + updatedAt: new Date() + }, + { serverName, username } + ) + } + + public async clearSession(serverName: string, username: string): Promise { + await this.dbClient.update( + { + sessionId: undefined, + updatedAt: new Date() + }, + { serverName, username } + ) + } + + public async deleteSession(serverName: string, username: string): Promise { + await this.dbClient.delete({ serverName, username }, false) + } + + public async deleteSessionsByServer(serverName: string): Promise { + await this.dbClient.delete({ serverName }) + } + + public async listSessionsForServer(serverName: string): Promise { + return this.dbClient.find({ serverName }) + } + + public async stop(): Promise { + await this.dbClient.disconnect() + } +} diff --git a/src/utils/mongodb.ts b/src/utils/mongodb.ts index 18fc335..99ed824 100644 --- a/src/utils/mongodb.ts +++ b/src/utils/mongodb.ts @@ -104,10 +104,10 @@ export class MongoDBClient { } const indexes = await this.collection.indexes() const indexMaps = indexes.map(({ key }) => objectMapString(key)) - for (const { name, key } of this.indexes) { + for (const { name, key, ...options } of this.indexes) { const indexMap = objectMapString(key) if (!indexMaps.includes(indexMap)) { - await this.collection.createIndexes([{ name, key }]) + await this.collection.createIndexes([{ name, key, ...options }]) log({ level: 'info', msg: `Created index ${name}` }) } } @@ -228,4 +228,4 @@ export class MongoDBClient { await this.client.close() log({ level: 'info', msg: `Disconnected from ${this.dbName} : ${this.collectionName}` }) } -} \ No newline at end of file +} diff --git a/src/utils/ssrf.ts b/src/utils/ssrf.ts new file mode 100644 index 0000000..195b0ee --- /dev/null +++ b/src/utils/ssrf.ts @@ -0,0 +1,93 @@ +import dns from 'dns/promises' +import net from 'net' +import { McpValidationError } from '../services/mcpErrors' + +const IPV4_PRIVATE_RANGES: Array<[number, number]> = [ + [ipToInt('0.0.0.0'), ipToInt('0.255.255.255')], + [ipToInt('10.0.0.0'), ipToInt('10.255.255.255')], + [ipToInt('100.64.0.0'), ipToInt('100.127.255.255')], + [ipToInt('127.0.0.0'), ipToInt('127.255.255.255')], + [ipToInt('169.254.0.0'), ipToInt('169.254.255.255')], + [ipToInt('172.16.0.0'), ipToInt('172.31.255.255')], + [ipToInt('192.0.0.0'), ipToInt('192.0.0.255')], + [ipToInt('192.0.2.0'), ipToInt('192.0.2.255')], + [ipToInt('192.168.0.0'), ipToInt('192.168.255.255')], + [ipToInt('198.18.0.0'), ipToInt('198.19.255.255')], + [ipToInt('198.51.100.0'), ipToInt('198.51.100.255')], + [ipToInt('203.0.113.0'), ipToInt('203.0.113.255')], + [ipToInt('224.0.0.0'), ipToInt('255.255.255.255')] +] + +function ipToInt(value: string): number { + return value.split('.').reduce((acc, part) => (acc << 8) + Number(part), 0) >>> 0 +} + +function isPrivateIpv4(value: string): boolean { + const intValue = ipToInt(value) + return IPV4_PRIVATE_RANGES.some(([start, end]) => intValue >= start && intValue <= end) +} + +function isBlockedIpv6(value: string): boolean { + const normalized = value.toLowerCase() + return ( + normalized === '::' || + normalized === '::1' || + normalized.startsWith('fc') || + normalized.startsWith('fd') || + normalized.startsWith('fe8') || + normalized.startsWith('fe9') || + normalized.startsWith('fea') || + normalized.startsWith('feb') || + normalized.startsWith('ff') + ) +} + +function assertSafeIp(address: string): void { + const family = net.isIP(address) + if (family === 4 && isPrivateIpv4(address)) { + throw new McpValidationError(`Blocked private or local IPv4 address: ${address}`) + } + if (family === 6 && isBlockedIpv6(address)) { + throw new McpValidationError(`Blocked private or local IPv6 address: ${address}`) + } +} + +export async function validateExternalMcpUrl(urlString: string): Promise { + let parsed: URL + try { + parsed = new URL(urlString) + } catch { + throw new McpValidationError('External MCP server url must be a valid URL') + } + + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + throw new McpValidationError('External MCP server url must use http or https') + } + + if (parsed.username || parsed.password) { + throw new McpValidationError('External MCP server url must not include embedded credentials') + } + + const hostname = parsed.hostname.trim().toLowerCase() + if (!hostname) { + throw new McpValidationError('External MCP server url hostname is required') + } + + if (hostname === 'localhost' || hostname.endsWith('.localhost') || hostname.endsWith('.local')) { + throw new McpValidationError(`Blocked local hostname: ${hostname}`) + } + + const literalFamily = net.isIP(hostname) + if (literalFamily !== 0) { + assertSafeIp(hostname) + return parsed + } + + const resolved = await dns.lookup(hostname, { all: true, verbatim: true }) + if (resolved.length === 0) { + throw new McpValidationError(`Unable to resolve external MCP hostname: ${hostname}`) + } + + resolved.forEach(({ address }) => assertSafeIp(address)) + return parsed +} diff --git a/test/mcp-external-auth.spec.ts b/test/mcp-external-auth.spec.ts new file mode 100644 index 0000000..cb43843 --- /dev/null +++ b/test/mcp-external-auth.spec.ts @@ -0,0 +1,322 @@ +import { requireUsername } from '../src/controllers/mcp' +import { + resolveUserConnectionTeardownPolicy, + assertTransportConfigCompatible, + buildAuthorizationServerMetadataCandidates, + buildMergedAuthorizationServerResult, + buildProtectedResourceMetadataCandidates, + buildExternalUrlWithSecretQueryParams, + canonicalizeExternalOAuthResourceUri, + parseWwwAuthenticateHeader, + shouldFallbackToSse +} from '../src/services/mcp' +import { StreamableHTTPError } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import { + McpServerAlreadyExistsError, + McpReauthRequiredError, + McpValidationError, + toMcpErrorResponse +} from '../src/services/mcpErrors' +import { McpOAuthClientProvider, McpOAuthTokens } from '../src/services/oauthTokens' +import { resolveInitialUserInstallAuthState } from '../src/services/userServerInstalls' +import { resolvePreferredTokenEndpointAuthMethod } from '../src/services/dcrClients' + +describe('external MCP request validation', () => { + test('requireUsername trims and returns non-empty usernames', () => { + expect(requireUsername(' alice ', 'tool calls')).toBe('alice') + }) + + test('requireUsername rejects blank usernames', () => { + expect(() => requireUsername(' ', 'tool calls')).toThrow(McpValidationError) + expect(() => requireUsername(undefined, 'tool calls')).toThrow('username is required for tool calls') + }) + + test('streamable HTTP server definitions reject shared sessionId fields', () => { + expect(() => + assertTransportConfigCompatible({ + transportType: 'streamable_http', + url: 'https://example.com/mcp', + sessionId: 'shared-session' + }) + ).toThrow('Streamable HTTP server definitions cannot define sessionId') + }) + + test('parses RFC 9728 challenge metadata and scope hints', () => { + expect( + parseWwwAuthenticateHeader( + 'Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource", scope="files:read files:write"' + ) + ).toEqual({ + resourceMetadataUrl: 'https://mcp.example.com/.well-known/oauth-protected-resource', + challengedScopes: ['files:read', 'files:write'] + }) + }) + + test('builds protected resource metadata fallback candidates in spec order', () => { + expect( + buildProtectedResourceMetadataCandidates(new URL('https://example.com/public/mcp')) + ).toEqual([ + 'https://example.com/.well-known/oauth-protected-resource/public/mcp', + 'https://example.com/.well-known/oauth-protected-resource' + ]) + }) + + test('builds authorization server discovery candidates in RFC 8414 / OIDC order', () => { + expect( + buildAuthorizationServerMetadataCandidates(new URL('https://auth.example.com/tenant1')) + ).toEqual([ + 'https://auth.example.com/.well-known/oauth-authorization-server/tenant1', + 'https://auth.example.com/.well-known/openid-configuration/tenant1', + 'https://auth.example.com/tenant1/.well-known/openid-configuration' + ]) + }) + + test('canonicalizes external OAuth resource URIs by removing query and hash', () => { + expect( + canonicalizeExternalOAuthResourceUri('https://example.com/public/mcp?token=123#fragment') + ).toBe('https://example.com/public/mcp') + }) + + test('applies external user secrets as query params without mutating the shared base url', () => { + expect( + buildExternalUrlWithSecretQueryParams('https://example.com/mcp?mode=connect', { + token: 'abc123', + tenant: 'demo' + }) + ).toBe('https://example.com/mcp?mode=connect&token=abc123&tenant=demo') + }) + + test('merges optional authorization metadata capabilities without overriding primary endpoints', () => { + expect( + buildMergedAuthorizationServerResult({ + issuer: 'https://app.asana.com', + scopesSupported: ['tasks:read'], + primaryDocuments: [ + { + url: 'https://app.asana.com/.well-known/oauth-authorization-server', + document: { + issuer: 'https://app.asana.com', + authorization_endpoint: 'https://app.asana.com/-/oauth_authorize', + token_endpoint: 'https://app.asana.com/-/oauth_token', + code_challenge_methods_supported: ['S256'], + token_endpoint_auth_methods_supported: ['client_secret_basic'] + } + }, + { + url: 'https://app.asana.com/.well-known/openid-configuration', + document: { + registration_endpoint: 'https://app.asana.com/late-registration', + client_id_metadata_document_supported: false + } + } + ], + compatibilityDocuments: [ + { + url: 'https://mcp.asana.com/.well-known/oauth-authorization-server', + document: { + registration_endpoint: 'https://mcp.asana.com/register', + client_id_metadata_document_supported: true + } + } + ] + }) + ).toEqual({ + issuer: 'https://app.asana.com', + authorizationServerMetadataUrl: 'https://app.asana.com/.well-known/oauth-authorization-server', + authorizationEndpoint: 'https://app.asana.com/-/oauth_authorize', + tokenEndpoint: 'https://app.asana.com/-/oauth_token', + scopesSupported: ['tasks:read'], + codeChallengeMethodsSupported: ['S256'], + clientIdMetadataDocumentSupported: true, + registrationEndpoint: 'https://app.asana.com/late-registration', + tokenEndpointAuthMethodsSupported: ['client_secret_basic'] + }) + }) + + test('prefers supported DCR token auth methods in the required order', () => { + expect(resolvePreferredTokenEndpointAuthMethod(['client_secret_basic', 'none'])).toBe('none') + expect(resolvePreferredTokenEndpointAuthMethod(['client_secret_post', 'client_secret_basic'])).toBe( + 'client_secret_post' + ) + expect(resolvePreferredTokenEndpointAuthMethod(undefined)).toBe('client_secret_basic') + }) + + test('falls back to SSE when streamable HTTP fails with unexpected html content type', () => { + expect( + shouldFallbackToSse(new StreamableHTTPError(-1, 'Unexpected content type: text/html; charset=utf-8')) + ).toBe(true) + }) +}) + +describe('external MCP error contract', () => { + test('reauth errors serialize to a machine-readable response', () => { + const error = new McpReauthRequiredError({ + serverName: 'remote-shopify', + username: 'alice', + authorizationUrl: 'https://example.com/oauth/authorize' + }) + + expect(toMcpErrorResponse(error)).toEqual({ + statusCode: 401, + body: { + success: false, + error: 'OAuth re-authorization required for user alice on external MCP server remote-shopify.', + code: 'reauth_required', + reauthRequired: true, + serverName: 'remote-shopify', + username: 'alice', + authorizationUrl: 'https://example.com/oauth/authorize' + } + }) + }) + + test('oauth provider redirect throws a typed reauth-required error', async () => { + const tokenStore = new McpOAuthTokens({ + mongoParams: { + host: 'localhost:27017', + db: 'test', + user: 'user', + pass: 'pass' + } + }) + + const provider = new McpOAuthClientProvider({ + serverName: 'remote-shopify', + username: 'alice', + tokenStore, + record: { + serverName: 'remote-shopify', + username: 'alice', + tokenType: 'Bearer', + accessToken: 'access', + refreshToken: 'refresh', + clientId: 'client-id', + clientSecret: 'client-secret', + redirectUri: 'https://missionsquad.example/callback', + tokenEndpointAuthMethod: 'client_secret_post', + registrationMode: 'manual', + createdAt: new Date('2026-03-13T00:00:00.000Z'), + updatedAt: new Date('2026-03-13T00:00:00.000Z') + }, + tokenEndpoint: 'https://example.com/oauth/token' + }) + + await expect( + provider.redirectToAuthorization(new URL('https://example.com/oauth/authorize')) + ).rejects.toMatchObject({ + code: 'reauth_required', + statusCode: 401, + details: { + reauthRequired: true, + serverName: 'remote-shopify', + username: 'alice', + authorizationUrl: 'https://example.com/oauth/authorize' + } + }) + }) + + test('oauth provider refresh passes the canonical resource URI to token refresh', async () => { + const expiredRecord = { + serverName: 'remote-shopify', + username: 'alice', + tokenType: 'Bearer', + accessToken: 'expired-access', + refreshToken: 'refresh', + clientId: 'client-id', + clientSecret: 'client-secret', + redirectUri: 'https://missionsquad.example/callback', + tokenEndpointAuthMethod: 'client_secret_post' as const, + registrationMode: 'manual' as const, + expiresAt: new Date(Date.now() - 60_000), + scopes: ['files:read'], + createdAt: new Date('2026-03-13T00:00:00.000Z'), + updatedAt: new Date('2026-03-13T00:00:00.000Z') + } + const refreshedRecord = { + ...expiredRecord, + accessToken: 'fresh-access', + expiresAt: new Date(Date.now() + 3_600_000), + updatedAt: new Date('2026-03-13T01:00:00.000Z') + } + const tokenStore = { + getTokenRecord: jest.fn().mockResolvedValue(expiredRecord), + refreshTokenRecord: jest.fn().mockResolvedValue(refreshedRecord) + } as unknown as McpOAuthTokens + + const provider = new McpOAuthClientProvider({ + serverName: 'remote-shopify', + username: 'alice', + tokenStore, + record: expiredRecord, + tokenEndpoint: 'https://example.com/oauth/token', + resource: 'https://oauth.example.com/resource' + }) + + await provider.tokens() + + expect((tokenStore as unknown as { refreshTokenRecord: jest.Mock }).refreshTokenRecord).toHaveBeenCalledWith({ + serverName: 'remote-shopify', + username: 'alice', + tokenEndpoint: 'https://example.com/oauth/token', + resource: 'https://oauth.example.com/resource' + }) + }) + + test('duplicate shared external servers serialize to the handbook 409 contract', () => { + const existingServer = { + name: 'remote-drive', + displayName: 'Remote Drive', + description: 'Shared OAuth MCP server', + source: 'external', + transportType: 'streamable_http', + installed: false, + enabled: true, + authRequired: true, + secretFields: [], + canInstall: true, + canUninstall: false, + canConfigure: false, + canManagePlatformServer: false + } + + expect(toMcpErrorResponse(new McpServerAlreadyExistsError('remote-drive', existingServer))).toEqual({ + statusCode: 409, + body: { + success: false, + error: 'Server remote-drive already exists', + code: 'server_already_exists', + serverName: 'remote-drive', + server: existingServer + } + }) + }) +}) + +describe('external MCP teardown policy', () => { + test('shutdown preserves resumable sessions', () => { + expect(resolveUserConnectionTeardownPolicy('shutdown')).toEqual({ + terminateSession: false, + clearPersistedSession: false + }) + }) + + test('oauth updates clear persisted sessions without terminating the remote session', () => { + expect(resolveUserConnectionTeardownPolicy('oauth_updated')).toEqual({ + terminateSession: false, + clearPersistedSession: true + }) + }) + + test('server disable clears persisted sessions and terminates the remote session', () => { + expect(resolveUserConnectionTeardownPolicy('server_disabled')).toEqual({ + terminateSession: true, + clearPersistedSession: true + }) + }) + + test('oauth installs always start disconnected while no-auth installs remain not_required', () => { + expect(resolveInitialUserInstallAuthState(undefined, 'oauth2')).toBe('not_connected') + expect(resolveInitialUserInstallAuthState(undefined, 'none')).toBe('not_required') + expect(resolveInitialUserInstallAuthState('connected', 'oauth2')).toBe('connected') + }) +}) From aadd12cf64462aebbd5a43a29f72eb490d92e835 Mon Sep 17 00:00:00 2001 From: Jayson Jacobs Date: Sun, 22 Mar 2026 12:34:41 -0600 Subject: [PATCH 2/2] update for more configurable dcr --- src/services/mcp.ts | 230 +++++++++++++++++++++++++++++---- test/mcp-external-auth.spec.ts | 180 ++++++++++++++++++++++++++ 2 files changed, 383 insertions(+), 27 deletions(-) diff --git a/src/services/mcp.ts b/src/services/mcp.ts index d258978..7b9bef2 100644 --- a/src/services/mcp.ts +++ b/src/services/mcp.ts @@ -68,6 +68,7 @@ export type ToolsList = { export type MCPTransportType = 'stdio' | 'streamable_http' export type McpServerSource = 'platform' | 'external' export type McpServerAuthMode = 'none' | 'oauth2' +export type McpExternalOAuthDiscoverySource = 'prm' | 'issuer_override' export interface McpExternalSecretField { name: string @@ -80,7 +81,7 @@ export interface McpExternalSecretField { export interface McpExternalOAuthTemplate { authorizationServerIssuer: string authorizationServerMetadataUrl: string - resourceMetadataUrl: string + resourceMetadataUrl?: string resourceUri: string authorizationEndpoint: string tokenEndpoint: string @@ -89,6 +90,7 @@ export interface McpExternalOAuthTemplate { codeChallengeMethodsSupported: string[] pkceRequired: boolean discoveryMode: 'auto' | 'manual' + discoverySource?: McpExternalOAuthDiscoverySource registrationMode: 'cimd' | 'dcr' | 'manual' manualClientCredentialsAllowed?: boolean clientIdMetadataDocumentSupported?: boolean @@ -98,6 +100,7 @@ export interface McpExternalOAuthTemplate { export interface DiscoverExternalAuthorizationInput { url: string + authorizationServerIssuerOverride?: string } export interface SuccessfulAuthorizationServerMetadataDocument { @@ -124,8 +127,9 @@ export interface DiscoveredAuthorizationServer { export interface DiscoverExternalAuthorizationResult { serverUrl: string - resourceMetadataUrl: string + resourceMetadataUrl?: string resourceUri: string + discoverySource: McpExternalOAuthDiscoverySource challengedScopes?: string[] authorizationServers: DiscoveredAuthorizationServer[] recommendedRegistrationMode: 'cimd' | 'dcr' | 'manual' @@ -442,6 +446,47 @@ export const canonicalizeExternalOAuthResourceUri = (input: string): string => { return parsed.toString() } +const INVALID_ISSUER_OVERRIDE_PATH_SUFFIXES = [ + '/authorize', + '/authorization', + '/oauth/token', + '/token', + '/openid-configuration', + '/oauth-authorization-server' +] as const + +export const normalizeAuthorizationServerIssuerOverride = (input: string): string => { + const trimmed = input.trim() + let parsed: URL + try { + parsed = new URL(trimmed) + } catch { + throw new McpValidationError('authorizationServerIssuerOverride must be a valid absolute HTTPS URL') + } + + if (parsed.protocol !== 'https:') { + throw new McpValidationError('authorizationServerIssuerOverride must use https') + } + if (parsed.username || parsed.password) { + throw new McpValidationError('authorizationServerIssuerOverride must not include embedded credentials') + } + if (parsed.search) { + throw new McpValidationError('authorizationServerIssuerOverride must not include a query string') + } + if (parsed.hash) { + throw new McpValidationError('authorizationServerIssuerOverride must not include a fragment') + } + + const normalizedPathname = parsed.pathname.replace(/\/+$/, '').toLowerCase() || '/' + if (INVALID_ISSUER_OVERRIDE_PATH_SUFFIXES.some((suffix) => normalizedPathname.endsWith(suffix))) { + throw new McpValidationError( + 'authorizationServerIssuerOverride must be an issuer URL, not an authorization, token, or well-known metadata endpoint' + ) + } + + return trimmed +} + export const buildExternalUrlWithSecretQueryParams = ( baseUrl: string, secretValues: Record @@ -636,6 +681,15 @@ export const buildAuthorizationServerMetadataCandidates = (issuerUrl: URL): stri ] } +const resolveRecommendedRegistrationMode = ( + authorizationServers: DiscoveredAuthorizationServer[] +): 'cimd' | 'dcr' | 'manual' => + authorizationServers.some((server) => server.clientIdMetadataDocumentSupported === true) + ? 'cimd' + : authorizationServers.some((server) => typeof server.registrationEndpoint === 'string') + ? 'dcr' + : 'manual' + const buildRequestInit = (headers?: Record): RequestInit | undefined => { if (!headers) { return undefined @@ -862,6 +916,33 @@ export class MCPService implements Resource { return document } + private async probeExternalMcpChallenge( + serverUrl: URL, + discoveryDeadlineMs: number + ): Promise<{ challengedScopes?: string[]; resourceMetadataUrlFromChallenge?: string }> { + try { + const probeResponse = await this.fetchDiscoveryResponse(serverUrl.toString(), discoveryDeadlineMs) + if (probeResponse.status !== 401) { + return {} + } + + const parsedHeader = parseWwwAuthenticateHeader(probeResponse.headers.get('www-authenticate')) + return { + ...(parsedHeader.challengedScopes ? { challengedScopes: parsedHeader.challengedScopes } : {}), + ...(parsedHeader.resourceMetadataUrl + ? { resourceMetadataUrlFromChallenge: parsedHeader.resourceMetadataUrl } + : {}) + } + } catch (error) { + log({ + level: 'warn', + msg: `Unauthenticated external MCP discovery probe failed for ${serverUrl.toString()}; continuing with fallback behavior`, + error + }) + return {} + } + } + private resolveProtectedResourceUri(metadata: Record, transportUrl: string): string { return typeof metadata.resource === 'string' ? metadata.resource @@ -879,23 +960,10 @@ export class MCPService implements Resource { attemptedUrls: string[] }> { const attemptedUrls: string[] = [] - let challengedScopes: string[] | undefined - let resourceMetadataUrlFromChallenge: string | undefined - - try { - const probeResponse = await this.fetchDiscoveryResponse(serverUrl.toString(), discoveryDeadlineMs) - if (probeResponse.status === 401) { - const parsedHeader = parseWwwAuthenticateHeader(probeResponse.headers.get('www-authenticate')) - challengedScopes = parsedHeader.challengedScopes - resourceMetadataUrlFromChallenge = parsedHeader.resourceMetadataUrl - } - } catch (error) { - log({ - level: 'warn', - msg: `Unauthenticated external MCP discovery probe failed for ${serverUrl.toString()}; continuing with RFC 9728 fallback`, - error - }) - } + const { challengedScopes, resourceMetadataUrlFromChallenge } = await this.probeExternalMcpChallenge( + serverUrl, + discoveryDeadlineMs + ) const candidateUrls = [ ...(resourceMetadataUrlFromChallenge ? [resourceMetadataUrlFromChallenge] : []), @@ -1032,6 +1100,55 @@ export class MCPService implements Resource { }) } + private async discoverExternalAuthorizationFromIssuerOverride(input: { + serverUrl: URL + authorizationServerIssuerOverride: string + discoveryDeadlineMs: number + }): Promise { + const { challengedScopes } = await this.probeExternalMcpChallenge(input.serverUrl, input.discoveryDeadlineMs) + const resourceUri = canonicalizeExternalOAuthResourceUri(input.serverUrl.toString()) + const attemptedIssuerMetadataUrls: string[] = [] + + const { documents, attemptedUrls } = await this.discoverAuthorizationServerMetadata( + input.authorizationServerIssuerOverride, + input.discoveryDeadlineMs + ) + attemptedIssuerMetadataUrls.push(...attemptedUrls) + + const primaryDocument = selectPrimaryAuthorizationServerMetadataDocument(documents) + if (!primaryDocument) { + throw new McpDiscoveryFailedError( + 'Unable to discover a usable authorization server for the external MCP server', + [input.serverUrl.toString(), ...attemptedIssuerMetadataUrls], + `Issuer ${input.authorizationServerIssuerOverride} does not advertise PKCE ${REQUIRED_PKCE_CHALLENGE_METHOD}` + ) + } + + const compatibilityDocuments = await this.discoverCompatibilityAuthorizationMetadata( + resourceUri, + input.authorizationServerIssuerOverride, + input.discoveryDeadlineMs + ) + + const authorizationServers = [ + this.buildMergedAuthorizationServer( + input.authorizationServerIssuerOverride, + undefined, + documents, + compatibilityDocuments + ) + ] + + return { + serverUrl: input.serverUrl.toString(), + resourceUri, + discoverySource: 'issuer_override', + ...(challengedScopes && challengedScopes.length > 0 ? { challengedScopes } : {}), + authorizationServers, + recommendedRegistrationMode: resolveRecommendedRegistrationMode(authorizationServers) + } + } + public async discoverExternalAuthorization( input: DiscoverExternalAuthorizationInput ): Promise { @@ -1042,6 +1159,17 @@ export class MCPService implements Resource { const sanitizedInputUrl = buildExtractedExternalUrlSecretMetadata(input.url, undefined).sanitizedUrl const serverUrl = await validateExternalMcpUrl(sanitizedInputUrl) const discoveryDeadlineMs = Date.now() + DISCOVERY_TOTAL_BUDGET_MS + + if (input.authorizationServerIssuerOverride) { + const normalizedIssuerOverride = normalizeAuthorizationServerIssuerOverride(input.authorizationServerIssuerOverride) + await validateExternalMcpUrl(normalizedIssuerOverride) + return this.discoverExternalAuthorizationFromIssuerOverride({ + serverUrl, + authorizationServerIssuerOverride: normalizedIssuerOverride, + discoveryDeadlineMs + }) + } + const { resourceMetadataUrl, resourceUri, challengedScopes, metadata } = await this.discoverProtectedResourceMetadata( serverUrl, discoveryDeadlineMs @@ -1104,13 +1232,10 @@ export class MCPService implements Resource { serverUrl: serverUrl.toString(), resourceMetadataUrl, resourceUri, + discoverySource: 'prm', ...(challengedScopes && challengedScopes.length > 0 ? { challengedScopes } : {}), authorizationServers: discoveredAuthorizationServers, - recommendedRegistrationMode: discoveredAuthorizationServers.some((server) => server.clientIdMetadataDocumentSupported === true) - ? 'cimd' - : discoveredAuthorizationServers.some((server) => typeof server.registrationEndpoint === 'string') - ? 'dcr' - : 'manual' + recommendedRegistrationMode: resolveRecommendedRegistrationMode(discoveredAuthorizationServers) } } @@ -1287,6 +1412,7 @@ export class MCPService implements Resource { const resourceUri = await this.resolveAuthoritativeResourceUri( server.url, server.oauthTemplate.discoveryMode, + server.oauthTemplate.discoverySource, server.oauthTemplate.resourceMetadataUrl ) const persistedOauthTemplate: McpExternalOAuthTemplate = { @@ -1320,6 +1446,9 @@ export class MCPService implements Resource { return { ...server.oauthTemplate, + ...(server.oauthTemplate.discoveryMode === 'auto' && !server.oauthTemplate.discoverySource + ? { discoverySource: 'prm' as const } + : {}), resourceUri: server.oauthTemplate.resourceUri ?? canonicalizeExternalOAuthResourceUri(server.url) } @@ -1462,13 +1591,19 @@ export class MCPService implements Resource { private async resolveAuthoritativeResourceUri( transportUrl: string, discoveryMode: McpExternalOAuthTemplate['discoveryMode'], + discoverySource?: McpExternalOAuthDiscoverySource, resourceMetadataUrl?: string ): Promise { if (discoveryMode !== 'auto') { return canonicalizeExternalOAuthResourceUri(transportUrl) } + + const normalizedDiscoverySource = discoverySource ?? 'prm' + if (normalizedDiscoverySource === 'issuer_override') { + return canonicalizeExternalOAuthResourceUri(transportUrl) + } if (!resourceMetadataUrl) { - throw new McpValidationError('oauthTemplate.resourceMetadataUrl is required in discovery mode') + throw new McpValidationError('oauthTemplate.resourceMetadataUrl is required for PRM-backed discovery mode') } const discoveryDeadlineMs = Date.now() + DISCOVERY_TOTAL_BUDGET_MS @@ -1505,14 +1640,35 @@ export class MCPService implements Resource { } if (oauthTemplate.discoveryMode === 'auto') { + const normalizedDiscoverySource = oauthTemplate.discoverySource ?? 'prm' this.assertAbsoluteUrl( oauthTemplate.authorizationServerMetadataUrl, 'oauthTemplate.authorizationServerMetadataUrl' ) - this.assertAbsoluteUrl(oauthTemplate.resourceMetadataUrl, 'oauthTemplate.resourceMetadataUrl') + this.assertAbsoluteHttpsUrl(oauthTemplate.authorizationServerIssuer, 'oauthTemplate.authorizationServerIssuer') if (!oauthTemplate.authorizationServerIssuer.trim()) { throw new McpValidationError('oauthTemplate.authorizationServerIssuer is required in discovery mode') } + if (normalizedDiscoverySource === 'prm') { + if (!oauthTemplate.resourceMetadataUrl) { + throw new McpValidationError('oauthTemplate.resourceMetadataUrl is required for PRM-backed discovery mode') + } + this.assertAbsoluteUrl(oauthTemplate.resourceMetadataUrl, 'oauthTemplate.resourceMetadataUrl') + } else if (normalizedDiscoverySource === 'issuer_override') { + if (oauthTemplate.resourceMetadataUrl) { + throw new McpValidationError('oauthTemplate.resourceMetadataUrl must be omitted for issuer-override discovery mode') + } + if (!transportUrl) { + throw new McpValidationError('External OAuth transport url is required for issuer-override mode validation') + } + if (oauthTemplate.resourceUri !== canonicalizeExternalOAuthResourceUri(transportUrl)) { + throw new McpValidationError( + 'oauthTemplate.resourceUri must equal the canonicalized transport url in issuer-override discovery mode' + ) + } + } else { + throw new McpValidationError('oauthTemplate.discoverySource must be prm or issuer_override in discovery mode') + } if ( oauthTemplate.registrationMode === 'dcr' && !oauthTemplate.registrationEndpoint @@ -1530,6 +1686,9 @@ export class MCPService implements Resource { } else { this.assertAbsoluteHttpsUrl(oauthTemplate.authorizationEndpoint, 'oauthTemplate.authorizationEndpoint') this.assertAbsoluteHttpsUrl(oauthTemplate.tokenEndpoint, 'oauthTemplate.tokenEndpoint') + if (oauthTemplate.discoverySource) { + throw new McpValidationError('oauthTemplate.discoverySource must be omitted in manual mode') + } if (oauthTemplate.registrationMode !== 'manual') { throw new McpValidationError('Manual OAuth template mode must use manual registration mode') } @@ -1568,21 +1727,38 @@ export class MCPService implements Resource { } if (oauthTemplate.discoveryMode === 'auto') { + const normalizedDiscoverySource = oauthTemplate.discoverySource ?? 'prm' this.assertAbsoluteUrl( oauthTemplate.authorizationServerMetadataUrl, 'oauthTemplate.authorizationServerMetadataUrl' ) - this.assertAbsoluteUrl(oauthTemplate.resourceMetadataUrl, 'oauthTemplate.resourceMetadataUrl') + this.assertAbsoluteHttpsUrl(oauthTemplate.authorizationServerIssuer, 'oauthTemplate.authorizationServerIssuer') if (!oauthTemplate.authorizationServerIssuer.trim()) { throw new McpValidationError('oauthTemplate.authorizationServerIssuer is required in discovery mode') } + if (normalizedDiscoverySource === 'prm') { + if (!oauthTemplate.resourceMetadataUrl) { + throw new McpValidationError('oauthTemplate.resourceMetadataUrl is required for PRM-backed discovery mode') + } + this.assertAbsoluteUrl(oauthTemplate.resourceMetadataUrl, 'oauthTemplate.resourceMetadataUrl') + } else if (normalizedDiscoverySource === 'issuer_override') { + if (oauthTemplate.resourceMetadataUrl) { + throw new McpValidationError('oauthTemplate.resourceMetadataUrl must be omitted for issuer-override discovery mode') + } + } else { + throw new McpValidationError('oauthTemplate.discoverySource must be prm or issuer_override in discovery mode') + } } const normalizedTemplate: McpExternalOAuthTemplate = { ...oauthTemplate, + ...(oauthTemplate.discoveryMode === 'auto' && !oauthTemplate.discoverySource + ? { discoverySource: 'prm' as const } + : {}), resourceUri: await this.resolveAuthoritativeResourceUri( transportUrl, oauthTemplate.discoveryMode, + oauthTemplate.discoverySource, oauthTemplate.resourceMetadataUrl ) } diff --git a/test/mcp-external-auth.spec.ts b/test/mcp-external-auth.spec.ts index cb43843..653d79c 100644 --- a/test/mcp-external-auth.spec.ts +++ b/test/mcp-external-auth.spec.ts @@ -1,5 +1,6 @@ import { requireUsername } from '../src/controllers/mcp' import { + MCPService, resolveUserConnectionTeardownPolicy, assertTransportConfigCompatible, buildAuthorizationServerMetadataCandidates, @@ -7,10 +8,12 @@ import { buildProtectedResourceMetadataCandidates, buildExternalUrlWithSecretQueryParams, canonicalizeExternalOAuthResourceUri, + normalizeAuthorizationServerIssuerOverride, parseWwwAuthenticateHeader, shouldFallbackToSse } from '../src/services/mcp' import { StreamableHTTPError } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import dns from 'dns/promises' import { McpServerAlreadyExistsError, McpReauthRequiredError, @@ -77,6 +80,30 @@ describe('external MCP request validation', () => { ).toBe('https://example.com/public/mcp') }) + test('normalizes valid issuer override URLs and preserves pathful issuers', () => { + expect(normalizeAuthorizationServerIssuerOverride(' https://auth.example.com/oauth2/default ')).toBe( + 'https://auth.example.com/oauth2/default' + ) + }) + + test('rejects malformed issuer override values', () => { + expect(() => normalizeAuthorizationServerIssuerOverride('auth.example.com')).toThrow( + 'authorizationServerIssuerOverride must be a valid absolute HTTPS URL' + ) + expect(() => normalizeAuthorizationServerIssuerOverride('http://auth.example.com')).toThrow( + 'authorizationServerIssuerOverride must use https' + ) + expect(() => normalizeAuthorizationServerIssuerOverride('https://auth.example.com?foo=bar')).toThrow( + 'authorizationServerIssuerOverride must not include a query string' + ) + expect(() => normalizeAuthorizationServerIssuerOverride('https://user:pass@auth.example.com')).toThrow( + 'authorizationServerIssuerOverride must not include embedded credentials' + ) + expect(() => normalizeAuthorizationServerIssuerOverride('https://auth.example.com/authorize')).toThrow( + 'authorizationServerIssuerOverride must be an issuer URL, not an authorization, token, or well-known metadata endpoint' + ) + }) + test('applies external user secrets as query params without mutating the shared base url', () => { expect( buildExternalUrlWithSecretQueryParams('https://example.com/mcp?mode=connect', { @@ -320,3 +347,156 @@ describe('external MCP teardown policy', () => { expect(resolveInitialUserInstallAuthState('connected', 'oauth2')).toBe('connected') }) }) + +describe('external MCP issuer-override discovery', () => { + const originalFetch = global.fetch + + afterEach(() => { + jest.restoreAllMocks() + global.fetch = originalFetch + }) + + test('discovers auth metadata from an issuer override when PRM is unavailable', async () => { + jest.spyOn(dns, 'lookup').mockResolvedValue([{ address: '93.184.216.34', family: 4 }] as never) + + global.fetch = jest.fn(async (input: string | URL | Request) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url + + if (url === 'https://mcp.example.com/v1/mcp') { + return new Response( + JSON.stringify({ error: 'invalid_token' }), + { + status: 401, + headers: { + 'content-type': 'application/json', + 'www-authenticate': 'Bearer scope="files:read files:write"' + } + } + ) + } + + if (url === 'https://auth.example.com/.well-known/oauth-authorization-server') { + return new Response( + JSON.stringify({ + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/oauth/token', + code_challenge_methods_supported: ['S256'] + }), + { + status: 200, + headers: { 'content-type': 'application/json' } + } + ) + } + + if (url === 'https://auth.example.com/.well-known/openid-configuration') { + return new Response( + JSON.stringify({ + issuer: 'https://auth.example.com', + registration_endpoint: 'https://auth.example.com/register', + token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'] + }), + { + status: 200, + headers: { 'content-type': 'application/json' } + } + ) + } + + return new Response('Not Found', { status: 404 }) + }) as typeof global.fetch + + const service = new MCPService({ + mongoParams: { host: 'localhost:27017', db: 'test', user: 'user', pass: 'pass' }, + secretsService: {} as never, + userServerInstalls: {} as never + }) + + const result = await service.discoverExternalAuthorization({ + url: 'https://mcp.example.com/v1/mcp', + authorizationServerIssuerOverride: 'https://auth.example.com' + }) + + expect(result).toMatchObject({ + serverUrl: 'https://mcp.example.com/v1/mcp', + resourceUri: 'https://mcp.example.com/v1/mcp', + discoverySource: 'issuer_override', + challengedScopes: ['files:read', 'files:write'], + authorizationServers: [ + { + issuer: 'https://auth.example.com', + authorizationServerMetadataUrl: 'https://auth.example.com/.well-known/oauth-authorization-server', + authorizationEndpoint: 'https://auth.example.com/authorize', + tokenEndpoint: 'https://auth.example.com/oauth/token', + codeChallengeMethodsSupported: ['S256'], + registrationEndpoint: 'https://auth.example.com/register', + tokenEndpointAuthMethodsSupported: ['client_secret_basic'] + } + ], + recommendedRegistrationMode: 'dcr' + }) + }) + + test('normalizes legacy discovery-backed templates to PRM mode at runtime', () => { + const service = new MCPService({ + mongoParams: { host: 'localhost:27017', db: 'test', user: 'user', pass: 'pass' }, + secretsService: {} as never, + userServerInstalls: {} as never + }) + + const normalized = (service as any).normalizeExternalOAuthTemplateForRuntime({ + name: 'remote-drive', + source: 'external', + authMode: 'oauth2', + transportType: 'streamable_http', + url: 'https://mcp.example.com/v1/mcp?token=123', + status: 'disconnected', + enabled: true, + oauthTemplate: { + authorizationServerIssuer: 'https://auth.example.com', + authorizationServerMetadataUrl: 'https://auth.example.com/.well-known/oauth-authorization-server', + resourceMetadataUrl: 'https://mcp.example.com/.well-known/oauth-protected-resource/v1/mcp', + authorizationEndpoint: 'https://auth.example.com/authorize', + tokenEndpoint: 'https://auth.example.com/oauth/token', + codeChallengeMethodsSupported: ['S256'], + pkceRequired: true, + discoveryMode: 'auto', + registrationMode: 'manual' + } + }) + + expect(normalized).toMatchObject({ + discoverySource: 'prm', + resourceUri: 'https://mcp.example.com/v1/mcp' + }) + }) + + test('rejects issuer-override templates that carry a PRM resource metadata url', () => { + const service = new MCPService({ + mongoParams: { host: 'localhost:27017', db: 'test', user: 'user', pass: 'pass' }, + secretsService: {} as never, + userServerInstalls: {} as never + }) + + expect(() => + (service as any).validateExternalOAuthTemplate( + 'oauth2', + { + authorizationServerIssuer: 'https://auth.example.com', + authorizationServerMetadataUrl: 'https://auth.example.com/.well-known/oauth-authorization-server', + resourceMetadataUrl: 'https://mcp.example.com/.well-known/oauth-protected-resource/v1/mcp', + resourceUri: 'https://mcp.example.com/v1/mcp', + authorizationEndpoint: 'https://auth.example.com/authorize', + tokenEndpoint: 'https://auth.example.com/oauth/token', + codeChallengeMethodsSupported: ['S256'], + pkceRequired: true, + discoveryMode: 'auto', + discoverySource: 'issuer_override', + registrationMode: 'manual' + }, + 'https://mcp.example.com/v1/mcp' + ) + ).toThrow('oauthTemplate.resourceMetadataUrl must be omitted for issuer-override discovery mode') + }) +})