diff --git a/package-lock.json b/package-lock.json index 30936e28..7b61ebfb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "anythingmcp", - "version": "0.3.5", + "version": "0.3.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "anythingmcp", - "version": "0.3.5", + "version": "0.3.6", "license": "AGPL-3.0-only", "workspaces": [ "packages/backend", @@ -27254,7 +27254,7 @@ }, "packages/backend": { "name": "@anythingmcp/backend", - "version": "0.3.5", + "version": "0.3.6", "license": "AGPL-3.0-only", "dependencies": { "@nestjs/common": "^11.1.28", @@ -27609,7 +27609,7 @@ }, "packages/frontend": { "name": "@anythingmcp/frontend", - "version": "0.3.5", + "version": "0.3.6", "license": "AGPL-3.0-only", "dependencies": { "@radix-ui/react-dialog": "^1.1.19", diff --git a/package.json b/package.json index a1eaa4e5..6a0a8fd4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "anythingmcp", - "version": "0.3.5", + "version": "0.3.6", "description": "Self-hosted MCP gateway for REST, SOAP/WSDL, GraphQL and SQL — turn any API into MCP tools for Claude, ChatGPT, Gemini, Copilot and Cursor. 30+ pre-built adapters, on-prem audit log, OAuth2/RBAC. Open source (AGPL-3.0).", "private": true, "license": "AGPL-3.0-only", diff --git a/packages/backend/package.json b/packages/backend/package.json index 967786eb..2c5f43d7 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,6 +1,6 @@ { "name": "@anythingmcp/backend", - "version": "0.3.5", + "version": "0.3.6", "description": "AnythingMCP — NestJS Backend + Dynamic MCP Server", "private": true, "license": "AGPL-3.0-only", diff --git a/packages/backend/src/adapters/catalog.ts b/packages/backend/src/adapters/catalog.ts index 7b6d7688..cd68ad88 100644 --- a/packages/backend/src/adapters/catalog.ts +++ b/packages/backend/src/adapters/catalog.ts @@ -3,6 +3,7 @@ import * as billbee from './de/billbee.json'; import * as billomat from './de/billomat.json'; import * as bundesbank from './de/bundesbank.json'; import * as datev from './de/datev.json'; +import * as datevSandbox from './de/datev-sandbox.json'; import * as destatisGenesis from './de/destatis-genesis.json'; import * as deutscheBahn from './de/deutsche-bahn.json'; import * as dhlTracking from './de/dhl-tracking.json'; @@ -283,6 +284,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ billomat as unknown as AdapterDefinition, bundesbank as unknown as AdapterDefinition, datev as unknown as AdapterDefinition, + datevSandbox as unknown as AdapterDefinition, destatisGenesis as unknown as AdapterDefinition, deutscheBahn as unknown as AdapterDefinition, dhlTracking as unknown as AdapterDefinition, diff --git a/packages/backend/src/adapters/de/datev-sandbox.json b/packages/backend/src/adapters/de/datev-sandbox.json new file mode 100644 index 00000000..a0149705 --- /dev/null +++ b/packages/backend/src/adapters/de/datev-sandbox.json @@ -0,0 +1,168 @@ +{ + "slug": "datev-sandbox", + "name": "DATEV Online APIs (Sandbox)", + "description": "Sandbox environment for the DATEV Online APIs — read accounting Mandanten (clients), document types and DUO version metadata against DATEV's test data. Use this while you build and test; every new DATEV app must run in sandbox until DATEV grants production approval. Same OAuth2 Authorization Code + PKCE flow as production, pointed at DATEV's sandbox endpoints.", + "instructions": "**Use this connector for testing.** Every DATEV app runs in the sandbox until DATEV completes its production-approval review — so this is where you start.\n\n**Setup**:\n1. Create a **Confidential** app on the DATEV-Entwicklerportal (https://developer.datev.de) with **OpenID Connect Authorization Code Flow**. Set the redirect URI to `/api/mcp-oauth/callback`.\n2. Subscribe the app to the sandbox API products: **accounting:clients 2.0** and **accounting:documents 2.0**.\n3. Set `DATEV_CLIENT_ID` and `DATEV_CLIENT_SECRET` (from the portal — copy the secret in full, it is shown only once).\n4. Import this adapter — it is pre-configured for the sandbox (authorize `login.datev.de/openidsandbox/authorize`, token `sandbox-api.datev.de/token`, API base `platform-sandbox/v2`) and authenticates at the token endpoint via **HTTP Basic** (`client_secret_basic`), which DATEV requires.\n5. Run the one-time OAuth flow: `POST /api/connectors/{id}/oauth/authorize` -> open the returned URL -> log in with your DATEV-Konto -> the platform stores an encrypted refresh token automatically.\n6. Every call then sends `Authorization: Bearer ` AND the mandatory `X-DATEV-Client-Id: ` header automatically.\n\n**Going to production**: once DATEV approves your app for production, use the separate **DATEV Online APIs** connector (same credentials, production endpoints).\n\n**Scopes requested**: `datev:accounting:clients accounting:clients:read accounting:documents`. Do NOT add `openid` — it is the issuer name, not a requested scope.\n\n**Mandant scoping**: every per-client tool takes a `clientId` parameter — the **Mandant UUID** (RFC4122), returned by `datev_list_clients`. Distinct from `DATEV_CLIENT_ID` (the OAuth app identifier, sent in the header).", + "region": "de", + "category": "accounting", + "icon": "datev", + "docsUrl": "https://developer.datev.de/en/products/988700e4-e0c5-40bf-814b-cd7327dad67a", + "requiredEnvVars": [ + "DATEV_CLIENT_ID", + "DATEV_CLIENT_SECRET" + ], + "connector": { + "name": "DATEV Online APIs (Sandbox)", + "type": "REST", + "baseUrl": "https://accounting-clients.api.datev.de/platform-sandbox/v2", + "authType": "OAUTH2", + "authConfig": { + "clientId": "{{DATEV_CLIENT_ID}}", + "clientSecret": "{{DATEV_CLIENT_SECRET}}", + "authorizationUrl": "https://login.datev.de/openidsandbox/authorize", + "tokenUrl": "https://sandbox-api.datev.de/token", + "tokenAuthMethod": "basic", + "scopes": "datev:accounting:clients accounting:clients:read accounting:documents" + }, + "headers": { + "X-DATEV-Client-Id": "{{DATEV_CLIENT_ID}}" + } + }, + "tools": [ + { + "name": "datev_list_clients", + "description": "List DATEV accounting Mandanten (clients) your DATEV-Konto user is provisioned for, from the accounting-clients v2 master-data product. Returns each client's UUID, consultant number, client number and display name. Use the returned UUID as the `clientId` parameter on all other tools.", + "parameters": { + "type": "object", + "properties": { + "skip": { + "type": "integer", + "description": "Pagination offset (>= 0)." + }, + "top": { + "type": "integer", + "description": "Max items per page (1-100)." + } + } + }, + "endpointMapping": { + "method": "GET", + "path": "https://accounting-clients.api.datev.de/platform-sandbox/v2/clients", + "queryParams": { + "skip": "$skip", + "top": "$top" + }, + "headers": { + "Accept": "application/json" + } + } + }, + { + "name": "datev_get_client", + "description": "Get one Mandant's master data (consultant/client number, name, address) from accounting-clients v2.", + "parameters": { + "type": "object", + "properties": { + "clientId": { + "type": "string", + "description": "Mandant UUID (RFC4122 8-4-4-4-12). Get this from datev_list_clients." + } + }, + "required": [ + "clientId" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "https://accounting-clients.api.datev.de/platform-sandbox/v2/clients/{clientId}", + "headers": { + "Accept": "application/json" + } + } + }, + { + "name": "datev_documents_list_clients", + "description": "List the subset of Mandanten that have DATEV Unternehmen online (DUO) document access via the accounting-documents v2 product. This may be smaller than datev_list_clients — only clients with active DUO subscriptions appear here.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "https://accounting-documents.api.datev.de/platform-sandbox/v2/clients", + "headers": { + "Accept": "application/json" + } + } + }, + { + "name": "datev_get_documents_client", + "description": "Get one Mandant's basic data as exposed by the accounting-documents v2 product (the DUO view, distinct from the accounting-clients master record).", + "parameters": { + "type": "object", + "properties": { + "clientId": { + "type": "string", + "description": "Mandant UUID (from datev_list_clients or datev_documents_list_clients)." + } + }, + "required": [ + "clientId" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "https://accounting-documents.api.datev.de/platform-sandbox/v2/clients/{clientId}", + "headers": { + "Accept": "application/json" + } + } + }, + { + "name": "datev_list_document_types", + "description": "List the document types (Belegtypen) configured for a Mandant on DATEV Unternehmen online — needed before uploading documents so you know which `documentType` values are accepted.", + "parameters": { + "type": "object", + "properties": { + "clientId": { + "type": "string", + "description": "Mandant UUID (from datev_list_clients)." + } + }, + "required": [ + "clientId" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "https://accounting-documents.api.datev.de/platform-sandbox/v2/clients/{clientId}/document-types", + "headers": { + "Accept": "application/json" + } + } + }, + { + "name": "datev_get_duo_version", + "description": "Get the DATEV Unternehmen online (DUO) version and the permitted file extensions for document upload on a given Mandant. Useful before generating uploads to know what file formats DUO will accept (PDF, JPG, TIFF, etc.) and any DUO-version-specific limits.", + "parameters": { + "type": "object", + "properties": { + "clientId": { + "type": "string", + "description": "Mandant UUID (from datev_list_clients)." + } + }, + "required": [ + "clientId" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "https://accounting-documents.api.datev.de/platform-sandbox/v2/clients/{clientId}/duo-version", + "headers": { + "Accept": "application/json" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/de/datev.json b/packages/backend/src/adapters/de/datev.json index a58d18f7..deb842ce 100644 --- a/packages/backend/src/adapters/de/datev.json +++ b/packages/backend/src/adapters/de/datev.json @@ -2,12 +2,15 @@ "slug": "datev", "name": "DATEV Online APIs", "description": "Read accounting Mandanten (clients), document types and DATEV Unternehmen online (DUO) version metadata from DATEV's official Online APIs. OAuth2 Authorization Code + PKCE bound to a real DATEV-Konto. Required by virtually every German tax consultant (Steuerberater).", - "instructions": "**Important — this connector is NOT plug-and-play.** DATEV's Online APIs require:\n\n1. A registered app on the DATEV-Entwicklerportal (https://developer.datev.de) → **Confidential client** + **OpenID Connect Authorization Code Flow**. Set the redirect URI to `/api/mcp-oauth/callback`.\n2. A subscription to each API product you want to use — currently `accounting-clients v2.0` and `accounting-documents v2.0`. Both are *Already subscribed* checks on the product page.\n3. A real DATEV-Konto user with SmartLogin or SmartCard/mIDentity. DATEV does NOT expose `client_credentials` — every call is on-behalf-of a human accountant. Confidential clients get 2-year rolling refresh tokens (the engine refreshes proactively).\n4. For *.api.datev.de (production) you must complete DATEV's production-approval review. Until then, point the connector at the **sandbox** (see below).\n\n**Authentication endpoints** (canonical, from `https://login.datev.de/openid/.well-known/openid-configuration`):\n- Production: `authorize=https://login.datev.de/openid/authorize`, `token=https://api.datev.de/token`\n- Sandbox: `authorize=https://login.datev.de/openidsandbox/authorize`, `token=https://sandbox-api.datev.de/token`\n\n**Setup**:\n1. Set `DATEV_CLIENT_ID` and `DATEV_CLIENT_SECRET` (from the DATEV portal).\n2. Import this adapter — defaults to **production** auth URLs.\n3. Run the one-time OAuth flow: `POST /api/connectors/{id}/oauth/authorize` → open the returned URL → log in with your DATEV-Konto → the platform stores an encrypted refresh token automatically.\n4. From now on every call sends `Authorization: Bearer ` AND the mandatory `X-DATEV-Client-Id: ` header automatically.\n\n**Switching to sandbox** (DATEV apps default to sandbox after creation, until production approval): after import, edit the connector's `authConfig`:\n- `authorizationUrl` → `https://login.datev.de/openidsandbox/authorize`\n- `tokenUrl` → `https://sandbox-api.datev.de/token`\n\nAND replace `platform` with `platform-sandbox` in each tool's `endpointMapping.path` (5 paths). A future iteration may expose this via env-var switching.\n\n**Scopes requested**: `datev:accounting:clients accounting:clients:read accounting:documents`. These cover both API products in one consent screen. Note: the current DATEV `datev_openid` flow does NOT include `openid` in its scope list (it's the issuer name, not a requested scope) — confirmed in the OpenAPI security schemes.\n\n**Mandant scoping**: every per-client tool takes a `clientId` parameter — that's the **Mandant UUID** (RFC4122 8-4-4-4-12), returned by `datev_list_clients`. **Distinct** from `DATEV_CLIENT_ID` (the OAuth app identifier, sent in the header).\n\n**Not exposed here** (engine limitation):\n- `POST /clients/{id}/documents`, `PUT /clients/{id}/documents/{guid}`, `PUT /clients/{id}/documents/stapled`: all require `multipart/form-data` with a binary file part. The REST engine currently serializes form-data field values via `String(v)`, which corrupts binary PDFs/images. These upload tools land once the engine accepts `Buffer`/base64 in form-data values.\n- `accounting:dataexchange`, `accounting:dxso-jobs`, `accounting:extf-files`, HR / Lohn, DMS, addressee: separate adapters per product line.\n- The `datev_idp_next` security option (next-gen `signin.datev.de/datevam/...` IdP) is defined in the spec but not yet exposed here.", + "instructions": "**Important — this connector is NOT plug-and-play.** DATEV's Online APIs require:\n\n1. A registered app on the DATEV-Entwicklerportal (https://developer.datev.de) → **Confidential client** + **OpenID Connect Authorization Code Flow**. Set the redirect URI to `/api/mcp-oauth/callback`.\n2. A subscription to each API product you want to use — currently `accounting-clients v2.0` and `accounting-documents v2.0`. Both are *Already subscribed* checks on the product page.\n3. A real DATEV-Konto user with SmartLogin or SmartCard/mIDentity. DATEV does NOT expose `client_credentials` — every call is on-behalf-of a human accountant. Confidential clients get 2-year rolling refresh tokens (the engine refreshes proactively).\n4. For *.api.datev.de (production) you must complete DATEV's production-approval review. Until then, point the connector at the **sandbox** (see below).\n\n**Authentication endpoints** (canonical, from `https://login.datev.de/openid/.well-known/openid-configuration`):\n- Production: `authorize=https://login.datev.de/openid/authorize`, `token=https://api.datev.de/token`\n- Sandbox: `authorize=https://login.datev.de/openidsandbox/authorize`, `token=https://sandbox-api.datev.de/token`\n\n**Setup**:\n1. Set `DATEV_CLIENT_ID` and `DATEV_CLIENT_SECRET` (from the DATEV portal).\n2. Import this adapter — defaults to **production** auth URLs.\n3. Run the one-time OAuth flow: `POST /api/connectors/{id}/oauth/authorize` → open the returned URL → log in with your DATEV-Konto → the platform stores an encrypted refresh token automatically.\n4. From now on every call sends `Authorization: Bearer ` AND the mandatory `X-DATEV-Client-Id: ` header automatically.\n\n**Testing first?** Every DATEV app runs in the sandbox until DATEV grants production approval. For sandbox testing use the dedicated **DATEV Online APIs (Sandbox)** connector (pre-configured for the sandbox endpoints) rather than editing this one — then switch to this production connector once approved, using the same credentials.\n\n**Scopes requested**: `datev:accounting:clients accounting:clients:read accounting:documents`. These cover both API products in one consent screen. Note: the current DATEV `datev_openid` flow does NOT include `openid` in its scope list (it's the issuer name, not a requested scope) — confirmed in the OpenAPI security schemes.\n\n**Mandant scoping**: every per-client tool takes a `clientId` parameter — that's the **Mandant UUID** (RFC4122 8-4-4-4-12), returned by `datev_list_clients`. **Distinct** from `DATEV_CLIENT_ID` (the OAuth app identifier, sent in the header).\n\n**Not exposed here** (engine limitation):\n- `POST /clients/{id}/documents`, `PUT /clients/{id}/documents/{guid}`, `PUT /clients/{id}/documents/stapled`: all require `multipart/form-data` with a binary file part. The REST engine currently serializes form-data field values via `String(v)`, which corrupts binary PDFs/images. These upload tools land once the engine accepts `Buffer`/base64 in form-data values.\n- `accounting:dataexchange`, `accounting:dxso-jobs`, `accounting:extf-files`, HR / Lohn, DMS, addressee: separate adapters per product line.\n- The `datev_idp_next` security option (next-gen `signin.datev.de/datevam/...` IdP) is defined in the spec but not yet exposed here.", "region": "de", "category": "accounting", "icon": "datev", "docsUrl": "https://developer.datev.de/en/products/988700e4-e0c5-40bf-814b-cd7327dad67a", - "requiredEnvVars": ["DATEV_CLIENT_ID", "DATEV_CLIENT_SECRET"], + "requiredEnvVars": [ + "DATEV_CLIENT_ID", + "DATEV_CLIENT_SECRET" + ], "connector": { "name": "DATEV Online APIs", "type": "REST", @@ -65,7 +68,9 @@ "description": "Mandant UUID (RFC4122 8-4-4-4-12). Get this from datev_list_clients." } }, - "required": ["clientId"] + "required": [ + "clientId" + ] }, "endpointMapping": { "method": "GET", @@ -101,7 +106,9 @@ "description": "Mandant UUID (from datev_list_clients or datev_documents_list_clients)." } }, - "required": ["clientId"] + "required": [ + "clientId" + ] }, "endpointMapping": { "method": "GET", @@ -122,7 +129,9 @@ "description": "Mandant UUID (from datev_list_clients)." } }, - "required": ["clientId"] + "required": [ + "clientId" + ] }, "endpointMapping": { "method": "GET", @@ -143,7 +152,9 @@ "description": "Mandant UUID (from datev_list_clients)." } }, - "required": ["clientId"] + "required": [ + "clientId" + ] }, "endpointMapping": { "method": "GET", diff --git a/packages/backend/src/connectors/connectors.service.ts b/packages/backend/src/connectors/connectors.service.ts index 5cf59d20..1593faa9 100644 --- a/packages/backend/src/connectors/connectors.service.ts +++ b/packages/backend/src/connectors/connectors.service.ts @@ -157,6 +157,31 @@ export class ConnectorsService { }); } + /** + * Merges `patch` into the connector's existing (decrypted) authConfig and + * re-encrypts it, instead of replacing the whole object. Used by the OAuth + * callback so storing the freshly-issued tokens does not drop static config + * such as `authorizationUrl` and `scopes` (needed for later re-authorization). + */ + async updateAuthConfigMerge( + id: string, + patch: Record, + ): Promise { + const connector = await this.findByIdInternal(id); + const existing = connector.authConfig + ? (JSON.parse( + decrypt(connector.authConfig, this.encryptionKey), + ) as Record) + : {}; + const merged = { ...existing, ...patch }; + return this.prisma.connector.update({ + where: { id }, + data: { + authConfig: encrypt(JSON.stringify(merged), this.encryptionKey), + }, + }); + } + async remove(id: string): Promise { await this.findById(id); await this.prisma.connector.delete({ where: { id } }); diff --git a/packages/backend/src/connectors/engines/oauth2-token.service.spec.ts b/packages/backend/src/connectors/engines/oauth2-token.service.spec.ts index 16fe8497..68d60e5b 100644 --- a/packages/backend/src/connectors/engines/oauth2-token.service.spec.ts +++ b/packages/backend/src/connectors/engines/oauth2-token.service.spec.ts @@ -407,4 +407,48 @@ describe('OAuth2TokenService', () => { expect(token).toBe('fresh'); }); }); + + describe('rolling refresh tokens', () => { + it('refreshes using the freshest refresh token from the DB, not the stale snapshot', async () => { + // DB holds the rotated ("fresh") refresh token; the caller passes a stale + // snapshot. The refresh must use the DB value (DATEV invalidates the old one). + mockPrisma.connector.findUnique.mockResolvedValue({ + authConfig: encrypt( + JSON.stringify({ + tokenUrl: 'https://sandbox-api.datev.de/token', + refreshToken: 'FRESH_RT', + clientId: 'cid', + clientSecret: 'sec', + tokenAuthMethod: 'basic', + }), + encryptionKey, + ), + }); + mockedAxios.post.mockResolvedValue({ + data: { access_token: 'AT2', refresh_token: 'RT2', expires_in: 3600 }, + }); + + const result = await service.refreshToken( + { + tokenUrl: 'https://sandbox-api.datev.de/token', + refreshToken: 'STALE_RT', + clientId: 'cid', + clientSecret: 'sec', + tokenAuthMethod: 'basic', + }, + 'conn-1', + ); + + expect(result).toBe('AT2'); + const body = String(mockedAxios.post.mock.calls[0][1]); + expect(body).toContain('refresh_token=FRESH_RT'); + expect(body).not.toContain('STALE_RT'); + // Basic auth used (DATEV requires client_secret_basic), secret not in body. + const cfg = mockedAxios.post.mock.calls[0][2] as any; + expect(cfg.headers.Authorization).toBe( + 'Basic ' + Buffer.from('cid:sec').toString('base64'), + ); + expect(body).not.toContain('client_secret='); + }); + }); }); diff --git a/packages/backend/src/connectors/engines/oauth2-token.service.ts b/packages/backend/src/connectors/engines/oauth2-token.service.ts index b164f4ab..47b206b0 100644 --- a/packages/backend/src/connectors/engines/oauth2-token.service.ts +++ b/packages/backend/src/connectors/engines/oauth2-token.service.ts @@ -100,6 +100,17 @@ export class OAuth2TokenService { authConfig: Record, connectorId?: string, ): Promise { + // Rolling refresh tokens (e.g. DATEV rotates the refresh token on every + // use) invalidate the previous one. The in-memory registry caches an + // authConfig snapshot that is NOT updated after a refresh — only the DB is + // (persistRefreshedToken). So for a persisted connector always re-read the + // freshest authConfig from the DB before refreshing; otherwise a second + // refresh would replay the already-rotated token and DATEV would reject it. + if (connectorId) { + const fresh = await this.loadAuthConfigFromDb(connectorId); + if (fresh) authConfig = { ...authConfig, ...fresh }; + } + const tokenUrl = String(authConfig.tokenUrl || ''); const grant = String(authConfig.grant || 'refresh_token'); const refreshToken = String(authConfig.refreshToken || ''); @@ -263,6 +274,29 @@ export class OAuth2TokenService { * Update the connector's encrypted authConfig with the new access token * so it survives server restarts. */ + /** + * Reads and decrypts the connector's current authConfig straight from the DB. + * Used to obtain the freshest (possibly-rotated) refresh token, bypassing the + * stale in-memory registry snapshot. Returns null if unavailable. + */ + private async loadAuthConfigFromDb( + connectorId: string, + ): Promise | null> { + try { + const connector = await this.prisma.connector.findUnique({ + where: { id: connectorId }, + select: { authConfig: true }, + }); + if (!connector?.authConfig) return null; + return JSON.parse(decrypt(connector.authConfig, this.encryptionKey)); + } catch (err: any) { + this.logger.warn( + `OAuth2: failed to load fresh authConfig for ${connectorId}: ${err.message}`, + ); + return null; + } + } + private async persistRefreshedToken( connectorId: string, newAccessToken: string, diff --git a/packages/backend/src/connectors/mcp-oauth-callback.controller.spec.ts b/packages/backend/src/connectors/mcp-oauth-callback.controller.spec.ts new file mode 100644 index 00000000..5fcdf019 --- /dev/null +++ b/packages/backend/src/connectors/mcp-oauth-callback.controller.spec.ts @@ -0,0 +1,92 @@ +import { McpOAuthCallbackController } from './mcp-oauth-callback.controller'; + +/** + * Regression test for the REST OAuth reload gap: after a REST/GraphQL connector + * completes the OAuth flow, the freshly-stored access token must be loaded into + * the in-memory MCP registry. The MCP auto-discovery below throws for non-MCP + * servers, so the reload must happen independently of it. + */ +function makeController(overrides: { + listToolsThrows?: boolean; +} = {}) { + const reloadConnectorTools = jest.fn().mockResolvedValue(undefined); + const updateAuthConfigMerge = jest.fn().mockResolvedValue(undefined); + const deletePendingFlow = jest.fn(); + + const mcpOAuthService: any = { + getPendingFlow: jest.fn().mockReturnValue({ + connectorId: 'conn-1', + tokenUrl: 'https://sandbox-api.datev.de/token', + redirectUri: 'https://cloud.example.com/api/mcp-oauth/callback', + clientId: 'cid', + clientSecret: 'sec', + codeVerifier: 'verifier', + tokenAuthMethod: 'basic', + }), + exchangeCodeForTokens: jest.fn().mockResolvedValue({ + accessToken: 'AT', + refreshToken: 'RT', + expiresIn: 3600, + }), + deletePendingFlow, + }; + const connectorsService: any = { + updateAuthConfigMerge, + findByIdInternal: jest.fn().mockResolvedValue({ + baseUrl: 'https://accounting-clients.api.datev.de/platform-sandbox/v2', + headers: {}, + }), + }; + const mcpClientEngine: any = { + listTools: overrides.listToolsThrows + ? jest.fn().mockRejectedValue(new Error('not an MCP server')) + : jest.fn().mockResolvedValue([]), + }; + const prisma: any = { mcpTool: { create: jest.fn() } }; + const mcpServer: any = { reloadConnectorTools }; + const configService: any = { get: jest.fn().mockReturnValue('https://cloud.example.com') }; + + const controller = new McpOAuthCallbackController( + mcpOAuthService, + connectorsService, + mcpClientEngine, + prisma, + mcpServer, + configService, + ); + return { controller, reloadConnectorTools, updateAuthConfigMerge, mcpOAuthService }; +} + +function makeRes() { + return { redirect: jest.fn() } as any; +} + +describe('McpOAuthCallbackController', () => { + it('reloads connector tools after storing the token even when MCP discovery throws (REST connector)', async () => { + const { controller, reloadConnectorTools, updateAuthConfigMerge } = + makeController({ listToolsThrows: true }); + const res = makeRes(); + + await controller.oauthCallback('the-code', 'the-state', res); + + // Token was persisted via a MERGE (preserves authorizationUrl/scopes)... + expect(updateAuthConfigMerge).toHaveBeenCalledWith( + 'conn-1', + expect.objectContaining({ accessToken: 'AT', tokenAuthMethod: 'basic' }), + ); + // ...and the registry was reloaded despite discovery throwing. + expect(reloadConnectorTools).toHaveBeenCalledWith('conn-1'); + // Redirects to success. + expect(res.redirect).toHaveBeenCalledWith( + expect.stringContaining('oauth=success'), + ); + }); + + it('redirects with an error when code/state are missing', async () => { + const { controller, reloadConnectorTools } = makeController(); + const res = makeRes(); + await controller.oauthCallback('', '', res); + expect(reloadConnectorTools).not.toHaveBeenCalled(); + expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('error=')); + }); +}); diff --git a/packages/backend/src/connectors/mcp-oauth-callback.controller.ts b/packages/backend/src/connectors/mcp-oauth-callback.controller.ts index 99f52ca0..74334689 100644 --- a/packages/backend/src/connectors/mcp-oauth-callback.controller.ts +++ b/packages/backend/src/connectors/mcp-oauth-callback.controller.ts @@ -71,25 +71,37 @@ export class McpOAuthCallbackController { `OAuth tokens obtained for connector ${flow.connectorId}`, ); - // 2. Store tokens (encrypted) in the connector's authConfig - await this.connectorsService.update( - flow.connectorId, - { - authConfig: { - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken, - tokenUrl: flow.tokenUrl, - clientId: flow.clientId, - clientSecret: flow.clientSecret, - tokenAuthMethod: flow.tokenAuthMethod, - expiresIn: tokens.expiresIn, - expiresAt: Date.now() + (tokens.expiresIn || 3600) * 1000, - authorizedAt: new Date().toISOString(), - }, - }, - ); + // 2. Store tokens (encrypted) in the connector's authConfig. Merge, don't + // replace — preserves static config (authorizationUrl, scopes) needed for + // later re-authorization. + await this.connectorsService.updateAuthConfigMerge(flow.connectorId, { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + tokenUrl: flow.tokenUrl, + clientId: flow.clientId, + clientSecret: flow.clientSecret, + tokenAuthMethod: flow.tokenAuthMethod, + expiresIn: tokens.expiresIn, + expiresAt: Date.now() + (tokens.expiresIn || 3600) * 1000, + authorizedAt: new Date().toISOString(), + }); + + // Reload the connector's tools into the in-memory MCP registry so the + // freshly-stored access token takes effect immediately. The registry + // caches a snapshot of authConfig (incl. the token) per tool, so without + // this a just-authorized connector would keep serving with the stale + // (token-less) snapshot. For REST/GraphQL OAuth connectors this is the + // ONLY reload — the MCP auto-discovery block below throws for non-MCP + // servers and never reaches its own reloadConnectorTools() call. + try { + await this.mcpServer.reloadConnectorTools(flow.connectorId); + } catch (reloadErr: any) { + this.logger.warn( + `Failed to reload tools after OAuth for connector ${flow.connectorId}: ${reloadErr.message}`, + ); + } - // 3. Auto-discover tools from the remote MCP server + // 3. Auto-discover tools from the remote MCP server (MCP connectors only) let toolsImported = 0; try { const connector = await this.connectorsService.findByIdInternal( diff --git a/packages/frontend/package.json b/packages/frontend/package.json index dc8ae708..ae233430 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@anythingmcp/frontend", - "version": "0.3.5", + "version": "0.3.6", "description": "AnythingMCP — Next.js Admin UI", "private": true, "license": "AGPL-3.0-only", diff --git a/scripts/generate-datev-sandbox.mjs b/scripts/generate-datev-sandbox.mjs new file mode 100644 index 00000000..ce1a728e --- /dev/null +++ b/scripts/generate-datev-sandbox.mjs @@ -0,0 +1,66 @@ +#!/usr/bin/env node +/** + * Derives the DATEV **Sandbox** adapter from the production DATEV adapter so the + * two never drift: the 6 tool definitions are the single source of truth in + * `de/datev.json`; only the environment-specific URLs/paths differ. + * + * Sandbox vs production (per DATEV's OIDC discovery): + * authorize : login.datev.de/openid/authorize -> login.datev.de/openidsandbox/authorize + * token : api.datev.de/token -> sandbox-api.datev.de/token + * API base : *.api.datev.de/platform/v2 -> *.api.datev.de/platform-sandbox/v2 + * + * Run after editing de/datev.json: node scripts/generate-datev-sandbox.mjs + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const adaptersDir = join(here, '..', 'packages', 'backend', 'src', 'adapters', 'de'); +const prod = JSON.parse(readFileSync(join(adaptersDir, 'datev.json'), 'utf-8')); + +const toSandbox = (s) => + s + .replace('login.datev.de/openid/authorize', 'login.datev.de/openidsandbox/authorize') + .replace('https://api.datev.de/token', 'https://sandbox-api.datev.de/token') + .replace(/\/platform\/v2\//g, '/platform-sandbox/v2/') + .replace(/\/platform\/v2$/g, '/platform-sandbox/v2'); + +const sandbox = JSON.parse(JSON.stringify(prod)); +sandbox.slug = 'datev-sandbox'; +sandbox.name = 'DATEV Online APIs (Sandbox)'; +sandbox.description = + 'Sandbox environment for the DATEV Online APIs — read accounting Mandanten (clients), document types and DUO version metadata against DATEV\'s test data. Use this while you build and test; every new DATEV app must run in sandbox until DATEV grants production approval. Same OAuth2 Authorization Code + PKCE flow as production, pointed at DATEV\'s sandbox endpoints.'; +sandbox.instructions = + '**Use this connector for testing.** Every DATEV app runs in the sandbox until DATEV completes its production-approval review — so this is where you start.\n\n' + + '**Setup**:\n' + + '1. Create a **Confidential** app on the DATEV-Entwicklerportal (https://developer.datev.de) with **OpenID Connect Authorization Code Flow**. Set the redirect URI to `/api/mcp-oauth/callback`.\n' + + '2. Subscribe the app to the sandbox API products: **accounting:clients 2.0** and **accounting:documents 2.0**.\n' + + '3. Set `DATEV_CLIENT_ID` and `DATEV_CLIENT_SECRET` (from the portal — copy the secret in full, it is shown only once).\n' + + '4. Import this adapter — it is pre-configured for the sandbox (authorize `login.datev.de/openidsandbox/authorize`, token `sandbox-api.datev.de/token`, API base `platform-sandbox/v2`) and authenticates at the token endpoint via **HTTP Basic** (`client_secret_basic`), which DATEV requires.\n' + + '5. Run the one-time OAuth flow: `POST /api/connectors/{id}/oauth/authorize` -> open the returned URL -> log in with your DATEV-Konto -> the platform stores an encrypted refresh token automatically.\n' + + '6. Every call then sends `Authorization: Bearer ` AND the mandatory `X-DATEV-Client-Id: ` header automatically.\n\n' + + '**Going to production**: once DATEV approves your app for production, use the separate **DATEV Online APIs** connector (same credentials, production endpoints).\n\n' + + '**Scopes requested**: `datev:accounting:clients accounting:clients:read accounting:documents`. Do NOT add `openid` — it is the issuer name, not a requested scope.\n\n' + + '**Mandant scoping**: every per-client tool takes a `clientId` parameter — the **Mandant UUID** (RFC4122), returned by `datev_list_clients`. Distinct from `DATEV_CLIENT_ID` (the OAuth app identifier, sent in the header).'; + +sandbox.connector.name = 'DATEV Online APIs (Sandbox)'; +sandbox.connector.baseUrl = toSandbox(prod.connector.baseUrl); +sandbox.connector.authConfig.authorizationUrl = toSandbox(prod.connector.authConfig.authorizationUrl); +sandbox.connector.authConfig.tokenUrl = toSandbox(prod.connector.authConfig.tokenUrl); +// tokenAuthMethod ('basic') and scopes carry over unchanged from prod. +sandbox.tools = prod.tools.map((t) => ({ + ...t, + endpointMapping: { ...t.endpointMapping, path: toSandbox(t.endpointMapping.path) }, +})); + +writeFileSync( + join(adaptersDir, 'datev-sandbox.json'), + JSON.stringify(sandbox, null, 2) + '\n', + 'utf-8', +); +console.log('Generated de/datev-sandbox.json from de/datev.json'); +console.log(' baseUrl :', sandbox.connector.baseUrl); +console.log(' authorize :', sandbox.connector.authConfig.authorizationUrl); +console.log(' token :', sandbox.connector.authConfig.tokenUrl); +console.log(' tools :', sandbox.tools.length, '(paths -> platform-sandbox)');