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; oauthClientSecret: string;
oauthScopes: string; oauthScopes: string;
staticToken: string; staticToken: string;
authHeaderName: string;
enabled: boolean; enabled: boolean;
}>; }>;
@ -83,6 +84,7 @@ export function createAdminRouter(deps: McpApiDeps): Router {
oauthClientSecret: body.oauthClientSecret, oauthClientSecret: body.oauthClientSecret,
oauthScopes: body.oauthScopes ?? null, oauthScopes: body.oauthScopes ?? null,
staticToken: body.staticToken, staticToken: body.staticToken,
authHeaderName: body.authHeaderName ?? null,
enabled: body.enabled !== false, enabled: body.enabled !== false,
createdBy: adminId, createdBy: adminId,
}); });
@ -263,6 +265,7 @@ export function createUserServersRouter(deps: McpApiDeps): Router {
oauthClientSecret: string; oauthClientSecret: string;
oauthScopes: string; oauthScopes: string;
staticToken: string; staticToken: string;
authHeaderName: string;
enabled: boolean; enabled: boolean;
}>; }>;
@ -309,6 +312,7 @@ export function createUserServersRouter(deps: McpApiDeps): Router {
oauthClientSecret: body.oauthClientSecret, oauthClientSecret: body.oauthClientSecret,
oauthScopes: body.oauthScopes ?? null, oauthScopes: body.oauthScopes ?? null,
staticToken: body.staticToken, staticToken: body.staticToken,
authHeaderName: body.authHeaderName ?? null,
enabled: body.enabled !== false, enabled: body.enabled !== false,
createdBy: userId, createdBy: userId,
}); });

View File

