diff --git a/src/bridge/mcp-api.ts b/src/bridge/mcp-api.ts index f830997..8b31238 100644 --- a/src/bridge/mcp-api.ts +++ b/src/bridge/mcp-api.ts @@ -43,6 +43,7 @@ export function createAdminRouter(deps: McpApiDeps): Router { oauthClientSecret: string; oauthScopes: string; staticToken: string; + authHeaderName: string; enabled: boolean; }>; @@ -83,6 +84,7 @@ export function createAdminRouter(deps: McpApiDeps): Router { oauthClientSecret: body.oauthClientSecret, oauthScopes: body.oauthScopes ?? null, staticToken: body.staticToken, + authHeaderName: body.authHeaderName ?? null, enabled: body.enabled !== false, createdBy: adminId, }); @@ -263,6 +265,7 @@ export function createUserServersRouter(deps: McpApiDeps): Router { oauthClientSecret: string; oauthScopes: string; staticToken: string; + authHeaderName: string; enabled: boolean; }>; @@ -309,6 +312,7 @@ export function createUserServersRouter(deps: McpApiDeps): Router { oauthClientSecret: body.oauthClientSecret, oauthScopes: body.oauthScopes ?? null, staticToken: body.staticToken, + authHeaderName: body.authHeaderName ?? null, enabled: body.enabled !== false, createdBy: userId, }); diff --git a/src/db/migrate.ts b/src/db/migrate.ts index b12138e..a50c22a 100644 --- a/src/db/migrate.ts +++ b/src/db/migrate.ts @@ -191,6 +191,11 @@ function migrateMcpTables(db: Database.Database): void { // NULL = global/admin-managed; NOT NULL = user-owned. db.exec('ALTER TABLE mcp_servers ADD COLUMN owner_id TEXT REFERENCES users(id) ON DELETE CASCADE'); } + if (!mcpServerColNames.has('auth_header_name')) { + // Custom auth header name for api_key servers (e.g. 'xc-mcp-token'). + // NULL = default Authorization: Bearer. + db.exec('ALTER TABLE mcp_servers ADD COLUMN auth_header_name TEXT'); + } db.exec('CREATE INDEX IF NOT EXISTS idx_mcp_servers_owner ON mcp_servers(owner_id);'); } diff --git a/src/mcp/client-factory.test.ts b/src/mcp/client-factory.test.ts new file mode 100644 index 0000000..04ac2ee --- /dev/null +++ b/src/mcp/client-factory.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { buildAuthHeaders } from './client-factory.js'; + +describe('buildAuthHeaders', () => { + it('uses Authorization: Bearer by default for api_key without custom header', () => { + expect(buildAuthHeaders({ authKind: 'api_key', authHeaderName: null }, 'tok')).toEqual({ + Authorization: 'Bearer tok', + }); + }); + + it('sends the token verbatim under a custom header for api_key', () => { + expect( + buildAuthHeaders({ authKind: 'api_key', authHeaderName: 'xc-mcp-token' }, 'secret-123'), + ).toEqual({ 'xc-mcp-token': 'secret-123' }); + }); + + it('always uses Authorization: Bearer for oauth, ignoring any header name', () => { + expect( + buildAuthHeaders({ authKind: 'oauth', authHeaderName: 'xc-mcp-token' }, 'oauth-at'), + ).toEqual({ Authorization: 'Bearer oauth-at' }); + }); + + it('falls back to Bearer when custom header name is an empty string', () => { + expect(buildAuthHeaders({ authKind: 'api_key', authHeaderName: '' }, 'tok')).toEqual({ + Authorization: 'Bearer tok', + }); + }); +}); diff --git a/src/mcp/client-factory.ts b/src/mcp/client-factory.ts index 58948bd..46c99d9 100644 --- a/src/mcp/client-factory.ts +++ b/src/mcp/client-factory.ts @@ -4,6 +4,23 @@ import { checkSSRFStrict, pinnedFetch } from './ssrf-strict.js'; import type { McpServerRecord } from './types.js'; import { logger } from '../logger.js'; +/** + * Build the auth headers sent to an MCP server. + * + * api_key servers may declare a custom auth header (e.g. NocoDB's `xc-mcp-token`), + * in which case the token is sent verbatim under that header name. Otherwise + * (and always for oauth) the standard `Authorization: Bearer ` is used. + */ +export function buildAuthHeaders( + server: Pick, + accessToken: string, +): Record { + if (server.authKind === 'api_key' && server.authHeaderName) { + return { [server.authHeaderName]: accessToken }; + } + return { Authorization: `Bearer ${accessToken}` }; +} + export interface ClientFactoryOptions { insecureLocalTestMode?: boolean; /** @@ -15,7 +32,7 @@ export interface ClientFactoryOptions { } export async function createMcpClient( - server: Pick, + server: Pick, accessToken: string, opts: ClientFactoryOptions, ): Promise<{ client: Client; close: () => Promise }> { @@ -36,7 +53,7 @@ export async function createMcpClient( const transport = new StreamableHTTPClientTransport(new URL(server.url), { fetch: fetcher, requestInit: { - headers: { Authorization: `Bearer ${accessToken}` }, + headers: buildAuthHeaders(server, accessToken), }, }); diff --git a/src/mcp/registry.test.ts b/src/mcp/registry.test.ts index ebd6796..5e3f3a4 100644 --- a/src/mcp/registry.test.ts +++ b/src/mcp/registry.test.ts @@ -119,6 +119,79 @@ describe('McpRegistry', () => { ).toThrow(/staticToken/); }); + it('round-trips a custom auth header name for api_key servers', () => { + const r = createRegistry(db); + r.upsert({ + id: 'nocodb', + name: 'NocoDB', + url: 'https://nocodb.example/mcp/abc', + authKind: 'api_key', + ownerId: 'u1', + staticToken: 'tok', + authHeaderName: 'xc-mcp-token', + }); + expect(r.listPublic()[0].authHeaderName).toBe('xc-mcp-token'); + expect(r.getDecrypted('nocodb')?.authHeaderName).toBe('xc-mcp-token'); + }); + + it('defaults authHeaderName to null when omitted', () => { + const r = createRegistry(db); + r.upsert({ + id: 'plain', + name: 'Plain', + url: 'https://plain.example/mcp', + authKind: 'api_key', + ownerId: 'u1', + staticToken: 'tok', + }); + expect(r.getDecrypted('plain')?.authHeaderName).toBeNull(); + }); + + it('rejects authHeaderName on oauth servers', () => { + const r = createRegistry(db); + expect(() => + r.upsert({ + id: 'badhdr', + name: 'BadHdr', + url: 'https://b.example/mcp', + authKind: 'oauth', + ownerId: null, + oauthClientId: 'i', + oauthClientSecret: 's', + authHeaderName: 'xc-mcp-token', + }), + ).toThrow(/only valid for authKind 'api_key'/); + }); + + it('rejects an auth header name containing illegal characters', () => { + const r = createRegistry(db); + expect(() => + r.upsert({ + id: 'badhdr2', + name: 'BadHdr2', + url: 'https://b2.example/mcp', + authKind: 'api_key', + ownerId: 'u1', + staticToken: 'tok', + authHeaderName: 'bad header: value', + }), + ).toThrow(/valid HTTP header token/); + }); + + it('treats a blank authHeaderName as null (no error)', () => { + const r = createRegistry(db); + r.upsert({ + id: 'blankhdr', + name: 'BlankHdr', + url: 'https://bh.example/mcp', + authKind: 'api_key', + ownerId: 'u1', + staticToken: 'tok', + authHeaderName: ' ', + }); + expect(r.getDecrypted('blankhdr')?.authHeaderName).toBeNull(); + }); + it('listEnabledForUser returns global and own servers only', () => { const r = createRegistry(db); r.upsert({ id: 'global1', name: 'Global', url: 'https://g.example/mcp', authKind: 'oauth', ownerId: null, oauthClientId: 'g', oauthClientSecret: 'gs' }); diff --git a/src/mcp/registry.ts b/src/mcp/registry.ts index 6a4ff35..b37081c 100644 --- a/src/mcp/registry.ts +++ b/src/mcp/registry.ts @@ -9,6 +9,9 @@ import type { import { logger } from '../logger.js'; const ID_REGEX = /^[a-z0-9_-]{1,64}$/; +// RFC 7230 header field-name token chars. Guards against header injection +// (CR/LF, colon, spaces) when a custom auth header name is supplied. +const HEADER_NAME_REGEX = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; export interface UpsertInput { id: string; @@ -22,6 +25,8 @@ export interface UpsertInput { oauthScopes?: string | null; // API key field (required when authKind === 'api_key') staticToken?: string; + // Optional custom auth header name for api_key servers (e.g. 'xc-mcp-token'). + authHeaderName?: string | null; enabled?: boolean; createdBy?: string | null; } @@ -45,6 +50,7 @@ interface Row { auth_kind: string; static_token_enc: Buffer | null; owner_id: string | null; + auth_header_name: string | null; } function rowToPublic(r: Row): McpServerPublic { @@ -54,6 +60,7 @@ function rowToPublic(r: Row): McpServerPublic { url: r.url, authKind: (r.auth_kind ?? 'oauth') as AuthKind, ownerId: r.owner_id ?? null, + authHeaderName: r.auth_header_name ?? null, oauthClientId: r.oauth_client_id, oauthScopes: r.oauth_scopes, issuer: r.issuer, @@ -89,6 +96,18 @@ export function createRegistry(db: Database.Database) { throw new Error(`Unknown authKind: ${authKind as string}`); } + // Optional custom auth header name (api_key only). Validated against the + // RFC 7230 token grammar to prevent header injection. + const authHeaderName = input.authHeaderName?.trim() || null; + if (authHeaderName !== null) { + if (authKind !== 'api_key') { + throw new Error(`authHeaderName is only valid for authKind 'api_key'`); + } + if (!HEADER_NAME_REGEX.test(authHeaderName)) { + throw new Error(`authHeaderName must be a valid HTTP header token: got "${authHeaderName}"`); + } + } + // For oauth: encrypt the real secret; static_token_enc stays NULL. // For api_key: oauth_client_id = '' and oauth_client_secret_enc = encrypt('', key) // because those columns are NOT NULL in the original schema. They are never read @@ -100,8 +119,8 @@ export function createRegistry(db: Database.Database) { db.prepare( `INSERT INTO mcp_servers (id, name, url, oauth_client_id, oauth_client_secret_enc, oauth_scopes, - auth_kind, owner_id, static_token_enc, enabled, created_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + auth_kind, owner_id, static_token_enc, auth_header_name, enabled, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name=excluded.name, url=excluded.url, @@ -111,6 +130,7 @@ export function createRegistry(db: Database.Database) { auth_kind=excluded.auth_kind, owner_id=excluded.owner_id, static_token_enc=excluded.static_token_enc, + auth_header_name=excluded.auth_header_name, enabled=excluded.enabled, updated_at=datetime('now')`, ).run( @@ -123,6 +143,7 @@ export function createRegistry(db: Database.Database) { authKind, input.ownerId ?? null, staticTokenEnc, + authHeaderName, input.enabled === false ? 0 : 1, input.createdBy ?? null, ); diff --git a/src/mcp/types.ts b/src/mcp/types.ts index b763261..859a24b 100644 --- a/src/mcp/types.ts +++ b/src/mcp/types.ts @@ -10,6 +10,7 @@ export interface McpServerRecord { oauthClientSecret: string; // decrypted, only in-memory (empty string for api_key servers) oauthScopes: string | null; staticToken: string | null; // decrypted, only in-memory (non-null for api_key servers) + authHeaderName: string | null; // custom auth header name for api_key (null = Authorization: Bearer) issuer: string | null; authorizationEndpoint: string | null; tokenEndpoint: string | null; diff --git a/ui/src/components/userfolder/McpServersPanel.tsx b/ui/src/components/userfolder/McpServersPanel.tsx index f6748fb..a6076bc 100644 --- a/ui/src/components/userfolder/McpServersPanel.tsx +++ b/ui/src/components/userfolder/McpServersPanel.tsx @@ -96,6 +96,7 @@ interface ServerFormBody { oauthClientSecret?: string; oauthScopes?: string; staticToken?: string; + authHeaderName?: string; enabled?: boolean; } @@ -108,6 +109,7 @@ const emptyForm = (): ServerFormBody => ({ oauthClientSecret: '', oauthScopes: '', staticToken: '', + authHeaderName: '', }); function FormField({ label, children }: { label: string; children: React.ReactNode }) { @@ -232,18 +234,32 @@ function AddServerForm({ sectionLabel, onSubmit, isPending }: AddServerFormProps )} - {/* API key field */} + {/* API key fields */} {form.authKind === 'api_key' && ( - - setForm({ ...form, staticToken: e.target.value })} - placeholder="sk-..." - required - /> - + <> + + setForm({ ...form, staticToken: e.target.value })} + placeholder="sk-..." + required + /> + + + setForm({ ...form, authHeaderName: e.target.value })} + placeholder="xc-mcp-token" + /> + + 指定すると、トークンをこのヘッダ名でそのまま送ります(Bearer 前置なし)。例: NocoDB は{' '} + xc-mcp-token。 + + + )} {formError && (