sync: update from private repo (03a03e4)
Some checks failed
CI / build-and-test (push) Has been cancelled
Some checks failed
CI / build-and-test (push) Has been cancelled
This commit is contained in:
parent
454d6f957b
commit
8041bc62cc
@ -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,
|
||||
});
|
||||
|
||||
@ -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);');
|
||||
}
|
||||
|
||||
28
src/mcp/client-factory.test.ts
Normal file
28
src/mcp/client-factory.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -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' });
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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' && (
|
||||
<FormField label="API key / Bearer token">
|
||||
<input
|
||||
className="w-full border border-hairline rounded px-2 py-1 text-[13px]"
|
||||
type="password"
|
||||
value={form.staticToken ?? ''}
|
||||
onChange={(e) => setForm({ ...form, staticToken: e.target.value })}
|
||||
placeholder="sk-..."
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<>
|
||||
<FormField label="API key / トークン">
|
||||
<input
|
||||
className="w-full border border-hairline rounded px-2 py-1 text-[13px]"
|
||||
type="password"
|
||||
value={form.staticToken ?? ''}
|
||||
onChange={(e) => setForm({ ...form, staticToken: e.target.value })}
|
||||
placeholder="sk-..."
|
||||
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 && (
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user