@ -191,6 +191,11 @@ function migrateMcpTables(db: Database.Database): void {
// NULL = global/admin-managed; NOT NULL = user-owned. // 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'); 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);'); 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 type { McpServerRecord } from './types.js';
import { logger } from '../logger.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 { export interface ClientFactoryOptions {
insecureLocalTestMode?: boolean; insecureLocalTestMode?: boolean;
/** /**
@ -15,7 +32,7 @@ export interface ClientFactoryOptions {
} }
export async function createMcpClient( export async function createMcpClient(
server: Pick<McpServerRecord, 'id' | 'url'>, server: Pick<McpServerRecord, 'id' | 'url' | 'authKind' | 'authHeaderName'>,
accessToken: string, accessToken: string,
opts: ClientFactoryOptions, opts: ClientFactoryOptions,
): Promise<{ client: Client; close: () => Promise<void> }> { ): Promise<{ client: Client; close: () => Promise<void> }> {
@ -36,7 +53,7 @@ export async function createMcpClient(
const transport = new StreamableHTTPClientTransport(new URL(server.url), { const transport = new StreamableHTTPClientTransport(new URL(server.url), {
fetch: fetcher, fetch: fetcher,
requestInit: { requestInit: {
headers: { Authorization: `Bearer ${accessToken}` }, headers: buildAuthHeaders(server, accessToken),
}, },
}); });

View File

@ -119,6 +119,79 @@ describe('McpRegistry', () => {
).toThrow(/staticToken/); ).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', () => { it('listEnabledForUser returns global and own servers only', () => {
const r = createRegistry(db); const r = createRegistry(db);
r.upsert({ id: 'global1', name: 'Global', url: 'https://g.example/mcp', authKind: 'oauth', ownerId: null, oauthClientId: 'g', oauthClientSecret: 'gs' }); 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'; import { logger } from '../logger.js';
const ID_REGEX = /^[a-z0-9_-]{1,64}$/; 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 { export interface UpsertInput {
id: string; id: string;
@ -22,6 +25,8 @@ export interface UpsertInput {
oauthScopes?: string | null; oauthScopes?: string | null;
// API key field (required when authKind === 'api_key') // API key field (required when authKind === 'api_key')
staticToken?: string; staticToken?: string;
// Optional custom auth header name for api_key servers (e.g. 'xc-mcp-token').
authHeaderName?: string | null;
enabled?: boolean; enabled?: boolean;
createdBy?: string | null; createdBy?: string | null;
} }
@ -45,6 +50,7 @@ interface Row {
auth_kind: string; auth_kind: string;
static_token_enc: Buffer | null; static_token_enc: Buffer | null;
owner_id: string | null; owner_id: string | null;
auth_header_name: string | null;
} }
function rowToPublic(r: Row): McpServerPublic { function rowToPublic(r: Row): McpServerPublic {
@ -54,6 +60,7 @@ function rowToPublic(r: Row): McpServerPublic {
url: r.url, url: r.url,
authKind: (r.auth_kind ?? 'oauth') as AuthKind, authKind: (r.auth_kind ?? 'oauth') as AuthKind,
ownerId: r.owner_id ?? null, ownerId: r.owner_id ?? null,
authHeaderName: r.auth_header_name ?? null,
oauthClientId: r.oauth_client_id, oauthClientId: r.oauth_client_id,
oauthScopes: r.oauth_scopes, oauthScopes: r.oauth_scopes,
issuer: r.issuer, issuer: r.issuer,
@ -89,6 +96,18 @@ export function createRegistry(db: Database.Database) {
throw new Error(`Unknown authKind: ${authKind as string}`); 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 oauth: encrypt the real secret; static_token_enc stays NULL.
// For api_key: oauth_client_id = '' and oauth_client_secret_enc = encrypt('', key) // 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 // 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( db.prepare(
`INSERT INTO mcp_servers `INSERT INTO mcp_servers
(id, name, url, oauth_client_id, oauth_client_secret_enc, oauth_scopes, (id, name, url, oauth_client_id, oauth_client_secret_enc, oauth_scopes,
auth_kind, owner_id, static_token_enc, enabled, created_by) auth_kind, owner_id, static_token_enc, auth_header_name, enabled, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
name=excluded.name, name=excluded.name,
url=excluded.url, url=excluded.url,
@ -111,6 +130,7 @@ export function createRegistry(db: Database.Database) {
auth_kind=excluded.auth_kind, auth_kind=excluded.auth_kind,
owner_id=excluded.owner_id, owner_id=excluded.owner_id,
static_token_enc=excluded.static_token_enc, static_token_enc=excluded.static_token_enc,
auth_header_name=excluded.auth_header_name,
enabled=excluded.enabled, enabled=excluded.enabled,
updated_at=datetime('now')`, updated_at=datetime('now')`,
).run( ).run(
@ -123,6 +143,7 @@ export function createRegistry(db: Database.Database) {
authKind, authKind,
input.ownerId ?? null, input.ownerId ?? null,
staticTokenEnc, staticTokenEnc,
authHeaderName,
input.enabled === false ? 0 : 1, input.enabled === false ? 0 : 1,
input.createdBy ?? null, input.createdBy ?? null,
); );

View File

@ -10,6 +10,7 @@ export interface McpServerRecord {
oauthClientSecret: string; // decrypted, only in-memory (empty string for api_key servers) oauthClientSecret: string; // decrypted, only in-memory (empty string for api_key servers)
oauthScopes: string | null; oauthScopes: string | null;
staticToken: string | null; // decrypted, only in-memory (non-null for api_key servers) 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; issuer: string | null;
authorizationEndpoint: string | null; authorizationEndpoint: string | null;
tokenEndpoint: string | null; tokenEndpoint: string | null;

View File

@ -96,6 +96,7 @@ interface ServerFormBody {
oauthClientSecret?: string; oauthClientSecret?: string;
oauthScopes?: string; oauthScopes?: string;
staticToken?: string; staticToken?: string;
authHeaderName?: string;
enabled?: boolean; enabled?: boolean;
} }
@ -108,6 +109,7 @@ const emptyForm = (): ServerFormBody => ({
oauthClientSecret: '', oauthClientSecret: '',
oauthScopes: '', oauthScopes: '',
staticToken: '', staticToken: '',
authHeaderName: '',
}); });
function FormField({ label, children }: { label: string; children: React.ReactNode }) { 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' && ( {form.authKind === 'api_key' && (
<FormField label="API key / Bearer token"> <>
<input <FormField label="API key / トークン">
className="w-full border border-hairline rounded px-2 py-1 text-[13px]" <input
type="password" className="w-full border border-hairline rounded px-2 py-1 text-[13px]"
value={form.staticToken ?? ''} type="password"
onChange={(e) => setForm({ ...form, staticToken: e.target.value })} value={form.staticToken ?? ''}
placeholder="sk-..." onChange={(e) => setForm({ ...form, staticToken: e.target.value })}
required placeholder="sk-..."
/> required
</FormField> />
</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 && ( {formError && (