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;
|
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,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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);');
|
||||||
}
|
}
|
||||||
|
|||||||
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 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),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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' });
|
||||||
|
|||||||
@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 && (
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user