mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
✨ feat(connector): fold OAuth into the custom MCP (PluginDevModal) form (#15661)
* ✨ feat(connector): support API key / custom header / OAuth auth in custom connector Make the connector backend a full replacement for the legacy custom-MCP plugin form: - connector create/update now accept bearer/apikey/header credentials (encrypted at rest); oauth2 stays callback-only - map apikey → bearer auth and header → request headers in both the sync path (syncTools + callTool) and the agent-runtime manifest path - pass custom HTTP headers through to the MCP client - AddConnectorModal becomes a rich form: MCP type (HTTP/STDIO), auth type (None / API Key / Custom Headers / OAuth), reusing the plugin form inputs; OAuth keeps the existing popup authorize flow, others create + sync directly Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * ♻️ refactor(connector): fold OAuth into the PluginDevModal MCP form Pivot the custom-MCP entry to reuse the rich PluginDevModal / MCPManifestForm instead of a bespoke connector modal, and add OAuth as an auth type inside it: - MCPManifestForm: gated `enableOAuth` adds an "OAuth" auth type with Client ID / Secret (optional) + redirect-URI hint. Only the custom-connector entry enables it, so plain custom-plugin DevModal callers (editing plugins, agent tools, …) are unaffected. - DevModal: opens the OAuth popup synchronously on the save click (browsers block window.open once an async boundary is crossed), validates, then hands the popup to onSave which navigates it to the authorize URL. - New CustomConnectorModal wraps DevModal and persists every auth type onto the connector backend (none / bearer / custom headers → create + sync; OAuth → create with OIDC config + run the authorize popup). - settings/skill entry now opens CustomConnectorModal; the standalone AddConnectorModal rich rewrite from the previous commit is reverted to the canary original (it is only referenced by the unused ConnectorList). - i18n: dev.mcp.auth.oauth* keys (default + en-US + zh-CN). Backend stays as in the prior commit (connector create/update accept bearer/apikey/header credentials; sync + manifest paths apply them). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * 🐛 fix(connector): route the OAuth auth type through the authorize flow, not the token-less manifest test Selecting OAuth and clicking "Test connection" called the plugin manifest test (getStreamableMcpServerManifest), which connects with no token and 401s on any OAuth-gated server (e.g. Linear MCP / DCR). For OAuth there is nothing to test without authorizing first, so the button now becomes "Authorize & Connect" and runs the connector OAuth flow (discovery + DCR + authorize popup), shared with the footer save button via DevModal.runOAuthFlow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * 🐛 fix(connector): make connector.create idempotent on (user, identifier) Re-adding or re-authorizing a custom connector with an existing identifier hit the user_connectors unique constraint and 500'd. Now an existing row is updated (reset to disconnected, refreshed name/url/oidcConfig/credentials) and its id reused, instead of inserting a duplicate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * ♻️ refactor(skill-store): route Add Custom MCP through the connector modal, drop the Custom tab - Skill Store "Add → Add Custom MCP Skill" now opens CustomConnectorModal (connector backend + OAuth), matching the settings/skill entry, instead of the legacy plugin DevModal (installCustomPlugin + togglePlugin). - Remove the now-redundant "Custom" tab from the Skill Store (custom MCP lives in the connector list now): drop SkillStoreTab.Custom, its tab option, CustomList render, and the matching search branch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -56,7 +56,19 @@ const oidcConfigSchema = z.object({
|
||||
usePKCE: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Non-OAuth credentials the client may set directly when creating/updating a
|
||||
* connector. OAuth2 tokens are intentionally excluded — those are written only
|
||||
* by the OAuth callback after a successful authorization exchange.
|
||||
*/
|
||||
const connectorCredentialsInputSchema = z.discriminatedUnion('type', [
|
||||
z.object({ token: z.string().min(1), type: z.literal('bearer') }),
|
||||
z.object({ apiKey: z.string().min(1), type: z.literal('apikey') }),
|
||||
z.object({ headers: z.record(z.string()), type: z.literal('header') }),
|
||||
]);
|
||||
|
||||
const createConnectorSchema = z.object({
|
||||
credentials: connectorCredentialsInputSchema.optional(),
|
||||
identifier: z.string().min(1).max(255),
|
||||
isEnabled: z.boolean().optional().default(true),
|
||||
mcpConnectionType: z
|
||||
@@ -113,13 +125,36 @@ export const connectorRouter = router({
|
||||
// ── Mutations ─────────────────────────────────────────────────────────────
|
||||
|
||||
create: connectorProcedure.input(createConnectorSchema).mutation(async ({ input, ctx }) => {
|
||||
return ctx.connectorModel.create({
|
||||
...input,
|
||||
const fields = {
|
||||
// The model expects the decrypted JSON string and encrypts it at rest.
|
||||
credentials: input.credentials ? JSON.stringify(input.credentials) : null,
|
||||
mcpConnectionType: input.mcpConnectionType ?? null,
|
||||
mcpServerUrl: input.mcpServerUrl ?? null,
|
||||
mcpStdioConfig: input.mcpStdioConfig ?? null,
|
||||
metadata: input.metadata ?? null,
|
||||
name: input.name,
|
||||
oidcConfig: input.oidcConfig ?? null,
|
||||
};
|
||||
|
||||
// Idempotent on (user_id, identifier): re-adding or re-authorizing the same
|
||||
// connector updates the existing row instead of violating the unique index.
|
||||
// Status resets to `disconnected` — the OAuth callback / tool sync promotes
|
||||
// it back to `connected` on success.
|
||||
const [existing] = await ctx.connectorModel.queryByIdentifiers([input.identifier]);
|
||||
if (existing) {
|
||||
await ctx.connectorModel.update(existing.id, {
|
||||
...fields,
|
||||
isEnabled: input.isEnabled ?? true,
|
||||
status: ConnectorStatus.disconnected,
|
||||
});
|
||||
return { id: existing.id };
|
||||
}
|
||||
|
||||
return ctx.connectorModel.create({
|
||||
...fields,
|
||||
identifier: input.identifier,
|
||||
isEnabled: input.isEnabled ?? true,
|
||||
sourceType: input.sourceType,
|
||||
status: ConnectorStatus.disconnected,
|
||||
});
|
||||
}),
|
||||
@@ -221,11 +256,22 @@ export const connectorRouter = router({
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string().uuid(),
|
||||
patch: createConnectorSchema.partial().omit({ identifier: true, sourceType: true }),
|
||||
patch: createConnectorSchema
|
||||
.partial()
|
||||
.omit({ identifier: true, sourceType: true })
|
||||
// Allow `null` here so an edit can clear credentials (switch to no-auth).
|
||||
.extend({ credentials: connectorCredentialsInputSchema.nullable().optional() }),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await ctx.connectorModel.update(input.id, input.patch as any);
|
||||
const { credentials, ...patch } = input.patch;
|
||||
await ctx.connectorModel.update(input.id, {
|
||||
...patch,
|
||||
// undefined → leave untouched; null → clear; object → encrypt the JSON string.
|
||||
...(credentials === undefined
|
||||
? {}
|
||||
: { credentials: credentials ? JSON.stringify(credentials) : null }),
|
||||
} as any);
|
||||
}),
|
||||
|
||||
delete: connectorProcedure
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { DecryptedConnector } from '@/database/models/connector';
|
||||
import type { ConnectorCredentials } from '@/database/schemas';
|
||||
|
||||
import { buildConnectorMcpParams, buildHttpAuthFromCredentials } from './sync';
|
||||
|
||||
const httpConnector = (credentials: ConnectorCredentials | null): DecryptedConnector =>
|
||||
({
|
||||
credentials,
|
||||
id: 'c1',
|
||||
identifier: 'my-conn',
|
||||
isEnabled: true,
|
||||
mcpConnectionType: 'http',
|
||||
mcpServerUrl: 'https://mcp.example.com',
|
||||
mcpStdioConfig: null,
|
||||
name: 'My Connector',
|
||||
oidcConfig: null,
|
||||
}) as any;
|
||||
|
||||
describe('buildHttpAuthFromCredentials', () => {
|
||||
it('returns nothing for no credentials (no-auth)', () => {
|
||||
expect(buildHttpAuthFromCredentials(null)).toEqual({});
|
||||
});
|
||||
|
||||
it('maps oauth2 to bearer auth with refresh metadata', () => {
|
||||
const result = buildHttpAuthFromCredentials({
|
||||
accessToken: 'access',
|
||||
clientSecret: 'secret',
|
||||
expiresAt: 123,
|
||||
refreshToken: 'refresh',
|
||||
type: 'oauth2',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
auth: {
|
||||
accessToken: 'access',
|
||||
clientId: undefined,
|
||||
clientSecret: 'secret',
|
||||
refreshToken: 'refresh',
|
||||
tokenExpiresAt: 123,
|
||||
type: 'oauth2',
|
||||
},
|
||||
});
|
||||
expect(result.headers).toBeUndefined();
|
||||
});
|
||||
|
||||
it('maps a bearer token to bearer auth', () => {
|
||||
expect(buildHttpAuthFromCredentials({ token: 'tok', type: 'bearer' })).toEqual({
|
||||
auth: { token: 'tok', type: 'bearer' },
|
||||
});
|
||||
});
|
||||
|
||||
it('maps an api key to bearer auth (Authorization header)', () => {
|
||||
expect(buildHttpAuthFromCredentials({ apiKey: 'key-123', type: 'apikey' })).toEqual({
|
||||
auth: { token: 'key-123', type: 'bearer' },
|
||||
});
|
||||
});
|
||||
|
||||
it('passes custom headers through verbatim with no auth', () => {
|
||||
const result = buildHttpAuthFromCredentials({
|
||||
headers: { 'X-Api-Key': 'abc', 'X-Tenant': 't1' },
|
||||
type: 'header',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ headers: { 'X-Api-Key': 'abc', 'X-Tenant': 't1' } });
|
||||
expect(result.auth).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildConnectorMcpParams', () => {
|
||||
it('builds http params with bearer auth', () => {
|
||||
expect(buildConnectorMcpParams(httpConnector({ token: 'tok', type: 'bearer' }))).toEqual({
|
||||
auth: { token: 'tok', type: 'bearer' },
|
||||
headers: undefined,
|
||||
name: 'My Connector',
|
||||
type: 'http',
|
||||
url: 'https://mcp.example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds http params with custom headers and no auth', () => {
|
||||
expect(
|
||||
buildConnectorMcpParams(
|
||||
httpConnector({ headers: { Authorization: 'Token x' }, type: 'header' }),
|
||||
),
|
||||
).toEqual({
|
||||
auth: undefined,
|
||||
headers: { Authorization: 'Token x' },
|
||||
name: 'My Connector',
|
||||
type: 'http',
|
||||
url: 'https://mcp.example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds http params with no auth when credentials are absent', () => {
|
||||
expect(buildConnectorMcpParams(httpConnector(null))).toEqual({
|
||||
auth: undefined,
|
||||
headers: undefined,
|
||||
name: 'My Connector',
|
||||
type: 'http',
|
||||
url: 'https://mcp.example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds stdio params from stdio config', () => {
|
||||
const connector = {
|
||||
credentials: null,
|
||||
identifier: 'local-conn',
|
||||
mcpConnectionType: 'stdio',
|
||||
mcpServerUrl: null,
|
||||
mcpStdioConfig: { args: ['serve'], command: 'my-mcp', env: { FOO: 'bar' } },
|
||||
name: 'Local Connector',
|
||||
} as any;
|
||||
|
||||
expect(buildConnectorMcpParams(connector)).toEqual({
|
||||
args: ['serve'],
|
||||
command: 'my-mcp',
|
||||
env: { FOO: 'bar' },
|
||||
name: 'Local Connector',
|
||||
type: 'stdio',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -28,36 +28,53 @@ export const buildConnectorMcpParams = (
|
||||
};
|
||||
}
|
||||
if (!connector.mcpServerUrl) throw new Error('Connector has no MCP server URL configured');
|
||||
const { auth, headers } = buildHttpAuthFromCredentials(connector.credentials);
|
||||
return {
|
||||
auth: buildAuthFromCredentials(connector.credentials),
|
||||
auth,
|
||||
headers,
|
||||
name: connector.name,
|
||||
type: 'http',
|
||||
url: connector.mcpServerUrl,
|
||||
};
|
||||
};
|
||||
|
||||
/** Map stored credentials into the MCP client's auth config. */
|
||||
export const buildAuthFromCredentials = (
|
||||
/**
|
||||
* Map stored credentials into the HTTP MCP client's auth config + custom headers.
|
||||
*
|
||||
* The MCP client only understands `bearer`/`oauth2` auth (both become an
|
||||
* `Authorization: Bearer …` header), so:
|
||||
* - bearer / apikey → bearer auth
|
||||
* - header → passed through verbatim as request headers
|
||||
*/
|
||||
export const buildHttpAuthFromCredentials = (
|
||||
credentials: ConnectorCredentials | null,
|
||||
): AuthConfig | undefined => {
|
||||
if (!credentials) return undefined;
|
||||
): { auth?: AuthConfig; headers?: Record<string, string> } => {
|
||||
if (!credentials) return {};
|
||||
|
||||
switch (credentials.type) {
|
||||
case 'oauth2': {
|
||||
return {
|
||||
accessToken: credentials.accessToken,
|
||||
clientId: undefined,
|
||||
clientSecret: credentials.clientSecret,
|
||||
refreshToken: credentials.refreshToken,
|
||||
tokenExpiresAt: credentials.expiresAt,
|
||||
type: 'oauth2',
|
||||
auth: {
|
||||
accessToken: credentials.accessToken,
|
||||
clientId: undefined,
|
||||
clientSecret: credentials.clientSecret,
|
||||
refreshToken: credentials.refreshToken,
|
||||
tokenExpiresAt: credentials.expiresAt,
|
||||
type: 'oauth2',
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'bearer': {
|
||||
return { token: credentials.token, type: 'bearer' };
|
||||
return { auth: { token: credentials.token, type: 'bearer' } };
|
||||
}
|
||||
case 'apikey': {
|
||||
return { auth: { token: credentials.apiKey, type: 'bearer' } };
|
||||
}
|
||||
case 'header': {
|
||||
return { headers: credentials.headers };
|
||||
}
|
||||
default: {
|
||||
return undefined;
|
||||
return {};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user