sync: update from private repo (03a03e4)
Some checks failed
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync 2026-06-09 14:04:19 +00:00
parent 454d6f957b
commit 8041bc62cc
8 changed files with 180 additions and 15 deletions

View File

@ -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,
});

View File

@ -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);');
}

View File

@ -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',
});
});
});

View File

@ -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 <token>` is used.
*/
export function buildAuthHeaders(
server: Pick<McpServerRecord, 'authKind' | 'authHeaderName'>,
accessToken: string,
): Record<string, string> {
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<McpServerRecord, 'id' | 'url'>,
server: Pick<McpServerRecord, 'id' | 'url' | 'authKind' | 'authHeaderName'>,
accessToken: string,
opts: ClientFactoryOptions,
): Promise<{ client: Client; close: () => Promise<void> }> {
@ -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),
},
});

View File

@ -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' });

View File

@ -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,
);

View File

@ -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;

View File

@ -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,9 +234,10 @@ function AddServerForm({ sectionLabel, onSubmit, isPending }: AddServerFormProps
</>
)}
{/* API key field */}
{/* API key fields */}
{form.authKind === 'api_key' && (
<FormField label="API key / Bearer token">
<>
<FormField label="API key / トークン">
<input
className="w-full border border-hairline rounded px-2 py-1 text-[13px]"
type="password"
@ -244,6 +247,19 @@ function AddServerForm({ sectionLabel, onSubmit, isPending }: AddServerFormProps
required
/>
</FormField>
<FormField label="認証ヘッダ名 (任意 — 空なら Authorization: Bearer)">
<input
className="w-full border border-hairline rounded px-2 py-1 text-[13px] font-mono"
value={form.authHeaderName ?? ''}
onChange={(e) => setForm({ ...form, authHeaderName: e.target.value })}
placeholder="xc-mcp-token"
/>
<span className="block text-[11px] text-slate-400 mt-1">
Bearer : NocoDB {' '}
<code className="font-mono">xc-mcp-token</code>
</span>
</FormField>
</>
)}
{formError && (