feat: initial public release (MAESTRO)

This commit is contained in:
oss-sync
2026-06-03 05:08:00 +00:00
commit f5c7666f6b
823 changed files with 184150 additions and 0 deletions
+80
View File
@@ -0,0 +1,80 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import Database from 'better-sqlite3';
import { runMigrations } from '../db/migrate.js';
import { createRegistry } from './registry.js';
import { createTokenManager } from './token-manager.js';
import { createToolCache } from './tool-cache.js';
import { createAggregator } from './aggregator.js';
describe('createAggregator', () => {
const validKey = 'a'.repeat(64);
let db: Database.Database;
beforeEach(() => {
process.env.MCP_ENCRYPTION_KEY = validKey;
db = new Database(':memory:');
db.exec(`CREATE TABLE users (id TEXT PRIMARY KEY);`);
db.exec(`CREATE TABLE jobs (id TEXT PRIMARY KEY, wait_reason TEXT);`);
db.exec(`CREATE TABLE local_tasks (id INTEGER PRIMARY KEY AUTOINCREMENT);`);
runMigrations(db);
db.prepare('INSERT INTO users(id) VALUES(?)').run('u1');
const reg = createRegistry(db);
reg.upsert({
id: 'canva',
name: 'Canva',
url: 'https://mcp.canva.example/mcp',
authKind: 'oauth',
ownerId: null,
oauthClientId: 'cid',
oauthClientSecret: 'cs',
oauthScopes: null,
});
const cache = createToolCache(db, 600);
cache.replaceForServer('canva', [
{ name: 'generate_designs', description: 'g', inputSchema: { type: 'object', properties: {} } },
]);
});
it('returns ToolDefs only for servers the user is connected to', async () => {
const tm = createTokenManager(db, { doRefresh: async () => ({ access_token: 'x' }) });
const agg = createAggregator({
registry: createRegistry(db),
tokenManager: tm,
toolCache: createToolCache(db, 600),
executeCall: vi.fn(),
});
// No token → empty
let defs = await agg.getToolDefs('u1', ['mcp__canva__*']);
expect(defs).toEqual([]);
tm.saveTokens({
userId: 'u1',
serverId: 'canva',
accessToken: 'at',
refreshToken: 'rt',
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
scope: null,
});
defs = await agg.getToolDefs('u1', ['mcp__canva__*']);
expect(defs).toHaveLength(1);
expect(defs[0].function.name).toBe('mcp__canva__generate_designs');
});
it('delegates executeTool for mcp__ names and returns null otherwise', async () => {
const executeCall = vi.fn().mockResolvedValue({ output: 'ok', isError: false });
const agg = createAggregator({
registry: createRegistry(db),
tokenManager: createTokenManager(db, { doRefresh: async () => ({ access_token: 'x' }) }),
toolCache: createToolCache(db, 600),
executeCall,
});
const other = await agg.executeTool('Read', {}, { workspacePath: '/tmp', ownerId: 'u1', jobId: 'j' } as never);
expect(other).toBeNull();
const bad = await agg.executeTool('mcp__bad__name__extra', {}, { workspacePath: '/tmp', ownerId: 'u1', jobId: 'j' } as never);
expect(bad?.isError).toBe(true);
});
});
+78
View File
@@ -0,0 +1,78 @@
import type { ToolDef } from '../llm/openai-compat.js';
import type { McpRegistry } from './registry.js';
import type { McpTokenManager } from './token-manager.js';
import type { McpToolCache } from './tool-cache.js';
import { buildToolDefsFromCache, parseToolName } from './tool-adapter.js';
import type { ExecuteCtx, ExecuteResult } from './tool-executor.js';
import { isKeyConfigured } from './crypto.js';
import { logger } from '../logger.js';
export interface AggregatorDeps {
registry: McpRegistry;
tokenManager: McpTokenManager;
toolCache: McpToolCache;
executeCall: (args: {
serverId: string;
toolName: string;
input: Record<string, unknown>;
ctx: ExecuteCtx;
accessToken: string;
serverUrl: string;
}) => Promise<ExecuteResult>;
}
export interface AggregatorCtx extends ExecuteCtx {
// Alias to the executor context
}
export function createAggregator(deps: AggregatorDeps) {
return {
async getToolDefs(userId: string, allowedPatterns: string[]): Promise<ToolDef[]> {
if (!isKeyConfigured()) return [];
const mcpPatterns = allowedPatterns.filter((p) => p.startsWith('mcp__'));
if (mcpPatterns.length === 0) return [];
const servers = deps.registry.listEnabledForUser(userId);
const connected = servers.filter((s) => deps.tokenManager.hasToken(userId, s.id));
if (connected.length === 0) return [];
const cache = deps.toolCache.getAllForServers(connected.map((s) => s.id));
const names = new Map(connected.map((s) => [s.id, s.name]));
return buildToolDefsFromCache(cache, mcpPatterns, names);
},
async executeTool(
name: string,
input: Record<string, unknown>,
ctx: AggregatorCtx,
): Promise<ExecuteResult | null> {
if (!name.startsWith('mcp__')) return null;
const parsed = parseToolName(name);
if (!parsed) return { output: `不正な MCP ツール名: ${name}`, isError: true };
const server = deps.registry.getDecrypted(parsed.serverId);
if (!server || !server.enabled) {
return { output: `MCP サーバーが利用不可: ${parsed.serverId}`, isError: true };
}
let accessToken: string;
try {
accessToken = await deps.tokenManager.getValidToken(ctx.ownerId, parsed.serverId);
} catch (err) {
return { output: `MCP 認証が必要: ${(err as Error).message}`, isError: true };
}
logger.debug(`[mcp:aggregator] execute server=${parsed.serverId} tool=${parsed.toolName}`);
return deps.executeCall({
serverId: parsed.serverId,
toolName: parsed.toolName,
input,
ctx,
accessToken,
serverUrl: server.url,
});
},
};
}
export type McpAggregator = ReturnType<typeof createAggregator>;
+88
View File
@@ -0,0 +1,88 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { saveBinary, type SaveBinaryInput } from './binary-saver.js';
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const FAKE_PNG = Buffer.concat([PNG_MAGIC, Buffer.alloc(16, 0)]);
describe('saveBinary', () => {
let workspace: string;
beforeEach(async () => {
workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-bin-'));
});
afterEach(async () => {
await fs.rm(workspace, { recursive: true, force: true });
});
const baseInput: Omit<SaveBinaryInput, 'bytes' | 'mimeType'> = {
workspacePath: '',
serverId: 'canva',
toolName: 'generate_designs',
maxBytes: 1024 * 1024,
jobQuota: { maxFiles: 10, maxBytes: 10 * 1024 * 1024, state: { files: 0, bytes: 0 } },
};
it('writes PNG content and returns a workspace-relative path', async () => {
const res = await saveBinary({
...baseInput,
workspacePath: workspace,
bytes: FAKE_PNG,
mimeType: 'image/png',
});
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.relPath).toMatch(/^output\/mcp\/canva\/generate_designs-/);
expect(res.relPath.endsWith('.png')).toBe(true);
const abs = path.join(workspace, res.relPath);
const stat = await fs.stat(abs);
expect(stat.size).toBe(FAKE_PNG.length);
}
});
it('rejects when magic bytes do not match mime type', async () => {
const res = await saveBinary({
...baseInput,
workspacePath: workspace,
bytes: Buffer.from('not an image'),
mimeType: 'image/png',
});
expect(res.ok).toBe(false);
});
it('rejects when exceeding per-file byte limit', async () => {
const res = await saveBinary({
...baseInput,
workspacePath: workspace,
bytes: FAKE_PNG,
mimeType: 'image/png',
maxBytes: 4,
});
expect(res.ok).toBe(false);
});
it('rejects when exceeding per-job file quota', async () => {
const quotaState = { files: 10, bytes: 0 };
const res = await saveBinary({
...baseInput,
workspacePath: workspace,
bytes: FAKE_PNG,
mimeType: 'image/png',
jobQuota: { maxFiles: 10, maxBytes: 1024, state: quotaState },
});
expect(res.ok).toBe(false);
});
it('rejects toolName with traversal', async () => {
const res = await saveBinary({
...baseInput,
toolName: '../../etc/passwd',
workspacePath: workspace,
bytes: FAKE_PNG,
mimeType: 'image/png',
});
expect(res.ok).toBe(false);
});
});
+101
View File
@@ -0,0 +1,101 @@
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { randomBytes } from 'node:crypto';
import { logger } from '../logger.js';
const SLUG = /^[a-z0-9_-]{1,64}$/;
const MIME_TO_EXT: Record<string, string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/gif': 'gif',
'image/webp': 'webp',
'image/svg+xml': 'svg',
'application/pdf': 'pdf',
'application/zip': 'zip',
'application/json': 'json',
'text/plain': 'txt',
'text/csv': 'csv',
};
const MAGIC_SIGNATURES: Array<{ mime: string; bytes: number[] | ((b: Buffer) => boolean) }> = [
{ mime: 'image/png', bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
{ mime: 'image/jpeg', bytes: [0xff, 0xd8, 0xff] },
{ mime: 'image/gif', bytes: [0x47, 0x49, 0x46, 0x38] },
{ mime: 'image/webp', bytes: (b) => b.slice(0, 4).toString() === 'RIFF' && b.slice(8, 12).toString() === 'WEBP' },
{ mime: 'application/pdf', bytes: [0x25, 0x50, 0x44, 0x46] }, // %PDF
{ mime: 'application/zip', bytes: [0x50, 0x4b, 0x03, 0x04] },
];
function magicMatches(bytes: Buffer, mime: string): boolean {
const sig = MAGIC_SIGNATURES.find((s) => s.mime === mime);
if (!sig) return true; // No magic check for plain-text MIMEs
if (typeof sig.bytes === 'function') return sig.bytes(bytes);
for (let i = 0; i < sig.bytes.length; i++) {
if (bytes[i] !== sig.bytes[i]) return false;
}
return true;
}
export interface JobQuotaState {
files: number;
bytes: number;
}
export interface SaveBinaryInput {
workspacePath: string;
serverId: string;
toolName: string;
bytes: Buffer;
mimeType: string;
maxBytes: number;
jobQuota: {
maxFiles: number;
maxBytes: number;
state: JobQuotaState;
};
}
export type SaveBinaryResult =
| { ok: true; relPath: string; size: number }
| { ok: false; reason: string };
export async function saveBinary(input: SaveBinaryInput): Promise<SaveBinaryResult> {
if (!SLUG.test(input.serverId)) return { ok: false, reason: 'invalid serverId slug' };
if (!SLUG.test(input.toolName)) return { ok: false, reason: 'invalid toolName slug' };
if (input.bytes.length > input.maxBytes) return { ok: false, reason: `binary exceeds max ${input.maxBytes}B` };
if (!magicMatches(input.bytes, input.mimeType)) {
return { ok: false, reason: `magic bytes do not match mimeType ${input.mimeType}` };
}
const quota = input.jobQuota;
if (quota.state.files >= quota.maxFiles) {
return { ok: false, reason: `job quota: max files ${quota.maxFiles} reached` };
}
if (quota.state.bytes + input.bytes.length > quota.maxBytes) {
return { ok: false, reason: `job quota: max bytes ${quota.maxBytes} reached` };
}
const ext = MIME_TO_EXT[input.mimeType] ?? 'bin';
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const shortHash = randomBytes(3).toString('hex');
const relDir = path.posix.join('output', 'mcp', input.serverId);
const relPath = path.posix.join(relDir, `${input.toolName}-${stamp}-${shortHash}.${ext}`);
const absDir = path.join(input.workspacePath, relDir);
const absPath = path.join(input.workspacePath, relPath);
// Path traversal defence: absPath must be within workspace.
const resolvedWorkspace = path.resolve(input.workspacePath);
const resolvedAbs = path.resolve(absPath);
if (!resolvedAbs.startsWith(resolvedWorkspace + path.sep)) {
return { ok: false, reason: 'resolved path escapes workspace' };
}
await fs.mkdir(absDir, { recursive: true });
await fs.writeFile(absPath, input.bytes);
quota.state.files += 1;
quota.state.bytes += input.bytes.length;
logger.debug(`[mcp:binary] saved ${relPath} size=${input.bytes.length}`);
return { ok: true, relPath, size: input.bytes.length };
}
+61
View File
@@ -0,0 +1,61 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { checkSSRFStrict, pinnedFetch } from './ssrf-strict.js';
import type { McpServerRecord } from './types.js';
import { logger } from '../logger.js';
export interface ClientFactoryOptions {
insecureLocalTestMode?: boolean;
/**
* When true, skip the SSRF strict-check for private/loopback addresses.
* Controlled by config.yaml `mcp.allow_private_addresses`.
*/
allowPrivateAddresses?: boolean;
callTimeoutMs: number;
}
export async function createMcpClient(
server: Pick<McpServerRecord, 'id' | 'url'>,
accessToken: string,
opts: ClientFactoryOptions,
): Promise<{ client: Client; close: () => Promise<void> }> {
let fetcher: typeof fetch;
if (opts.insecureLocalTestMode || opts.allowPrivateAddresses) {
fetcher = fetch;
} else {
const ssrf = await checkSSRFStrict(server.url);
if (!ssrf.ok) throw new Error(`SSRF check failed for server '${server.id}': ${ssrf.reason}`);
fetcher = ((url: string, init?: RequestInit) =>
pinnedFetch(url, {
...(init ?? {}),
pinnedIp: ssrf.pinnedIp,
family: ssrf.family,
})) as typeof fetch;
}
const transport = new StreamableHTTPClientTransport(new URL(server.url), {
fetch: fetcher,
requestInit: {
headers: { Authorization: `Bearer ${accessToken}` },
},
});
const client = new Client(
{ name: 'maestro', version: '0.1.0' },
{ capabilities: {} },
);
await client.connect(transport);
logger.debug(`[mcp:client] connected server=${server.id}`);
return {
client,
close: async () => {
try {
await client.close();
} catch (err) {
logger.warn(`[mcp:client] close failed server=${server.id}: ${(err as Error).message}`);
}
},
};
}
+13
View File
@@ -0,0 +1,13 @@
import { describe, it, expect } from 'vitest';
import { mergeMcpConfig, MCP_DEFAULTS } from './config.js';
describe('mergeMcpConfig', () => {
it('returns defaults when partial is undefined', () => {
expect(mergeMcpConfig(undefined)).toEqual(MCP_DEFAULTS);
});
it('overrides specified keys only', () => {
const merged = mergeMcpConfig({ callTimeoutSeconds: 120 });
expect(merged.callTimeoutSeconds).toBe(120);
expect(merged.maxBinarySizeMb).toBe(MCP_DEFAULTS.maxBinarySizeMb);
});
});
+28
View File
@@ -0,0 +1,28 @@
export interface McpRuntimeConfig {
callTimeoutSeconds: number;
maxBinarySizeMb: number;
maxOutputFilesPerJob: number;
maxOutputSizeMbPerJob: number;
toolCacheTtlSeconds: number;
oauthPendingTtlMinutes: number;
/**
* When true, skip the SSRF strict-check for MCP server URLs that resolve to
* private/loopback addresses. Intended for self-hosted MCP servers on a local
* network. Default false — all server URLs must resolve to public IPs.
*/
allowPrivateAddresses: boolean;
}
export const MCP_DEFAULTS: McpRuntimeConfig = {
callTimeoutSeconds: 60,
maxBinarySizeMb: 20,
maxOutputFilesPerJob: 10,
maxOutputSizeMbPerJob: 200,
toolCacheTtlSeconds: 600,
oauthPendingTtlMinutes: 10,
allowPrivateAddresses: false,
};
export function mergeMcpConfig(partial: Partial<McpRuntimeConfig> | undefined): McpRuntimeConfig {
return { ...MCP_DEFAULTS, ...(partial ?? {}) };
}
+113
View File
@@ -0,0 +1,113 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { encrypt, decrypt, loadKeyFromEnv, isKeyConfigured, initMcpKeyFromFile } from './crypto.js';
import { statSync, rmSync, writeFileSync, mkdirSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { randomUUID } from 'crypto';
describe('mcp/crypto', () => {
const validKey = 'a'.repeat(64); // 32 bytes hex
beforeEach(() => {
delete process.env.MCP_ENCRYPTION_KEY;
});
it('isKeyConfigured returns false when env var is missing', () => {
expect(isKeyConfigured()).toBe(false);
});
it('isKeyConfigured returns true when env var is a valid 64-char hex', () => {
process.env.MCP_ENCRYPTION_KEY = validKey;
expect(isKeyConfigured()).toBe(true);
});
it('isKeyConfigured returns false when env var is wrong length', () => {
process.env.MCP_ENCRYPTION_KEY = 'abc';
expect(isKeyConfigured()).toBe(false);
});
it('encrypt + decrypt roundtrips a string', () => {
process.env.MCP_ENCRYPTION_KEY = validKey;
const key = loadKeyFromEnv();
const cipher = encrypt('hello world', key);
expect(cipher).toBeInstanceOf(Buffer);
const plain = decrypt(cipher, key);
expect(plain).toBe('hello world');
});
it('produces different ciphertexts for the same plaintext (random IV)', () => {
process.env.MCP_ENCRYPTION_KEY = validKey;
const key = loadKeyFromEnv();
const a = encrypt('same', key);
const b = encrypt('same', key);
expect(Buffer.compare(a, b)).not.toBe(0);
});
it('decrypt throws on tampered ciphertext (GCM auth tag)', () => {
process.env.MCP_ENCRYPTION_KEY = validKey;
const key = loadKeyFromEnv();
const cipher = encrypt('secret', key);
cipher[cipher.length - 1] ^= 0xff; // flip last byte
expect(() => decrypt(cipher, key)).toThrow();
});
it('loadKeyFromEnv throws when key is unset', () => {
expect(() => loadKeyFromEnv()).toThrow(/MCP_ENCRYPTION_KEY/);
});
});
describe('initMcpKeyFromFile', () => {
let tmpDir: string;
let keyPath: string;
beforeEach(() => {
delete process.env.MCP_ENCRYPTION_KEY;
tmpDir = join(tmpdir(), `mcp-crypto-test-${randomUUID()}`);
keyPath = join(tmpDir, 'secrets', 'mcp.key');
});
afterEach(() => {
delete process.env.MCP_ENCRYPTION_KEY;
rmSync(tmpDir, { recursive: true, force: true });
});
it('generates a new 32-byte key file when file does not exist', () => {
initMcpKeyFromFile(keyPath);
const stat = statSync(keyPath);
expect(stat.size).toBe(32);
// mode check: 0o100600 = regular file with 0600 permissions
expect(stat.mode & 0o777).toBe(0o600);
});
it('sets MCP_ENCRYPTION_KEY env var (64-char hex) after generating', () => {
initMcpKeyFromFile(keyPath);
const raw = process.env.MCP_ENCRYPTION_KEY;
expect(raw).toMatch(/^[0-9a-f]{64}$/);
});
it('reads an existing key file and sets env var', () => {
// Pre-create the file with a known 32-byte key
mkdirSync(join(tmpDir, 'secrets'), { recursive: true });
const knownKey = Buffer.alloc(32, 0xab);
writeFileSync(keyPath, knownKey, { mode: 0o600 });
initMcpKeyFromFile(keyPath);
expect(process.env.MCP_ENCRYPTION_KEY).toBe('ab'.repeat(32));
});
it('calling twice uses the same key (idempotent)', () => {
initMcpKeyFromFile(keyPath);
const first = process.env.MCP_ENCRYPTION_KEY;
delete process.env.MCP_ENCRYPTION_KEY;
initMcpKeyFromFile(keyPath);
const second = process.env.MCP_ENCRYPTION_KEY;
expect(first).toBe(second);
});
it('throws if existing file is wrong size', () => {
mkdirSync(join(tmpDir, 'secrets'), { recursive: true });
writeFileSync(keyPath, Buffer.alloc(16, 0xff), { mode: 0o600 });
expect(() => initMcpKeyFromFile(keyPath)).toThrow(/not 32 bytes/);
});
});
+71
View File
@@ -0,0 +1,71 @@
import { randomBytes, createCipheriv, createDecipheriv, timingSafeEqual } from 'node:crypto';
import { existsSync, readFileSync, writeFileSync, chmodSync, mkdirSync } from 'fs';
import { dirname } from 'path';
const ALGO = 'aes-256-gcm';
const IV_LEN = 12;
const TAG_LEN = 16;
const KEY_HEX_LEN = 64; // 32 bytes
const ENV_VAR = 'MCP_ENCRYPTION_KEY';
/**
* Read or auto-generate the MCP encryption key at `path`.
* If the file exists: reads it (must be 32 bytes) and sets MCP_ENCRYPTION_KEY env var.
* If the file does not exist: generates 32 random bytes, writes to file (mode 0600),
* then sets the env var. Uses the same pattern as initMasterKey in src/crypto/sessions.ts.
*/
export function initMcpKeyFromFile(path: string): void {
if (existsSync(path)) {
const buf = readFileSync(path);
if (buf.length !== 32) {
throw new Error(`MCP key at ${path} is not 32 bytes (got ${buf.length})`);
}
process.env[ENV_VAR] = buf.toString('hex');
return;
}
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
const key = randomBytes(32);
writeFileSync(path, key, { mode: 0o600 });
chmodSync(path, 0o600);
process.env[ENV_VAR] = key.toString('hex');
}
export function isKeyConfigured(): boolean {
const raw = process.env[ENV_VAR];
return typeof raw === 'string' && /^[0-9a-fA-F]{64}$/.test(raw);
}
export function loadKeyFromEnv(): Buffer {
const raw = process.env[ENV_VAR];
if (!raw || !/^[0-9a-fA-F]{64}$/.test(raw)) {
throw new Error(
`${ENV_VAR} must be a 64-character hex string (32 bytes). MCP client features are disabled.`,
);
}
return Buffer.from(raw, 'hex');
}
// Layout: [IV (12)] [TAG (16)] [CIPHERTEXT (n)]
export function encrypt(plaintext: string, key: Buffer): Buffer {
const iv = randomBytes(IV_LEN);
const cipher = createCipheriv(ALGO, key, iv);
const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([iv, tag, ct]);
}
export function decrypt(blob: Buffer, key: Buffer): string {
if (blob.length < IV_LEN + TAG_LEN) throw new Error('ciphertext too short');
const iv = blob.subarray(0, IV_LEN);
const tag = blob.subarray(IV_LEN, IV_LEN + TAG_LEN);
const ct = blob.subarray(IV_LEN + TAG_LEN);
const decipher = createDecipheriv(ALGO, key, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
}
export function safeEqual(a: Buffer, b: Buffer): boolean {
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
+70
View File
@@ -0,0 +1,70 @@
import { describe, it, expect } from 'vitest';
import { createServer } from 'node:http';
import { fetchDiscovery } from './discovery.js';
async function startMock(
handler: (req: import('http').IncomingMessage, body: string, res: import('http').ServerResponse) => void,
): Promise<{ origin: string; close: () => Promise<void> }> {
const server = createServer((req, res) => {
const chunks: Buffer[] = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => handler(req, Buffer.concat(chunks).toString(), res));
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const port = (server.address() as { port: number }).port;
return {
origin: `http://127.0.0.1:${port}`,
close: () => new Promise((r) => server.close(() => r())),
};
}
describe('discovery (mock-backed)', () => {
it('fetchDiscovery returns endpoints and fingerprint', async () => {
let mockOrigin: string | undefined;
const mock = await startMock((req, _body, res) => {
if (req.url === '/.well-known/oauth-authorization-server') {
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({
issuer: mockOrigin,
authorization_endpoint: `${mockOrigin}/authorize`,
token_endpoint: `${mockOrigin}/token`,
}));
} else {
res.statusCode = 404;
res.end();
}
});
mockOrigin = mock.origin;
try {
const meta = await fetchDiscovery(mock.origin + '/mcp', { insecureLocalTestMode: true });
expect(meta.authorizationEndpoint).toContain('/authorize');
expect(meta.tokenEndpoint).toContain('/token');
expect(meta.fingerprint).toHaveLength(64); // sha256 hex
} finally {
await mock.close();
}
});
it('rejects discovery when endpoints have different origin than MCP url', async () => {
const mock = await startMock((req, _body, res) => {
if (req.url === '/.well-known/oauth-authorization-server') {
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({
issuer: 'https://evil.example',
authorization_endpoint: 'https://evil.example/authorize',
token_endpoint: 'https://evil.example/token',
}));
} else {
res.statusCode = 404;
res.end();
}
});
try {
await expect(
fetchDiscovery(mock.origin + '/mcp', { insecureLocalTestMode: true }),
).rejects.toThrow(/origin/i);
} finally {
await mock.close();
}
});
});
+150
View File
@@ -0,0 +1,150 @@
import { createHash } from 'node:crypto';
import { checkSSRFStrict, pinnedFetch } from './ssrf-strict.js';
import type { McpDiscoveryMetadata, TokenEndpointResponse } from './types.js';
export interface FetchOpts {
/**
* When true, skip SSRF + HTTPS enforcement and use plain `fetch`.
* ONLY set in tests against a 127.0.0.1 mock server.
*/
insecureLocalTestMode?: boolean;
}
async function safeFetch(urlStr: string, init: RequestInit, opts: FetchOpts): Promise<Response> {
if (opts.insecureLocalTestMode) {
return fetch(urlStr, init);
}
const ssrf = await checkSSRFStrict(urlStr);
if (!ssrf.ok) throw new Error(`SSRF check failed: ${ssrf.reason}`);
return pinnedFetch(urlStr, { ...init, pinnedIp: ssrf.pinnedIp, family: ssrf.family });
}
function sameOrigin(a: string, b: string): boolean {
try {
const ua = new URL(a);
const ub = new URL(b);
return ua.origin === ub.origin;
} catch {
return false;
}
}
export async function fetchDiscovery(
mcpUrlStr: string,
opts: FetchOpts = {},
): Promise<McpDiscoveryMetadata> {
const mcpUrl = new URL(mcpUrlStr);
const discoveryUrl = `${mcpUrl.origin}/.well-known/oauth-authorization-server`;
const res = await safeFetch(discoveryUrl, { method: 'GET' }, opts);
if (!res.ok) throw new Error(`Discovery fetch failed: ${res.status}`);
const bodyText = await res.text();
const meta = JSON.parse(bodyText) as {
issuer: string;
authorization_endpoint: string;
token_endpoint: string;
};
if (!sameOrigin(meta.authorization_endpoint, mcpUrlStr)) {
throw new Error(
`authorization_endpoint origin must match MCP url origin: got ${meta.authorization_endpoint}`,
);
}
if (!sameOrigin(meta.token_endpoint, mcpUrlStr)) {
throw new Error(
`token_endpoint origin must match MCP url origin: got ${meta.token_endpoint}`,
);
}
const fingerprint = createHash('sha256').update(bodyText).digest('hex');
return {
issuer: meta.issuer,
authorizationEndpoint: meta.authorization_endpoint,
tokenEndpoint: meta.token_endpoint,
fingerprint,
};
}
export interface ExchangeInput {
tokenEndpoint: string;
clientId: string;
clientSecret: string;
code: string;
redirectUri: string;
codeVerifier: string;
}
export async function exchangeCode(
input: ExchangeInput,
opts: FetchOpts = {},
): Promise<TokenEndpointResponse> {
const body = new URLSearchParams({
grant_type: 'authorization_code',
code: input.code,
redirect_uri: input.redirectUri,
client_id: input.clientId,
client_secret: input.clientSecret,
code_verifier: input.codeVerifier,
});
const res = await safeFetch(
input.tokenEndpoint,
{
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: body.toString(),
},
opts,
);
if (!res.ok) {
const text = await res.text();
const err = Object.assign(new Error(`token exchange failed: ${res.status} ${text}`), {
code: extractOauthError(text),
status: res.status,
});
throw err;
}
return (await res.json()) as TokenEndpointResponse;
}
export async function refreshAccessToken(
input: {
tokenEndpoint: string;
clientId: string;
clientSecret: string;
refreshToken: string;
},
opts: FetchOpts = {},
): Promise<TokenEndpointResponse> {
const body = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: input.refreshToken,
client_id: input.clientId,
client_secret: input.clientSecret,
});
const res = await safeFetch(
input.tokenEndpoint,
{
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: body.toString(),
},
opts,
);
if (!res.ok) {
const text = await res.text();
const err = Object.assign(new Error(`refresh failed: ${res.status} ${text}`), {
code: extractOauthError(text),
status: res.status,
});
throw err;
}
return (await res.json()) as TokenEndpointResponse;
}
function extractOauthError(text: string): string | undefined {
try {
const obj = JSON.parse(text) as { error?: string };
return obj.error;
} catch {
return undefined;
}
}
+350
View File
@@ -0,0 +1,350 @@
/**
* MCP integration tests (Issue #239 / Phase 7).
*
* Drives the real OAuth Express router + registry + token-manager + discovery
* against a real HTTP mock server (`mock-mcp-server.ts`). Complements the
* existing unit tests, which mock at finer granularity.
*
* Out of scope:
* - End-to-end SDK Streamable HTTP transport (uses SSE; tested via fake Client
* in `aggregator.test.ts` / `tool-executor.test.ts`)
* - Refresh-on-401 retry at aggregator/client level (separate issue)
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import express, { type Express } from 'express';
import { type Server } from 'node:http';
import Database from 'better-sqlite3';
import { runMigrations } from '../db/migrate.js';
import { createRegistry, type McpRegistry } from './registry.js';
import { createTokenManager } from './token-manager.js';
import { createMcpOauthRouter } from './oauth-routes.js';
import { fetchDiscovery, refreshAccessToken } from './discovery.js';
import { startMockMcpServer, type MockMcpServer } from './testing/mock-mcp-server.js';
const validKey = 'a'.repeat(64);
interface TestRig {
db: Database.Database;
mock: MockMcpServer;
registry: McpRegistry;
app: Express;
appServer: Server;
appOrigin: string;
resumedCalls: Array<[string, string]>;
}
async function bootRig(tools: { name: string; description?: string }[] = []): Promise<TestRig> {
process.env.MCP_ENCRYPTION_KEY = validKey;
const db = new Database(':memory:');
db.exec(`CREATE TABLE users (id TEXT PRIMARY KEY);`);
db.exec(`CREATE TABLE jobs (id TEXT PRIMARY KEY, wait_reason TEXT);`);
db.exec(`CREATE TABLE local_tasks (id INTEGER PRIMARY KEY AUTOINCREMENT);`);
runMigrations(db);
db.prepare('INSERT INTO users(id) VALUES(?)').run('u1');
const mock = await startMockMcpServer({ tools });
const registry = createRegistry(db);
const tm = createTokenManager(db, {
// Mirror production wiring: look up server endpoint + credentials, then refreshAccessToken().
// insecureLocalTestMode allows loopback for the mock server.
doRefresh: async (serverId, refreshToken) => {
const server = registry.getDecrypted(serverId);
if (!server || !server.tokenEndpoint) throw new Error(`no server/tokenEndpoint for ${serverId}`);
return refreshAccessToken(
{
tokenEndpoint: server.tokenEndpoint,
clientId: server.oauthClientId,
clientSecret: server.oauthClientSecret,
refreshToken,
},
{ insecureLocalTestMode: true },
);
},
});
const resumedCalls: Array<[string, string]> = [];
const app = express();
let appOrigin = '';
app.use(
'/auth/mcp',
createMcpOauthRouter({
db,
registry,
tokenManager: tm,
pendingTtlMinutes: 10,
getCallbackBaseUrl: () => appOrigin,
getAuthenticatedUserId: () => 'u1',
resumeWaitingJobs: (uid, sid) => {
resumedCalls.push([uid, sid]);
},
insecureLocalTestMode: true,
}),
);
const appServer = app.listen(0, '127.0.0.1');
await new Promise<void>((resolve) => appServer.once('listening', resolve));
appOrigin = `http://127.0.0.1:${(appServer.address() as { port: number }).port}`;
return { db, mock, registry, app, appServer, appOrigin, resumedCalls };
}
async function tearDownRig(rig: TestRig): Promise<void> {
await new Promise<void>((r) => rig.appServer.close(() => r()));
await rig.mock.close();
rig.db.close();
delete process.env.MCP_ENCRYPTION_KEY;
}
describe('MCP integration: OAuth dance against real mock server', () => {
let rig: TestRig;
beforeEach(async () => { rig = await bootRig(); });
afterEach(async () => { await tearDownRig(rig); });
it('discovery → registry → start → callback → token saved + encrypted at rest', async () => {
// 1. Discovery against the mock
const disco = await fetchDiscovery(rig.mock.origin + '/mcp', { insecureLocalTestMode: true });
expect(disco).toMatchObject({
issuer: rig.mock.origin,
authorizationEndpoint: `${rig.mock.origin}/authorize`,
tokenEndpoint: `${rig.mock.origin}/token`,
});
expect(disco.fingerprint).toMatch(/^[0-9a-f]{64}$/);
// 2. Register the server with discovery metadata
rig.registry.upsert({
id: 'mocksrv',
name: 'Mock',
url: `${rig.mock.origin}/mcp`,
authKind: 'oauth',
ownerId: null,
oauthClientId: 'test-client',
oauthClientSecret: 'test-secret',
oauthScopes: 'read',
});
rig.registry.setDiscovery('mocksrv', {
issuer: disco.issuer,
authorizationEndpoint: disco.authorizationEndpoint,
tokenEndpoint: disco.tokenEndpoint,
fingerprint: disco.fingerprint,
});
// 3. Start the OAuth dance — orchestrator redirects to mock /authorize
const startRes = await fetch(`${rig.appOrigin}/auth/mcp/mocksrv/start`, { redirect: 'manual' });
expect(startRes.status).toBe(302);
const authorizeUrl = startRes.headers.get('location')!;
expect(authorizeUrl).toContain(`${rig.mock.origin}/authorize`);
expect(authorizeUrl).toMatch(/code_challenge_method=S256/);
// 4. Follow the redirect to mock /authorize — it issues a code and redirects back
const authorizeRes = await fetch(authorizeUrl, { redirect: 'manual' });
expect(authorizeRes.status).toBe(302);
const callbackUrl = authorizeRes.headers.get('location')!;
expect(callbackUrl).toContain(`${rig.appOrigin}/auth/mcp/mocksrv/callback`);
// 5. Hit the callback — orchestrator exchanges code with mock /token, persists tokens
const callbackRes = await fetch(callbackUrl, { redirect: 'manual' });
expect(callbackRes.status).toBe(302);
const tokenRow = rig.db
.prepare('SELECT access_token_enc, refresh_token_enc, expires_at FROM user_mcp_tokens WHERE user_id=? AND server_id=?')
.get('u1', 'mocksrv') as { access_token_enc: Buffer; refresh_token_enc: Buffer; expires_at: string };
expect(tokenRow).toBeTruthy();
expect(tokenRow.access_token_enc).toBeInstanceOf(Buffer);
expect(tokenRow.refresh_token_enc).toBeInstanceOf(Buffer);
expect(new Date(tokenRow.expires_at).getTime()).toBeGreaterThan(Date.now());
// 6. Plaintext token bytes never appear in the DB column (encrypted at rest)
expect(tokenRow.access_token_enc.toString('utf-8')).not.toMatch(/^at-/);
expect(tokenRow.refresh_token_enc.toString('utf-8')).not.toMatch(/^rt-/);
// 7. Resume callback fired
expect(rig.resumedCalls).toEqual([['u1', 'mocksrv']]);
});
it('refresh roundtrip: expired token triggers /token POST with grant_type=refresh_token', async () => {
// Same dance to acquire initial tokens
const disco = await fetchDiscovery(rig.mock.origin + '/mcp', { insecureLocalTestMode: true });
rig.registry.upsert({
id: 'mocksrv',
name: 'Mock',
url: `${rig.mock.origin}/mcp`,
authKind: 'oauth',
ownerId: null,
oauthClientId: 'test-client',
oauthClientSecret: 'test-secret',
oauthScopes: null,
});
rig.registry.setDiscovery('mocksrv', {
issuer: disco.issuer,
authorizationEndpoint: disco.authorizationEndpoint,
tokenEndpoint: disco.tokenEndpoint,
fingerprint: disco.fingerprint,
});
const startRes = await fetch(`${rig.appOrigin}/auth/mcp/mocksrv/start`, { redirect: 'manual' });
const authorizeRes = await fetch(startRes.headers.get('location')!, { redirect: 'manual' });
await fetch(authorizeRes.headers.get('location')!, { redirect: 'manual' });
// Force the saved token to be expired
rig.db
.prepare("UPDATE user_mcp_tokens SET expires_at = datetime('now', '-1 hour') WHERE user_id=? AND server_id=?")
.run('u1', 'mocksrv');
// Recreate the token manager bound to this registry so getValidToken triggers refresh
const tm = createTokenManager(rig.db, {
doRefresh: async (serverId, refreshToken) => {
const server = rig.registry.getDecrypted(serverId);
return refreshAccessToken(
{
tokenEndpoint: server!.tokenEndpoint!,
clientId: server!.oauthClientId,
clientSecret: server!.oauthClientSecret,
refreshToken,
},
{ insecureLocalTestMode: true },
);
},
});
const refreshed = await tm.getValidToken('u1', 'mocksrv');
expect(refreshed).toMatch(/^at-/); // mock issues a fresh `at-<rnd>` token
// Refresh roundtrip is also visible on the mock side (the token row is rotated)
expect(rig.mock.issuedTokens.has(refreshed)).toBe(true);
});
it('callback rejected when state was not issued by start', async () => {
rig.registry.upsert({
id: 'mocksrv',
name: 'Mock',
url: `${rig.mock.origin}/mcp`,
authKind: 'oauth',
ownerId: null,
oauthClientId: 'test-client',
oauthClientSecret: 'test-secret',
oauthScopes: 'read',
});
const res = await fetch(
`${rig.appOrigin}/auth/mcp/mocksrv/callback?code=anything&state=NOT_ISSUED`,
{ redirect: 'manual' },
);
expect(res.status).toBe(400);
});
});
describe('MCP integration: api_key bearer flow', () => {
let rig: TestRig;
beforeEach(async () => {
rig = await bootRig([{ name: 'echo', description: 'echo args back' }]);
rig.mock.options.staticToken = 'sk-test-1234';
});
afterEach(async () => { await tearDownRig(rig); });
it('registry persists encrypted static token; tokenManager returns it for any user', async () => {
rig.registry.upsert({
id: 'apikey-srv',
name: 'ApiKey Mock',
url: `${rig.mock.origin}/mcp`,
authKind: 'api_key',
ownerId: null,
staticToken: 'sk-test-1234',
});
// Static token stored encrypted
const row = rig.db.prepare('SELECT static_token_enc FROM mcp_servers WHERE id=?').get('apikey-srv') as { static_token_enc: Buffer };
expect(row.static_token_enc).toBeInstanceOf(Buffer);
expect(row.static_token_enc.toString('utf-8')).not.toContain('sk-test-1234');
const tm = createTokenManager(rig.db, {
doRefresh: async () => { throw new Error('api_key servers should not refresh'); },
});
expect(tm.hasToken('u1', 'apikey-srv')).toBe(true);
expect(tm.hasToken('any-other-user', 'apikey-srv')).toBe(true);
const bearer = await tm.getValidToken('u1', 'apikey-srv');
expect(bearer).toBe('sk-test-1234');
});
it('static token authenticates against the mock /mcp endpoint (tools/list + tools/call)', async () => {
// Hit /mcp directly with the bearer to validate the HTTP contract.
// (Real SDK transport uses SSE — out of scope; tested separately via fake Client.)
const listRes = await fetch(`${rig.mock.origin}/mcp`, {
method: 'POST',
headers: { authorization: 'Bearer sk-test-1234', 'content-type': 'application/json' },
body: JSON.stringify({ method: 'tools/list' }),
});
expect(listRes.ok).toBe(true);
const listJson = await listRes.json() as { result: { tools: Array<{ name: string }> } };
expect(listJson.result.tools.map((t) => t.name)).toEqual(['echo']);
const callRes = await fetch(`${rig.mock.origin}/mcp`, {
method: 'POST',
headers: { authorization: 'Bearer sk-test-1234', 'content-type': 'application/json' },
body: JSON.stringify({ method: 'tools/call', params: { name: 'echo', arguments: { x: 1 } } }),
});
expect(callRes.ok).toBe(true);
const callJson = await callRes.json() as { result: { content: Array<{ type: string; text: string }> } };
expect(callJson.result.content[0].text).toBe('mock-called echo');
// Wrong bearer is rejected with 401
const unauthRes = await fetch(`${rig.mock.origin}/mcp`, {
method: 'POST',
headers: { authorization: 'Bearer wrong', 'content-type': 'application/json' },
body: JSON.stringify({ method: 'tools/list' }),
});
expect(unauthRes.status).toBe(401);
});
});
describe('MCP integration: refresh failure deletes token', () => {
let rig: TestRig;
beforeEach(async () => { rig = await bootRig(); });
afterEach(async () => { await tearDownRig(rig); });
it('expired token + invalid_grant on refresh → token row is deleted', async () => {
rig.registry.upsert({
id: 'mocksrv',
name: 'Mock',
url: `${rig.mock.origin}/mcp`,
authKind: 'oauth',
ownerId: null,
oauthClientId: 'test-client',
oauthClientSecret: 'test-secret',
oauthScopes: null,
});
rig.registry.setDiscovery('mocksrv', {
issuer: rig.mock.origin,
authorizationEndpoint: `${rig.mock.origin}/authorize`,
tokenEndpoint: `${rig.mock.origin}/token`,
fingerprint: 'fp',
});
const tm = createTokenManager(rig.db, {
doRefresh: async (serverId, refreshToken) => {
const server = rig.registry.getDecrypted(serverId);
return refreshAccessToken(
{
tokenEndpoint: server!.tokenEndpoint!,
clientId: server!.oauthClientId,
clientSecret: server!.oauthClientSecret,
refreshToken,
},
{ insecureLocalTestMode: true },
);
},
});
// Save a token row with an expired timestamp and a bogus refresh token.
tm.saveTokens({
userId: 'u1',
serverId: 'mocksrv',
accessToken: 'old-at',
refreshToken: 'never-issued-rt',
expiresAt: new Date(Date.now() - 60_000).toISOString(),
scope: null,
});
expect(tm.hasToken('u1', 'mocksrv')).toBe(true);
await expect(tm.getValidToken('u1', 'mocksrv')).rejects.toThrow();
expect(tm.hasToken('u1', 'mocksrv')).toBe(false);
});
});
+158
View File
@@ -0,0 +1,158 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import express from 'express';
import { createServer, type Server } from 'node:http';
import Database from 'better-sqlite3';
import { runMigrations } from '../db/migrate.js';
import { createRegistry } from './registry.js';
import { createTokenManager } from './token-manager.js';
import { createMcpOauthRouter } from './oauth-routes.js';
async function startMockOauthServer(): Promise<{
origin: string;
expectedCode: string;
close: () => Promise<void>;
}> {
const server = createServer((req, res) => {
if (req.url?.startsWith('/.well-known/oauth-authorization-server')) {
res.setHeader('content-type', 'application/json');
res.end(
JSON.stringify({
issuer: 'http://127.0.0.1',
authorization_endpoint: `http://127.0.0.1:${(server.address() as { port: number }).port}/authorize`,
token_endpoint: `http://127.0.0.1:${(server.address() as { port: number }).port}/token`,
}),
);
} else if (req.url?.startsWith('/token')) {
const chunks: Buffer[] = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
res.setHeader('content-type', 'application/json');
res.end(
JSON.stringify({
access_token: 'AT-1',
refresh_token: 'RT-1',
expires_in: 3600,
scope: 'read',
}),
);
});
} else {
res.statusCode = 404;
res.end();
}
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const origin = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
return {
origin,
expectedCode: 'auth-code-123',
close: () => new Promise((r) => server.close(() => r())),
};
}
describe('mcp oauth routes', () => {
const validKey = 'a'.repeat(64);
let db: Database.Database;
let mockOauth: Awaited<ReturnType<typeof startMockOauthServer>>;
let app: express.Express;
let appServer: Server;
let appOrigin: string;
let resumedCalls: Array<[string, string]>;
beforeEach(async () => {
process.env.MCP_ENCRYPTION_KEY = validKey;
db = new Database(':memory:');
db.exec(`CREATE TABLE users (id TEXT PRIMARY KEY);`);
db.exec(`CREATE TABLE jobs (id TEXT PRIMARY KEY, wait_reason TEXT);`);
db.exec(`CREATE TABLE local_tasks (id INTEGER PRIMARY KEY AUTOINCREMENT);`); // runMigrations needs this
runMigrations(db);
db.prepare('INSERT INTO users(id) VALUES(?)').run('u1');
mockOauth = await startMockOauthServer();
const reg = createRegistry(db);
reg.upsert({
id: 'canva',
name: 'Canva',
url: mockOauth.origin + '/mcp',
authKind: 'oauth',
ownerId: null,
oauthClientId: 'client',
oauthClientSecret: 'secret',
oauthScopes: 'read',
});
const tm = createTokenManager(db, { doRefresh: async () => ({ access_token: 'x' }) });
resumedCalls = [];
app = express();
app.use(
'/auth/mcp',
createMcpOauthRouter({
db,
registry: reg,
tokenManager: tm,
pendingTtlMinutes: 10,
getCallbackBaseUrl: () => appOrigin,
getAuthenticatedUserId: () => 'u1',
resumeWaitingJobs: (uid, sid) => {
resumedCalls.push([uid, sid]);
},
insecureLocalTestMode: true,
}),
);
appServer = app.listen(0, '127.0.0.1');
await new Promise<void>((resolve) => appServer.once('listening', resolve));
appOrigin = `http://127.0.0.1:${(appServer.address() as { port: number }).port}`;
});
afterEach(async () => {
await new Promise<void>((r) => appServer.close(() => r()));
await mockOauth.close();
db.close();
delete process.env.MCP_ENCRYPTION_KEY;
});
it('start redirects to authorization_endpoint and records pending row', async () => {
const res = await fetch(`${appOrigin}/auth/mcp/canva/start`, { redirect: 'manual' });
expect(res.status).toBe(302);
const loc = res.headers.get('location')!;
expect(loc).toContain('/authorize');
expect(loc).toMatch(/code_challenge_method=S256/);
const pendingCount = db.prepare('SELECT COUNT(*) AS c FROM mcp_oauth_pending').get() as {
c: number;
};
expect(pendingCount.c).toBe(1);
});
it('callback exchanges code and saves tokens, then calls resumeWaitingJobs', async () => {
// First, run start so state is recorded
await fetch(`${appOrigin}/auth/mcp/canva/start`, { redirect: 'manual' });
const pending = db.prepare('SELECT state FROM mcp_oauth_pending').get() as { state: string };
const callbackRes = await fetch(
`${appOrigin}/auth/mcp/canva/callback?code=some-code&state=${pending.state}`,
{ redirect: 'manual' },
);
expect(callbackRes.status).toBe(302);
const token = db
.prepare('SELECT * FROM user_mcp_tokens WHERE user_id=? AND server_id=?')
.get('u1', 'canva');
expect(token).toBeTruthy();
expect(resumedCalls).toEqual([['u1', 'canva']]);
});
it('callback rejects reused state (single-use)', async () => {
await fetch(`${appOrigin}/auth/mcp/canva/start`, { redirect: 'manual' });
const pending = db.prepare('SELECT state FROM mcp_oauth_pending').get() as { state: string };
const first = await fetch(
`${appOrigin}/auth/mcp/canva/callback?code=c&state=${pending.state}`,
{ redirect: 'manual' },
);
expect(first.status).toBe(302);
const second = await fetch(
`${appOrigin}/auth/mcp/canva/callback?code=c&state=${pending.state}`,
{ redirect: 'manual' },
);
expect(second.status).toBe(400);
});
});
+184
View File
@@ -0,0 +1,184 @@
import { Router, type RequestHandler } from 'express';
import { randomBytes, createHash } from 'node:crypto';
import type Database from 'better-sqlite3';
import type { McpRegistry } from './registry.js';
import type { McpTokenManager } from './token-manager.js';
import { fetchDiscovery, exchangeCode } from './discovery.js';
import { isKeyConfigured } from './crypto.js';
import { redactSecrets } from './redact.js';
import { logger } from '../logger.js';
function base64url(buf: Buffer): string {
return buf.toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
}
function pkcePair(): { verifier: string; challenge: string } {
const verifier = base64url(randomBytes(32));
const challenge = base64url(createHash('sha256').update(verifier).digest());
return { verifier, challenge };
}
export interface OauthRouterDeps {
db: Database.Database;
registry: McpRegistry;
tokenManager: McpTokenManager;
pendingTtlMinutes: number;
getCallbackBaseUrl: () => string;
getAuthenticatedUserId: (req: import('express').Request) => string | null;
resumeWaitingJobs: (userId: string, serverId: string) => void;
insecureLocalTestMode?: boolean;
/** Best-effort list_tools call after OAuth token is saved; non-fatal on failure. */
listToolsAfterAuth?: (serverId: string, accessToken: string) => Promise<void>;
}
export function createMcpOauthRouter(deps: OauthRouterDeps): Router {
const router = Router();
const requireAuthUser: RequestHandler = (req, res, next) => {
const uid = deps.getAuthenticatedUserId(req);
if (!uid) {
res.status(401).json({ error: 'Authentication required' });
return;
}
(req as unknown as { mcpUserId: string }).mcpUserId = uid;
next();
};
router.get('/:serverId/start', requireAuthUser, async (req, res) => {
if (!isKeyConfigured()) {
res.status(503).json({ error: 'MCP_ENCRYPTION_KEY not configured' });
return;
}
const { serverId } = req.params;
const userId = (req as unknown as { mcpUserId: string }).mcpUserId;
const server = deps.registry.getDecrypted(serverId);
if (!server || !server.enabled) {
res.status(404).json({ error: 'Unknown MCP server' });
return;
}
if (server.authKind !== 'oauth') {
res.status(400).json({ error: 'this server does not use OAuth — no authorization needed' });
return;
}
let authEndpoint = server.authorizationEndpoint;
let tokenEndpoint = server.tokenEndpoint;
if (!authEndpoint || !tokenEndpoint) {
try {
const meta = await fetchDiscovery(server.url, { insecureLocalTestMode: deps.insecureLocalTestMode });
deps.registry.setDiscovery(serverId, meta);
authEndpoint = meta.authorizationEndpoint;
tokenEndpoint = meta.tokenEndpoint;
} catch (err) {
logger.error(`[mcp:oauth] discovery failed server=${serverId}: ${(err as Error).message}`);
res.status(502).json({ error: 'Discovery failed' });
return;
}
}
const { verifier, challenge } = pkcePair();
const stateNonce = base64url(randomBytes(24));
// Clean expired pending rows
deps.db.prepare(
`DELETE FROM mcp_oauth_pending
WHERE datetime(created_at, '+' || ? || ' minutes') < datetime('now')`,
).run(String(deps.pendingTtlMinutes));
deps.db.prepare(
`INSERT INTO mcp_oauth_pending (state, user_id, server_id, code_verifier) VALUES (?, ?, ?, ?)`,
).run(stateNonce, userId, serverId, verifier);
const redirectUri = `${deps.getCallbackBaseUrl()}/auth/mcp/${encodeURIComponent(serverId)}/callback`;
const params = new URLSearchParams({
response_type: 'code',
client_id: server.oauthClientId,
redirect_uri: redirectUri,
state: stateNonce,
code_challenge: challenge,
code_challenge_method: 'S256',
});
if (server.oauthScopes) params.set('scope', server.oauthScopes);
res.redirect(`${authEndpoint}?${params.toString()}`);
});
router.get('/:serverId/callback', requireAuthUser, async (req, res) => {
const { serverId } = req.params;
const userId = (req as unknown as { mcpUserId: string }).mcpUserId;
const code = typeof req.query.code === 'string' ? req.query.code : '';
const state = typeof req.query.state === 'string' ? req.query.state : '';
if (!code || !state) {
res.status(400).send('Missing code/state');
return;
}
// single-use state
const pending = deps.db.prepare(
`DELETE FROM mcp_oauth_pending WHERE state = ? RETURNING user_id, server_id, code_verifier`,
).get(state) as { user_id: string; server_id: string; code_verifier: string } | undefined;
if (!pending) {
res.status(400).send('Invalid or expired state');
return;
}
if (pending.user_id !== userId) {
res.status(403).send('State/user mismatch');
return;
}
if (pending.server_id !== serverId) {
res.status(400).send('State/server mismatch');
return;
}
const server = deps.registry.getDecrypted(serverId);
if (!server || !server.tokenEndpoint) {
res.status(500).send('Server token endpoint missing');
return;
}
const redirectUri = `${deps.getCallbackBaseUrl()}/auth/mcp/${encodeURIComponent(serverId)}/callback`;
try {
const resp = await exchangeCode(
{
tokenEndpoint: server.tokenEndpoint,
clientId: server.oauthClientId,
clientSecret: server.oauthClientSecret,
code,
redirectUri,
codeVerifier: pending.code_verifier,
},
{ insecureLocalTestMode: deps.insecureLocalTestMode },
);
if (server.issuer && resp.iss && resp.iss !== server.issuer) {
res.status(400).send('Issuer mismatch');
return;
}
const expiresAt = resp.expires_in
? new Date(Date.now() + resp.expires_in * 1000).toISOString()
: null;
deps.tokenManager.saveTokens({
userId,
serverId,
accessToken: resp.access_token,
refreshToken: resp.refresh_token ?? null,
expiresAt,
scope: resp.scope ?? null,
});
// Auto list_tools so the LLM sees tools immediately (best-effort)
if (deps.listToolsAfterAuth) {
await deps.listToolsAfterAuth(serverId, resp.access_token).catch((err: unknown) => {
logger.warn(`[mcp:oauth] auto list_tools failed server=${serverId}: ${err}`);
});
}
deps.resumeWaitingJobs(userId, serverId);
logger.info(
`[mcp:oauth] connected user=${userId} server=${serverId} ${JSON.stringify(redactSecrets({ scope: resp.scope }))}`,
);
res.redirect('/settings#mcp');
} catch (err) {
logger.error(`[mcp:oauth] token exchange failed: ${(err as Error).message}`);
res.status(502).send('Token exchange failed');
}
});
return router;
}
+203
View File
@@ -0,0 +1,203 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { saveMcpRaw } from './raw-logger.js';
describe('saveMcpRaw', () => {
let workspace: string;
beforeEach(async () => {
workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-raw-'));
});
afterEach(async () => {
await fs.rm(workspace, { recursive: true, force: true });
});
async function listMcpDir(serverId: string): Promise<string[]> {
try {
return await fs.readdir(path.join(workspace, 'logs', 'mcp', serverId));
} catch {
return [];
}
}
it('writes a JSON file under logs/mcp/{serverId}/ with the expected structure', async () => {
saveMcpRaw({
workspacePath: workspace,
serverId: 'canva',
toolName: 'generate_designs',
args: { prompt: 'cat' },
content: [{ type: 'text', text: 'hello' }],
isError: false,
output: 'hello',
savedPaths: [],
});
const files = await listMcpDir('canva');
expect(files.length).toBe(1);
expect(files[0]).toMatch(/^generate_designs-.*\.json$/);
const body = JSON.parse(
await fs.readFile(path.join(workspace, 'logs', 'mcp', 'canva', files[0]), 'utf-8'),
);
expect(body.serverId).toBe('canva');
expect(body.toolName).toBe('generate_designs');
expect(body.arguments).toEqual({ prompt: 'cat' });
expect(body.isError).toBe(false);
expect(body.content).toEqual([{ type: 'text', text: 'hello' }]);
expect(body.output).toBe('hello');
expect(body.savedBinaries).toEqual([]);
expect(typeof body.timestamp).toBe('string');
});
it('appends one line per call to logs/mcp-history.jsonl', async () => {
saveMcpRaw({
workspacePath: workspace,
serverId: 'canva',
toolName: 'tool_a',
args: {},
content: [{ type: 'text', text: 'A' }],
isError: false,
output: 'A',
savedPaths: [],
});
saveMcpRaw({
workspacePath: workspace,
serverId: 'canva',
toolName: 'tool_b',
args: {},
content: [{ type: 'text', text: 'B' }],
isError: true,
output: 'B',
savedPaths: [],
});
const lines = (
await fs.readFile(path.join(workspace, 'logs', 'mcp-history.jsonl'), 'utf-8')
)
.trim()
.split('\n');
expect(lines.length).toBe(2);
const e0 = JSON.parse(lines[0]);
const e1 = JSON.parse(lines[1]);
expect(e0.toolName).toBe('tool_a');
expect(e0.isError).toBe(false);
expect(e0.serverId).toBe('canva');
expect(e0.filename).toMatch(/^logs\/mcp\/canva\/tool_a-/);
expect(e0.bytes).toBeGreaterThan(0);
expect(e1.toolName).toBe('tool_b');
expect(e1.isError).toBe(true);
});
it('redacts secrets in arguments', async () => {
saveMcpRaw({
workspacePath: workspace,
serverId: 'oauth',
toolName: 'tok',
args: {
prompt: 'hi',
access_token: 'super-secret',
nested: { refresh_token: 'r' },
},
content: [],
isError: false,
output: '',
savedPaths: [],
});
const files = await listMcpDir('oauth');
const body = JSON.parse(
await fs.readFile(path.join(workspace, 'logs', 'mcp', 'oauth', files[0]), 'utf-8'),
);
expect(body.arguments.prompt).toBe('hi');
expect(body.arguments.access_token).toBe('***');
expect(body.arguments.nested.refresh_token).toBe('***');
});
it('strips base64 from image and resource blocks (replaces with reference)', async () => {
saveMcpRaw({
workspacePath: workspace,
serverId: 'canva',
toolName: 'render',
args: {},
content: [
{ type: 'image', data: 'AAAA'.repeat(10000), mimeType: 'image/png' },
{ type: 'resource', resource: { blob: 'BBBB'.repeat(10000), mimeType: 'application/pdf' } },
{ type: 'text', text: 'caption' },
],
isError: false,
output: 'Saved: output/mcp/canva/render-x.png',
savedPaths: ['output/mcp/canva/render-x.png', 'output/mcp/canva/render-y.pdf'],
});
const files = await listMcpDir('canva');
const body = JSON.parse(
await fs.readFile(path.join(workspace, 'logs', 'mcp', 'canva', files[0]), 'utf-8'),
);
expect(body.content[0].type).toBe('image');
expect(body.content[0].data).toMatch(/^<base64 omitted/);
expect(body.content[0].mimeType).toBe('image/png');
expect(body.content[1].resource.blob).toMatch(/^<base64 omitted/);
expect(body.content[1].resource.mimeType).toBe('application/pdf');
expect(body.content[2]).toEqual({ type: 'text', text: 'caption' });
expect(body.savedBinaries).toEqual([
'output/mcp/canva/render-x.png',
'output/mcp/canva/render-y.pdf',
]);
});
it('saves failure responses as well (isError true with synthetic content)', async () => {
saveMcpRaw({
workspacePath: workspace,
serverId: 'canva',
toolName: 'fail',
args: { x: 1 },
content: [],
isError: true,
output: 'MCP call failed: boom',
savedPaths: [],
});
const files = await listMcpDir('canva');
expect(files.length).toBe(1);
const body = JSON.parse(
await fs.readFile(path.join(workspace, 'logs', 'mcp', 'canva', files[0]), 'utf-8'),
);
expect(body.isError).toBe(true);
expect(body.output).toBe('MCP call failed: boom');
});
it('sanitizes unsafe serverId / toolName for filesystem paths', async () => {
saveMcpRaw({
workspacePath: workspace,
serverId: '../escape',
toolName: 'weird/tool name',
args: {},
content: [],
isError: false,
output: '',
savedPaths: [],
});
// Must not create files outside workspace
const outside = await fs.readdir(path.dirname(workspace));
expect(outside.some((f) => f === 'escape')).toBe(false);
// Should still write under logs/mcp/ with sanitized names
const root = await fs.readdir(path.join(workspace, 'logs', 'mcp'));
expect(root.length).toBe(1);
expect(root[0]).not.toContain('/');
expect(root[0]).not.toContain('..');
});
it('does not throw when workspacePath is invalid (best-effort)', () => {
expect(() =>
saveMcpRaw({
workspacePath: '/nonexistent/path/that/should/fail/\0invalid',
serverId: 'x',
toolName: 'y',
args: {},
content: [],
isError: false,
output: '',
savedPaths: [],
}),
).not.toThrow();
});
});
+115
View File
@@ -0,0 +1,115 @@
import { mkdirSync, writeFileSync, appendFileSync } from 'node:fs';
import path from 'node:path';
import { randomBytes } from 'node:crypto';
import { logger } from '../logger.js';
import { redactSecrets } from './redact.js';
export interface SaveMcpRawInput {
workspacePath: string;
serverId: string;
toolName: string;
args: Record<string, unknown>;
content: unknown[];
isError: boolean;
output: string;
savedPaths: string[];
}
function sanitizeSlug(raw: string, fallback: string): string {
const cleaned = raw.toLowerCase().replace(/[^a-z0-9_-]/g, '-').slice(0, 64);
return cleaned.length > 0 ? cleaned : fallback;
}
function stampForFilename(): string {
return new Date().toISOString().replace(/[:.]/g, '-');
}
interface ContentBlockLike {
type?: unknown;
data?: unknown;
resource?: { blob?: unknown; [k: string]: unknown };
[k: string]: unknown;
}
function stripBase64(content: unknown[]): unknown[] {
return content.map((block) => {
if (!block || typeof block !== 'object') return block;
const b = block as ContentBlockLike;
const clone: ContentBlockLike = { ...b };
if (typeof b.data === 'string' && b.data.length > 0) {
clone.data = `<base64 omitted (${b.data.length} chars) — see savedBinaries>`;
}
if (b.resource && typeof b.resource === 'object') {
const r = b.resource as { blob?: unknown };
if (typeof r.blob === 'string' && r.blob.length > 0) {
clone.resource = {
...b.resource,
blob: `<base64 omitted (${r.blob.length} chars) — see savedBinaries>`,
};
}
}
return clone;
});
}
/**
* Persist a raw MCP tool-call response to {workspace}/logs/mcp/{serverId}/{toolName}-{stamp}-{hash}.json
* and append a one-line summary to {workspace}/logs/mcp-history.jsonl.
*
* Best-effort: never throws — failures are logged at warn level and the caller continues.
* Base64 payloads inside image/resource blocks are replaced with a reference string
* because the same bytes are already saved separately under output/mcp/ by binary-saver.
*/
export function saveMcpRaw(input: SaveMcpRawInput): void {
try {
const serverSlug = sanitizeSlug(input.serverId, 'unknown-server');
const toolSlug = sanitizeSlug(input.toolName, 'unknown-tool');
const stamp = stampForFilename();
const hash = randomBytes(3).toString('hex');
const filename = `${toolSlug}-${stamp}-${hash}.json`;
const relDir = path.posix.join('logs', 'mcp', serverSlug);
const absDir = path.join(input.workspacePath, relDir);
const absPath = path.join(absDir, filename);
// Path traversal defence: resolved absPath must stay inside workspace.
const resolvedWorkspace = path.resolve(input.workspacePath);
const resolvedAbs = path.resolve(absPath);
if (!resolvedAbs.startsWith(resolvedWorkspace + path.sep)) {
logger.warn(`[mcp:raw] resolved path escapes workspace; skip save`);
return;
}
mkdirSync(absDir, { recursive: true });
const payload = {
timestamp: new Date().toISOString(),
serverId: input.serverId,
toolName: input.toolName,
arguments: redactSecrets(input.args),
isError: input.isError,
content: stripBase64(input.content),
output: input.output,
savedBinaries: input.savedPaths,
};
const json = JSON.stringify(payload, null, 2);
writeFileSync(absPath, json, 'utf-8');
const relFile = path.posix.join(relDir, filename);
const historyEntry = {
timestamp: payload.timestamp,
serverId: input.serverId,
toolName: input.toolName,
filename: relFile,
isError: input.isError,
bytes: Buffer.byteLength(json, 'utf-8'),
};
appendFileSync(
path.join(input.workspacePath, 'logs', 'mcp-history.jsonl'),
JSON.stringify(historyEntry) + '\n',
'utf-8',
);
} catch (err) {
logger.warn(`[mcp:raw] failed to save raw mcp data: ${(err as Error).message}`);
}
}
+31
View File
@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest';
import { redactSecrets } from './redact.js';
describe('redactSecrets', () => {
it('masks access_token and refresh_token', () => {
expect(redactSecrets({ access_token: 'abc', data: 'keep' })).toEqual({
access_token: '***',
data: 'keep',
});
expect(redactSecrets({ refresh_token: 'abc' })).toEqual({ refresh_token: '***' });
});
it('masks Authorization header variations', () => {
expect(redactSecrets({ Authorization: 'Bearer xyz' })).toEqual({ Authorization: '***' });
expect(redactSecrets({ authorization: 'Bearer xyz' })).toEqual({ authorization: '***' });
});
it('masks nested objects and arrays', () => {
const out = redactSecrets({ outer: { code_verifier: 'v', inner: [{ code: 'c' }] } });
expect(out).toEqual({ outer: { code_verifier: '***', inner: [{ code: '***' }] } });
});
it('returns non-object inputs unchanged', () => {
expect(redactSecrets('hello')).toBe('hello');
expect(redactSecrets(42)).toBe(42);
expect(redactSecrets(null)).toBe(null);
});
it('handles client_secret and id_token', () => {
expect(redactSecrets({ client_secret: 's', id_token: 't' })).toEqual({
client_secret: '***',
id_token: '***',
});
});
});
+31
View File
@@ -0,0 +1,31 @@
const SECRET_TOKENS = [
'access_token',
'refresh_token',
'client_secret',
'authorization',
'code_verifier',
'id_token',
];
export function redactSecrets<T>(value: T): T {
if (value === null || value === undefined) return value;
if (Array.isArray(value)) {
return value.map((v) => redactSecrets(v)) as unknown as T;
}
if (typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
const lower = k.toLowerCase();
if (SECRET_TOKENS.some((s) => lower.includes(s))) {
out[k] = '***';
} else if (lower === 'code') {
// Stand-alone "code" key is always an authorization code in OAuth payloads; redact.
out[k] = '***';
} else {
out[k] = redactSecrets(v);
}
}
return out as T;
}
return value;
}
+185
View File
@@ -0,0 +1,185 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Database from 'better-sqlite3';
import { createRegistry } from './registry.js';
import { runMigrations } from '../db/migrate.js';
describe('McpRegistry', () => {
const validKey = 'a'.repeat(64);
let db: Database.Database;
beforeEach(() => {
process.env.MCP_ENCRYPTION_KEY = validKey;
db = new Database(':memory:');
db.pragma('foreign_keys = ON');
// runMigrations expects these tables to exist (it ALTERs them).
db.exec(`CREATE TABLE users (id TEXT PRIMARY KEY);`);
db.exec(`CREATE TABLE jobs (id TEXT PRIMARY KEY, wait_reason TEXT);`);
db.exec(`CREATE TABLE local_tasks (id INTEGER PRIMARY KEY AUTOINCREMENT);`);
runMigrations(db);
// Seed users referenced as owner_id in tests
for (const id of ['u1', 'alice', 'bob']) {
db.prepare('INSERT INTO users(id) VALUES(?)').run(id);
}
});
afterEach(() => {
db.close();
});
it('rejects invalid server ids', () => {
const r = createRegistry(db);
expect(() =>
r.upsert({
id: 'Bad ID!',
name: 'Canva',
url: 'https://mcp.canva.com/mcp',
authKind: 'oauth',
ownerId: null,
oauthClientId: 'cid',
oauthClientSecret: 'cs',
oauthScopes: null,
}),
).toThrow(/id must match/);
});
it('stores and returns without secret in public view (oauth)', () => {
const r = createRegistry(db);
r.upsert({
id: 'canva',
name: 'Canva',
url: 'https://mcp.canva.com/mcp',
authKind: 'oauth',
ownerId: null,
oauthClientId: 'cid',
oauthClientSecret: 'cs-secret',
oauthScopes: 'read write',
});
const pub = r.listPublic();
expect(pub).toHaveLength(1);
expect(pub[0].id).toBe('canva');
expect(pub[0].authKind).toBe('oauth');
expect(pub[0].ownerId).toBeNull();
// @ts-expect-error - public view must not have the secret
expect(pub[0].oauthClientSecret).toBeUndefined();
// @ts-expect-error - public view must not have staticToken
expect(pub[0].staticToken).toBeUndefined();
const full = r.getDecrypted('canva');
expect(full?.oauthClientSecret).toBe('cs-secret');
expect(full?.staticToken).toBeNull();
});
it('stores and retrieves api_key server', () => {
const r = createRegistry(db);
r.upsert({
id: 'myapi',
name: 'My API',
url: 'https://api.example.com/mcp',
authKind: 'api_key',
ownerId: 'u1',
staticToken: 'tok-secret-123',
});
const pub = r.listPublic();
expect(pub).toHaveLength(1);
expect(pub[0].authKind).toBe('api_key');
expect(pub[0].ownerId).toBe('u1');
// @ts-expect-error - public view must not have staticToken
expect(pub[0].staticToken).toBeUndefined();
const full = r.getDecrypted('myapi');
expect(full?.staticToken).toBe('tok-secret-123');
expect(full?.oauthClientSecret).toBe(''); // empty placeholder for NOT NULL column
});
it('rejects oauth server missing oauthClientId', () => {
const r = createRegistry(db);
expect(() =>
r.upsert({
id: 'bad',
name: 'Bad',
url: 'https://bad.example/mcp',
authKind: 'oauth',
ownerId: null,
// missing oauthClientId / oauthClientSecret
}),
).toThrow(/oauthClientId and oauthClientSecret/);
});
it('rejects api_key server missing staticToken', () => {
const r = createRegistry(db);
expect(() =>
r.upsert({
id: 'bad2',
name: 'Bad2',
url: 'https://bad2.example/mcp',
authKind: 'api_key',
ownerId: null,
// missing staticToken
}),
).toThrow(/staticToken/);
});
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' });
r.upsert({ id: 'user1', name: 'User1', url: 'https://u1.example/mcp', authKind: 'api_key', ownerId: 'alice', staticToken: 'tok' });
r.upsert({ id: 'user2', name: 'User2', url: 'https://u2.example/mcp', authKind: 'api_key', ownerId: 'bob', staticToken: 'tok' });
const aliceServers = r.listEnabledForUser('alice');
expect(aliceServers.map(s => s.id).sort()).toEqual(['global1', 'user1']);
const bobServers = r.listEnabledForUser('bob');
expect(bobServers.map(s => s.id).sort()).toEqual(['global1', 'user2']);
const strangerServers = r.listEnabledForUser('stranger');
expect(strangerServers.map(s => s.id)).toEqual(['global1']);
});
it('listEnabledForOwner returns only that user\'s servers', () => {
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: 'user1', name: 'User1', url: 'https://u1.example/mcp', authKind: 'api_key', ownerId: 'alice', staticToken: 'tok' });
const owned = r.listEnabledForOwner('alice');
expect(owned.map(s => s.id)).toEqual(['user1']);
});
it('delete removes the row and cascades', () => {
const r = createRegistry(db);
r.upsert({
id: 'x',
name: 'X',
url: 'https://a.example/mcp',
authKind: 'oauth',
ownerId: null,
oauthClientId: 'i',
oauthClientSecret: 's',
oauthScopes: null,
});
r.delete('x');
expect(r.listPublic()).toHaveLength(0);
});
it('updates discovery metadata', () => {
const r = createRegistry(db);
r.upsert({
id: 'canva',
name: 'Canva',
url: 'https://mcp.canva.com/mcp',
authKind: 'oauth',
ownerId: null,
oauthClientId: 'i',
oauthClientSecret: 's',
oauthScopes: null,
});
r.setDiscovery('canva', {
issuer: 'https://mcp.canva.com',
authorizationEndpoint: 'https://mcp.canva.com/authorize',
tokenEndpoint: 'https://mcp.canva.com/token',
fingerprint: 'abc123',
});
const got = r.getDecrypted('canva');
expect(got?.authorizationEndpoint).toBe('https://mcp.canva.com/authorize');
expect(got?.discoveryFingerprint).toBe('abc123');
});
});
+208
View File
@@ -0,0 +1,208 @@
import type Database from 'better-sqlite3';
import { encrypt, decrypt, loadKeyFromEnv } from './crypto.js';
import type {
AuthKind,
McpServerPublic,
McpServerRecord,
McpDiscoveryMetadata,
} from './types.js';
import { logger } from '../logger.js';
const ID_REGEX = /^[a-z0-9_-]{1,64}$/;
export interface UpsertInput {
id: string;
name: string;
url: string;
authKind: AuthKind;
ownerId: string | null; // null = global/admin-managed
// OAuth fields (required when authKind === 'oauth')
oauthClientId?: string;
oauthClientSecret?: string;
oauthScopes?: string | null;
// API key field (required when authKind === 'api_key')
staticToken?: string;
enabled?: boolean;
createdBy?: string | null;
}
interface Row {
id: string;
name: string;
url: string;
oauth_client_id: string;
oauth_client_secret_enc: Buffer;
oauth_scopes: string | null;
issuer: string | null;
authorization_endpoint: string | null;
token_endpoint: string | null;
discovery_fingerprint: string | null;
enabled: number;
created_by: string | null;
created_at: string;
updated_at: string;
// Phase 8 columns
auth_kind: string;
static_token_enc: Buffer | null;
owner_id: string | null;
}
function rowToPublic(r: Row): McpServerPublic {
return {
id: r.id,
name: r.name,
url: r.url,
authKind: (r.auth_kind ?? 'oauth') as AuthKind,
ownerId: r.owner_id ?? null,
oauthClientId: r.oauth_client_id,
oauthScopes: r.oauth_scopes,
issuer: r.issuer,
authorizationEndpoint: r.authorization_endpoint,
tokenEndpoint: r.token_endpoint,
discoveryFingerprint: r.discovery_fingerprint,
enabled: r.enabled === 1,
createdBy: r.created_by,
createdAt: r.created_at,
updatedAt: r.updated_at,
};
}
export function createRegistry(db: Database.Database) {
return {
upsert(input: UpsertInput): void {
if (!ID_REGEX.test(input.id)) {
throw new Error(`id must match ${ID_REGEX}: got "${input.id}"`);
}
const key = loadKeyFromEnv();
const authKind = input.authKind;
// Validate per-kind requirements
if (authKind === 'oauth') {
if (!input.oauthClientId || !input.oauthClientSecret) {
throw new Error(`authKind 'oauth' requires oauthClientId and oauthClientSecret`);
}
} else if (authKind === 'api_key') {
if (!input.staticToken) {
throw new Error(`authKind 'api_key' requires staticToken`);
}
} else {
throw new Error(`Unknown authKind: ${authKind as string}`);
}
// 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
// for api_key servers. static_token_enc = encrypt(realToken, key).
const oauthClientId = authKind === 'oauth' ? input.oauthClientId! : '';
const oauthSecretEnc = encrypt(authKind === 'oauth' ? input.oauthClientSecret! : '', key);
const staticTokenEnc = authKind === 'api_key' ? encrypt(input.staticToken!, key) : null;
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name=excluded.name,
url=excluded.url,
oauth_client_id=excluded.oauth_client_id,
oauth_client_secret_enc=excluded.oauth_client_secret_enc,
oauth_scopes=excluded.oauth_scopes,
auth_kind=excluded.auth_kind,
owner_id=excluded.owner_id,
static_token_enc=excluded.static_token_enc,
enabled=excluded.enabled,
updated_at=datetime('now')`,
).run(
input.id,
input.name,
input.url,
oauthClientId,
oauthSecretEnc,
input.oauthScopes ?? null,
authKind,
input.ownerId ?? null,
staticTokenEnc,
input.enabled === false ? 0 : 1,
input.createdBy ?? null,
);
logger.info(`[mcp:registry] upsert server id=${input.id} url=${input.url} authKind=${authKind}`);
},
delete(id: string): void {
db.prepare('DELETE FROM mcp_servers WHERE id = ?').run(id);
logger.info(`[mcp:registry] delete server id=${id}`);
},
listPublic(): McpServerPublic[] {
const rows = db.prepare('SELECT * FROM mcp_servers ORDER BY id').all() as Row[];
return rows.map(rowToPublic);
},
/**
* Returns all enabled servers visible to the given user:
* - Global (owner_id IS NULL) servers are visible to everyone.
* - User-owned (owner_id = userId) servers are only visible to that user.
*/
listEnabledForUser(userId: string): McpServerPublic[] {
const rows = db
.prepare('SELECT * FROM mcp_servers WHERE enabled = 1 AND (owner_id IS NULL OR owner_id = ?) ORDER BY id')
.all(userId) as Row[];
return rows.map(rowToPublic);
},
/**
* Returns only servers owned by the given user (for owner/admin scoped listing).
*/
listEnabledForOwner(ownerId: string): McpServerPublic[] {
const rows = db
.prepare('SELECT * FROM mcp_servers WHERE enabled = 1 AND owner_id = ? ORDER BY id')
.all(ownerId) as Row[];
return rows.map(rowToPublic);
},
/** @deprecated Use listEnabledForUser(userId) to include user-owned servers. */
listEnabledPublic(): McpServerPublic[] {
const rows = db
.prepare('SELECT * FROM mcp_servers WHERE enabled = 1 ORDER BY id')
.all() as Row[];
return rows.map(rowToPublic);
},
getDecrypted(id: string): McpServerRecord | null {
const row = db.prepare('SELECT * FROM mcp_servers WHERE id = ?').get(id) as
| Row
| undefined;
if (!row) return null;
const key = loadKeyFromEnv();
const authKind = (row.auth_kind ?? 'oauth') as AuthKind;
const oauthClientSecret = authKind === 'oauth'
? decrypt(row.oauth_client_secret_enc, key)
: ''; // not used for api_key servers
const staticToken = (authKind === 'api_key' && row.static_token_enc)
? decrypt(row.static_token_enc, key)
: null;
return {
...rowToPublic(row),
oauthClientSecret,
staticToken,
};
},
setDiscovery(id: string, meta: McpDiscoveryMetadata): void {
db.prepare(
`UPDATE mcp_servers
SET issuer=?, authorization_endpoint=?, token_endpoint=?, discovery_fingerprint=?, updated_at=datetime('now')
WHERE id=?`,
).run(meta.issuer, meta.authorizationEndpoint, meta.tokenEndpoint, meta.fingerprint, id);
},
setEnabled(id: string, enabled: boolean): void {
db.prepare(
`UPDATE mcp_servers SET enabled=?, updated_at=datetime('now') WHERE id=?`,
).run(enabled ? 1 : 0, id);
},
};
}
export type McpRegistry = ReturnType<typeof createRegistry>;
+27
View File
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest';
import { createServer } from 'node:http';
import { pinnedFetch } from './ssrf-strict.js';
describe('pinnedFetch', () => {
it('reaches a local server via pinned 127.0.0.1 using a fake Host header', async () => {
const server = createServer((req, res) => {
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({ host: req.headers.host, path: req.url }));
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const port = (server.address() as { port: number }).port;
// We intentionally pass a non-matching hostname and pin localhost so the DNS is bypassed.
try {
const res = await pinnedFetch(`http://example.invalid:${port}/ping`, {
pinnedIp: '127.0.0.1',
family: 4,
});
const json = (await res.json()) as { host: string; path: string };
expect(json.path).toBe('/ping');
expect(json.host).toContain('example.invalid');
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
});
+67
View File
@@ -0,0 +1,67 @@
import { describe, it, expect, vi } from 'vitest';
import { checkSSRFStrict, isPrivateOrForbidden } from './ssrf-strict.js';
describe('isPrivateOrForbidden', () => {
it('blocks IPv4 loopback', () => {
expect(isPrivateOrForbidden('127.0.0.1', 4)).toBe(true);
});
it('blocks IPv4 RFC1918 ranges', () => {
expect(isPrivateOrForbidden('10.0.0.1', 4)).toBe(true);
expect(isPrivateOrForbidden('172.16.0.1', 4)).toBe(true);
expect(isPrivateOrForbidden('10.0.0.10', 4)).toBe(true);
});
it('blocks IPv4 link-local and IMDS', () => {
expect(isPrivateOrForbidden('169.254.169.254', 4)).toBe(true);
});
it('blocks IPv6 loopback and mapped', () => {
expect(isPrivateOrForbidden('::1', 6)).toBe(true);
expect(isPrivateOrForbidden('::ffff:127.0.0.1', 6)).toBe(true);
});
it('blocks IPv6 unique-local', () => {
expect(isPrivateOrForbidden('fc00::1', 6)).toBe(true);
expect(isPrivateOrForbidden('fd00::1', 6)).toBe(true);
});
it('blocks IPv6 link-local', () => {
expect(isPrivateOrForbidden('fe80::1', 6)).toBe(true);
});
it('blocks AWS IMDSv6 prefix', () => {
expect(isPrivateOrForbidden('fd00:ec2::254', 6)).toBe(true);
});
it('blocks NAT64 prefix', () => {
expect(isPrivateOrForbidden('64:ff9b::1', 6)).toBe(true);
});
it('allows a public IPv4', () => {
expect(isPrivateOrForbidden('8.8.8.8', 4)).toBe(false);
});
it('allows a public IPv6', () => {
expect(isPrivateOrForbidden('2001:4860:4860::8888', 6)).toBe(false);
});
});
describe('checkSSRFStrict', () => {
it('rejects non-https schemes', async () => {
const res = await checkSSRFStrict('http://example.com');
expect(res.ok).toBe(false);
});
it('rejects if any resolved IP is private', async () => {
const fakeLookup = vi.fn().mockResolvedValue([
{ address: '8.8.8.8', family: 4 },
{ address: '::1', family: 6 }, // AAAA points to loopback — should reject
]);
const res = await checkSSRFStrict('https://example.com', { lookup: fakeLookup });
expect(res.ok).toBe(false);
});
it('accepts when all resolved IPs are public', async () => {
const fakeLookup = vi.fn().mockResolvedValue([
{ address: '8.8.8.8', family: 4 },
]);
const res = await checkSSRFStrict('https://example.com', { lookup: fakeLookup });
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.pinnedIp).toBe('8.8.8.8');
expect(res.family).toBe(4);
}
});
});
+22
View File
@@ -0,0 +1,22 @@
/**
* MCP-facing SSRF API.
*
* Logic now lives in src/net/ssrf-strict.ts so the SSH preflight can share it.
* This file is a thin re-export — existing imports from `src/mcp/ssrf-strict.js`
* keep working unchanged.
*/
export {
isPrivateOrForbidden,
checkSSRFStrict,
pinnedFetch,
resolveAndCheck,
pinnedConnect,
} from '../net/ssrf-strict.js';
export type {
LookupFn,
SsrfResult,
PinnedFetchOptions,
ResolveCheckArgs,
ResolveCheckResult,
PinnedConnectArgs,
} from '../net/ssrf-strict.js';
+254
View File
@@ -0,0 +1,254 @@
/**
* Mock MCP server for integration tests (Issue #239 / Phase 7).
*
* Pure Node http.createServer instance. NOT an MCP Streamable HTTP SSE transport —
* the real `@modelcontextprotocol/sdk` client cannot connect end-to-end through
* this. Instead, this mock covers:
*
* - OAuth discovery (`GET /.well-known/oauth-authorization-server`)
* - OAuth authorize (`GET /authorize` → 302 redirect with `?code=...&state=...`)
* - OAuth token (`POST /token`, grant=authorization_code or refresh_token)
* - Raw MCP JSON-RPC over HTTP (`POST /mcp` with `Authorization: Bearer ...`)
*
* Use cases:
* - OAuth dance integration tests: register server → start → callback → token roundtrip
* - api_key tests: validate that `tokenManager.getValidToken()` returns the right
* bearer and that the mock accepts it on `/mcp`
* - Refresh roundtrip tests: drive the /token endpoint with `grant_type=refresh_token`
*
* For full SDK transport tests, continue using fake `Client` objects (existing
* pattern in `aggregator.test.ts` / `tool-executor.test.ts`).
*/
import { createServer, type IncomingMessage, type ServerResponse, type Server } from 'node:http';
import type { AddressInfo } from 'node:net';
/** Definition of a tool the mock advertises via `tools/list`. */
export interface MockTool {
name: string;
description?: string;
inputSchema?: unknown;
}
export interface MockMcpServerOptions {
/** OAuth client id accepted on the /token endpoint. Default 'test-client'. */
clientId?: string;
/** OAuth client secret accepted on the /token endpoint. Default 'test-secret'. */
clientSecret?: string;
/**
* Static bearer token accepted on /mcp for api_key auth.
* When set, /mcp accepts `Authorization: Bearer <staticToken>` without the
* code → access_token dance.
*/
staticToken?: string;
/** Tools to advertise via tools/list. Default []. */
tools?: MockTool[];
/**
* Custom tool-call handler. Receives the tool name and arguments,
* returns the MCP `content[]` payload. Defaults to a stub that echoes
* the tool name as a text content block.
*/
callHandler?: (name: string, args: Record<string, unknown>) => { content: unknown[]; isError?: boolean };
/**
* When true, the next /token POST fails with 400 invalid_grant.
* Test helper for refresh-failure paths. Auto-resets after consuming.
*/
failNextTokenWith?: { status: number; body: unknown };
}
export interface MockMcpServer {
/** http://127.0.0.1:<port> — pass to registry.upsert as the server origin. */
origin: string;
port: number;
close(): Promise<void>;
/** Issued auth-codes (set on /authorize, consumed on /token). */
issuedCodes: Set<string>;
/** access_token → { refresh, scope } for tokens issued by /token. */
issuedTokens: Map<string, { refresh: string; scope: string | null }>;
/** History of /mcp call payloads for assertions. */
callLog: Array<{ method: string; params: unknown; authHeader: string | null }>;
/** Set `failNextTokenWith` at runtime to inject an error on the next /token. */
options: MockMcpServerOptions;
}
const DEFAULT_CALL_HANDLER: NonNullable<MockMcpServerOptions['callHandler']> = (name) => ({
content: [{ type: 'text', text: `mock-called ${name}` }],
});
export async function startMockMcpServer(opts: MockMcpServerOptions = {}): Promise<MockMcpServer> {
const options: MockMcpServerOptions = {
clientId: opts.clientId ?? 'test-client',
clientSecret: opts.clientSecret ?? 'test-secret',
staticToken: opts.staticToken,
tools: opts.tools ?? [],
callHandler: opts.callHandler ?? DEFAULT_CALL_HANDLER,
failNextTokenWith: opts.failNextTokenWith,
};
const issuedCodes = new Set<string>();
const issuedTokens = new Map<string, { refresh: string; scope: string | null }>();
const callLog: MockMcpServer['callLog'] = [];
const server: Server = createServer((req, res) => {
void handle(req, res).catch((err) => {
res.statusCode = 500;
res.end(JSON.stringify({ error: 'internal', detail: String(err) }));
});
});
let port = 0;
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
port = (server.address() as AddressInfo).port;
const origin = `http://127.0.0.1:${port}`;
async function readBody(req: IncomingMessage): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
return Buffer.concat(chunks).toString('utf-8');
}
function writeJson(res: ServerResponse, status: number, body: unknown): void {
res.statusCode = status;
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify(body));
}
async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
// OAuth discovery
if (url.pathname === '/.well-known/oauth-authorization-server' && req.method === 'GET') {
writeJson(res, 200, {
issuer: origin,
authorization_endpoint: `${origin}/authorize`,
token_endpoint: `${origin}/token`,
});
return;
}
// OAuth authorize: redirect with code+state
if (url.pathname === '/authorize' && req.method === 'GET') {
const code = `code-${Math.random().toString(36).slice(2)}`;
issuedCodes.add(code);
const state = url.searchParams.get('state') ?? '';
const redirect = url.searchParams.get('redirect_uri') ?? '';
res.statusCode = 302;
res.setHeader('location', `${redirect}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`);
res.end();
return;
}
// OAuth token endpoint (code → access; or refresh)
if (url.pathname === '/token' && req.method === 'POST') {
if (options.failNextTokenWith) {
const fail = options.failNextTokenWith;
options.failNextTokenWith = undefined; // consume one-shot
writeJson(res, fail.status, fail.body);
return;
}
const body = await readBody(req);
const form = new URLSearchParams(body);
const grant = form.get('grant_type');
if (grant === 'authorization_code') {
const code = form.get('code') ?? '';
if (!issuedCodes.has(code)) {
writeJson(res, 400, { error: 'invalid_grant' });
return;
}
issuedCodes.delete(code);
const access = `at-${Math.random().toString(36).slice(2)}`;
const refresh = `rt-${Math.random().toString(36).slice(2)}`;
const scope = form.get('scope') ?? null;
issuedTokens.set(access, { refresh, scope });
writeJson(res, 200, {
access_token: access,
refresh_token: refresh,
expires_in: 3600,
scope,
token_type: 'Bearer',
});
return;
}
if (grant === 'refresh_token') {
const rt = form.get('refresh_token') ?? '';
let matched: [string, { refresh: string; scope: string | null }] | undefined;
for (const entry of issuedTokens.entries()) {
if (entry[1].refresh === rt) { matched = entry; break; }
}
if (!matched) {
writeJson(res, 400, { error: 'invalid_grant' });
return;
}
const newAccess = `at-${Math.random().toString(36).slice(2)}`;
issuedTokens.set(newAccess, matched[1]);
// Old access token is invalidated (real OAuth servers vary; we mirror strict semantics).
issuedTokens.delete(matched[0]);
writeJson(res, 200, {
access_token: newAccess,
expires_in: 3600,
token_type: 'Bearer',
});
return;
}
writeJson(res, 400, { error: 'unsupported_grant_type' });
return;
}
// MCP JSON-RPC over HTTP
if (url.pathname === '/mcp' && req.method === 'POST') {
const authHeader = req.headers.authorization ?? null;
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : '';
const isApiKey = options.staticToken && token === options.staticToken;
const isOauth = issuedTokens.has(token);
if (!token || (!isApiKey && !isOauth)) {
writeJson(res, 401, { error: 'unauthorized' });
return;
}
const body = await readBody(req);
let payload: { method?: string; params?: { name?: string; arguments?: Record<string, unknown> } };
try {
payload = JSON.parse(body || '{}');
} catch {
writeJson(res, 400, { error: 'invalid_json' });
return;
}
callLog.push({
method: payload.method ?? '',
params: payload.params ?? {},
authHeader,
});
if (payload.method === 'tools/list') {
writeJson(res, 200, { jsonrpc: '2.0', result: { tools: options.tools ?? [] } });
return;
}
if (payload.method === 'tools/call') {
const name = payload.params?.name ?? '';
const args = payload.params?.arguments ?? {};
const result = (options.callHandler ?? DEFAULT_CALL_HANDLER)(name, args);
writeJson(res, 200, { jsonrpc: '2.0', result });
return;
}
writeJson(res, 400, { error: 'bad method' });
return;
}
res.statusCode = 404;
res.end();
}
return {
origin,
port,
issuedCodes,
issuedTokens,
callLog,
options,
close: () => new Promise<void>((resolve) => server.close(() => resolve())),
};
}
+136
View File
@@ -0,0 +1,136 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import Database from 'better-sqlite3';
import { createTokenManager } from './token-manager.js';
import { createRegistry } from './registry.js';
import { runMigrations } from '../db/migrate.js';
import { encrypt, loadKeyFromEnv } from './crypto.js';
describe('McpTokenManager', () => {
const validKey = 'a'.repeat(64);
let db: Database.Database;
beforeEach(() => {
process.env.MCP_ENCRYPTION_KEY = validKey;
db = new Database(':memory:');
db.exec(`CREATE TABLE users (id TEXT PRIMARY KEY);`);
db.exec(`CREATE TABLE jobs (id TEXT PRIMARY KEY, wait_reason TEXT);`);
db.exec(`CREATE TABLE local_tasks (id INTEGER PRIMARY KEY AUTOINCREMENT);`); // runMigrations needs this
runMigrations(db);
db.prepare(`INSERT INTO users(id) VALUES('u1')`).run();
const r = createRegistry(db);
r.upsert({
id: 'canva',
name: 'Canva',
url: 'https://mcp.canva.com/mcp',
authKind: 'oauth',
ownerId: null,
oauthClientId: 'cid',
oauthClientSecret: 'cs',
oauthScopes: null,
});
r.setDiscovery('canva', {
issuer: 'https://canva.example',
authorizationEndpoint: 'https://canva.example/auth',
tokenEndpoint: 'https://canva.example/token',
fingerprint: 'x',
});
});
it('saveTokens + hasToken + getValidToken happy path', async () => {
const tm = createTokenManager(db, { doRefresh: async () => { throw new Error('should not refresh'); } });
const future = new Date(Date.now() + 60 * 60_000).toISOString();
tm.saveTokens({
userId: 'u1',
serverId: 'canva',
accessToken: 'at-1',
refreshToken: 'rt-1',
expiresAt: future,
scope: 'read',
});
expect(tm.hasToken('u1', 'canva')).toBe(true);
expect(await tm.getValidToken('u1', 'canva')).toBe('at-1');
});
it('throws McpNotConnectedError when no row exists', async () => {
const tm = createTokenManager(db, { doRefresh: async () => ({ access_token: 'x' }) });
await expect(tm.getValidToken('u1', 'canva')).rejects.toMatchObject({ name: 'McpNotConnectedError' });
});
it('refreshes when expired', async () => {
const doRefresh = vi.fn().mockResolvedValue({
access_token: 'at-2',
refresh_token: 'rt-2',
expires_in: 3600,
});
const tm = createTokenManager(db, { doRefresh });
tm.saveTokens({
userId: 'u1',
serverId: 'canva',
accessToken: 'at-1',
refreshToken: 'rt-1',
expiresAt: new Date(Date.now() - 60_000).toISOString(), // already expired
scope: null,
});
const token = await tm.getValidToken('u1', 'canva');
expect(token).toBe('at-2');
expect(doRefresh).toHaveBeenCalledOnce();
// Second call hits cache (not expired anymore)
const token2 = await tm.getValidToken('u1', 'canva');
expect(token2).toBe('at-2');
expect(doRefresh).toHaveBeenCalledOnce();
});
it('deletes token on invalid_grant error', async () => {
const doRefresh = vi.fn().mockRejectedValue(Object.assign(new Error('invalid_grant'), { code: 'invalid_grant' }));
const tm = createTokenManager(db, { doRefresh });
tm.saveTokens({
userId: 'u1',
serverId: 'canva',
accessToken: 'at-1',
refreshToken: 'rt-1',
expiresAt: new Date(Date.now() - 60_000).toISOString(),
scope: null,
});
await expect(tm.getValidToken('u1', 'canva')).rejects.toThrow();
expect(tm.hasToken('u1', 'canva')).toBe(false);
});
describe('api_key auth', () => {
beforeEach(() => {
const r = createRegistry(db);
r.upsert({
id: 'apiserver',
name: 'API Server',
url: 'https://api.example.com/mcp',
authKind: 'api_key',
ownerId: 'u1',
staticToken: 'static-tok-abc',
});
});
it('hasToken returns true for api_key server with static_token_enc', () => {
const tm = createTokenManager(db, { doRefresh: async () => { throw new Error('no refresh'); } });
expect(tm.hasToken('u1', 'apiserver')).toBe(true);
});
it('hasToken returns false for unknown server', () => {
const tm = createTokenManager(db, { doRefresh: async () => { throw new Error('no refresh'); } });
expect(tm.hasToken('u1', 'nonexistent')).toBe(false);
});
it('getValidToken returns decrypted static token without refresh', async () => {
const doRefresh = vi.fn();
const tm = createTokenManager(db, { doRefresh });
const tok = await tm.getValidToken('u1', 'apiserver');
expect(tok).toBe('static-tok-abc');
expect(doRefresh).not.toHaveBeenCalled();
});
it('getValidToken for api_key is user-agnostic (any userId works)', async () => {
const tm = createTokenManager(db, { doRefresh: async () => { throw new Error('no refresh'); } });
// api_key servers don't gate on userId — any user can use the server's token
const tok = await tm.getValidToken('different-user', 'apiserver');
expect(tok).toBe('static-tok-abc');
});
});
});
+182
View File
@@ -0,0 +1,182 @@
import type Database from 'better-sqlite3';
import { encrypt, decrypt, loadKeyFromEnv } from './crypto.js';
import { McpNotConnectedError, McpTokenExpiredError, type TokenEndpointResponse } from './types.js';
import { logger } from '../logger.js';
export interface SaveTokensInput {
userId: string;
serverId: string;
accessToken: string;
refreshToken: string | null;
expiresAt: string | null;
scope: string | null;
}
export interface DoRefreshFn {
(serverId: string, refreshToken: string): Promise<TokenEndpointResponse>;
}
interface Row {
user_id: string;
server_id: string;
access_token_enc: Buffer;
refresh_token_enc: Buffer | null;
expires_at: string | null;
scope: string | null;
scope_type: string;
scope_id: string | null;
connected_at: string;
updated_at: string;
}
export function createTokenManager(
db: Database.Database,
deps: { doRefresh: DoRefreshFn },
) {
// In-process mutex per (userId, serverId)
const mutexes = new Map<string, Promise<unknown>>();
async function withMutex<T>(key: string, fn: () => Promise<T>): Promise<T> {
const prev = mutexes.get(key) ?? Promise.resolve();
const next = prev.catch(() => undefined).then(fn);
mutexes.set(key, next);
try {
return await next;
} finally {
if (mutexes.get(key) === next) mutexes.delete(key);
}
}
function getRow(userId: string, serverId: string): Row | null {
return (db
.prepare('SELECT * FROM user_mcp_tokens WHERE user_id=? AND server_id=?')
.get(userId, serverId) as Row | undefined) ?? null;
}
interface ServerAuthRow {
auth_kind: string;
static_token_enc: Buffer | null;
}
function getServerAuthRow(serverId: string): ServerAuthRow | null {
return (db
.prepare('SELECT auth_kind, static_token_enc FROM mcp_servers WHERE id = ?')
.get(serverId) as ServerAuthRow | undefined) ?? null;
}
return {
saveTokens(input: SaveTokensInput): void {
const key = loadKeyFromEnv();
db.prepare(
`INSERT INTO user_mcp_tokens
(user_id, server_id, access_token_enc, refresh_token_enc, expires_at, scope, scope_type, scope_id, connected_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 'user', NULL, datetime('now'), datetime('now'))
ON CONFLICT(user_id, server_id) DO UPDATE SET
access_token_enc=excluded.access_token_enc,
refresh_token_enc=excluded.refresh_token_enc,
expires_at=excluded.expires_at,
scope=excluded.scope,
updated_at=datetime('now')`,
).run(
input.userId,
input.serverId,
encrypt(input.accessToken, key),
input.refreshToken ? encrypt(input.refreshToken, key) : null,
input.expiresAt,
input.scope,
);
},
hasToken(userId: string, serverId: string): boolean {
const server = getServerAuthRow(serverId);
if (!server) return false;
if (server.auth_kind === 'api_key') {
// For api_key servers, a token is "present" iff static_token_enc is non-null.
// (NULL means the server was stored without a token, which shouldn't happen
// due to registry validation, but we guard defensively.)
return server.static_token_enc !== null;
}
// oauth path: check user_mcp_tokens row
return getRow(userId, serverId) !== null;
},
deleteToken(userId: string, serverId: string): void {
db.prepare('DELETE FROM user_mcp_tokens WHERE user_id=? AND server_id=?').run(userId, serverId);
},
async getValidToken(userId: string, serverId: string): Promise<string> {
const key = loadKeyFromEnv();
// Check auth_kind first — api_key servers skip user_mcp_tokens entirely.
const server = getServerAuthRow(serverId);
if (!server) throw new McpNotConnectedError(serverId);
if (server.auth_kind === 'api_key') {
if (!server.static_token_enc) throw new McpNotConnectedError(serverId);
return decrypt(server.static_token_enc, key);
}
const row = getRow(userId, serverId);
if (!row) throw new McpNotConnectedError(serverId);
if (row.expires_at && new Date(row.expires_at).getTime() > Date.now() + 30_000) {
return decrypt(row.access_token_enc, key);
}
if (!row.refresh_token_enc) {
db.prepare('DELETE FROM user_mcp_tokens WHERE user_id=? AND server_id=?').run(userId, serverId);
throw new McpTokenExpiredError(serverId);
}
return withMutex(`${userId}:${serverId}`, async () => {
const latest = getRow(userId, serverId);
if (!latest) throw new McpNotConnectedError(serverId);
if (latest.expires_at && new Date(latest.expires_at).getTime() > Date.now() + 30_000) {
return decrypt(latest.access_token_enc, key);
}
if (!latest.refresh_token_enc) {
db.prepare('DELETE FROM user_mcp_tokens WHERE user_id=? AND server_id=?').run(userId, serverId);
throw new McpTokenExpiredError(serverId);
}
const refreshPlain = decrypt(latest.refresh_token_enc, key);
let resp: TokenEndpointResponse;
try {
resp = await deps.doRefresh(serverId, refreshPlain);
} catch (err) {
const code = (err as { code?: string }).code;
if (code === 'invalid_grant') {
db.prepare('DELETE FROM user_mcp_tokens WHERE user_id=? AND server_id=?').run(userId, serverId);
logger.warn(`[mcp:token] invalid_grant, cleared tokens user=${userId} server=${serverId}`);
}
throw err;
}
const newExpiresAt = resp.expires_in
? new Date(Date.now() + resp.expires_in * 1000).toISOString()
: null;
const newRefresh = resp.refresh_token ?? refreshPlain;
const result = db.prepare(
`UPDATE user_mcp_tokens
SET access_token_enc=?, refresh_token_enc=?, expires_at=?, updated_at=datetime('now')
WHERE user_id=? AND server_id=? AND access_token_enc=?`,
).run(
encrypt(resp.access_token, key),
encrypt(newRefresh, key),
newExpiresAt,
userId,
serverId,
latest.access_token_enc,
);
if (result.changes === 0) {
// Another worker updated first — re-read.
const fresh = getRow(userId, serverId);
if (!fresh) throw new McpNotConnectedError(serverId);
return decrypt(fresh.access_token_enc, key);
}
return resp.access_token;
});
},
};
}
export type McpTokenManager = ReturnType<typeof createTokenManager>;
+60
View File
@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest';
import { matchesAnyPattern, normalizeToolName, parseToolName, buildToolDefsFromCache } from './tool-adapter.js';
describe('matchesAnyPattern', () => {
it('matches wildcard with prefix', () => {
expect(matchesAnyPattern('mcp__canva__generate', ['mcp__canva__*'])).toBe(true);
});
it('does not match different server', () => {
expect(matchesAnyPattern('mcp__notion__x', ['mcp__canva__*'])).toBe(false);
});
it('matches exact names', () => {
expect(matchesAnyPattern('mcp__canva__export', ['mcp__canva__export'])).toBe(true);
});
it('rejects empty tool suffix under wildcard', () => {
expect(matchesAnyPattern('mcp__canva__', ['mcp__canva__*'])).toBe(false);
});
});
describe('normalizeToolName / parseToolName', () => {
it('normalizes', () => {
expect(normalizeToolName('canva', 'generate_designs')).toBe('mcp__canva__generate_designs');
});
it('parses back', () => {
expect(parseToolName('mcp__canva__generate_designs')).toEqual({
serverId: 'canva',
toolName: 'generate_designs',
});
});
it('returns null for invalid prefix', () => {
expect(parseToolName('WebFetch')).toBeNull();
});
it('rejects slug violations', () => {
expect(parseToolName('mcp__BAD__ok')).toBeNull();
});
});
describe('buildToolDefsFromCache', () => {
const cache = [
{ serverId: 'canva', toolName: 'generate_designs', description: 'gen', inputSchema: '{"type":"object"}' },
{ serverId: 'canva', toolName: 'export_design', description: null, inputSchema: null },
{ serverId: 'notion', toolName: 'search', description: 'n', inputSchema: '{"type":"object"}' },
];
const serverNames = new Map([['canva', 'Canva'], ['notion', 'Notion']]);
it('filters by wildcard', () => {
const defs = buildToolDefsFromCache(cache, ['mcp__canva__*'], serverNames);
expect(defs.map((d) => d.function.name)).toEqual([
'mcp__canva__generate_designs',
'mcp__canva__export_design',
]);
});
it('prefixes description with [外部ツール: ... 提供]', () => {
const defs = buildToolDefsFromCache(cache, ['mcp__canva__generate_designs'], serverNames);
expect(defs[0].function.description).toMatch(/^\[外部ツール: Canva 提供\]/);
});
it('falls back to empty object schema when missing', () => {
const defs = buildToolDefsFromCache(cache, ['mcp__canva__export_design'], serverNames);
expect(defs[0].function.parameters).toEqual({ type: 'object', properties: {} });
});
});
+75
View File
@@ -0,0 +1,75 @@
import type { ToolDef } from '../llm/openai-compat.js';
const SLUG = /^[a-z0-9_-]{1,64}$/;
export function normalizeToolName(serverId: string, toolName: string): string {
return `mcp__${serverId}__${toolName}`;
}
export function parseToolName(name: string): { serverId: string; toolName: string } | null {
if (!name.startsWith('mcp__')) return null;
const parts = name.split('__');
if (parts.length !== 3) return null;
const [, serverId, toolName] = parts;
if (!SLUG.test(serverId) || !SLUG.test(toolName)) return null;
return { serverId, toolName };
}
export function matchesAnyPattern(name: string, patterns: string[]): boolean {
return patterns.some((p) => {
if (p === name) return true;
if (p.endsWith('__*')) {
const prefix = p.slice(0, -1);
return name.startsWith(prefix) && name.length > prefix.length;
}
return false;
});
}
export interface CachedTool {
serverId: string;
toolName: string;
description: string | null;
inputSchema: string | null;
}
const MAX_DESCRIPTION = 1000;
export function buildToolDefsFromCache(
cache: CachedTool[],
patterns: string[],
serverNames: Map<string, string>,
): ToolDef[] {
const mcpPatterns = patterns.filter((p) => p.startsWith('mcp__'));
if (mcpPatterns.length === 0) return [];
const defs: ToolDef[] = [];
for (const tool of cache) {
if (!SLUG.test(tool.serverId) || !SLUG.test(tool.toolName)) continue;
const canonical = normalizeToolName(tool.serverId, tool.toolName);
if (!matchesAnyPattern(canonical, mcpPatterns)) continue;
let params: unknown = { type: 'object', properties: {} };
if (tool.inputSchema) {
try {
params = JSON.parse(tool.inputSchema);
} catch {
// Keep default object schema
}
}
const serverLabel = serverNames.get(tool.serverId) ?? tool.serverId;
const rawDesc = tool.description ?? '';
const description = `[外部ツール: ${serverLabel} 提供] ${rawDesc}`.slice(0, MAX_DESCRIPTION);
defs.push({
type: 'function',
function: {
name: canonical,
description,
parameters: params as Record<string, unknown>,
},
});
}
return defs;
}
+87
View File
@@ -0,0 +1,87 @@
import type Database from 'better-sqlite3';
import type { CachedTool } from './tool-adapter.js';
import { logger } from '../logger.js';
const SLUG = /^[a-z0-9_-]{1,64}$/;
interface Row {
server_id: string;
tool_name: string;
description: string | null;
input_schema: string | null;
refreshed_at: string;
}
export function createToolCache(db: Database.Database, ttlSeconds: number) {
return {
getForServer(serverId: string): CachedTool[] {
const rows = db
.prepare('SELECT * FROM mcp_server_tools WHERE server_id = ?')
.all(serverId) as Row[];
return rows.map((r) => ({
serverId: r.server_id,
toolName: r.tool_name,
description: r.description,
inputSchema: r.input_schema,
}));
},
getAllForServers(serverIds: string[]): CachedTool[] {
if (serverIds.length === 0) return [];
const placeholders = serverIds.map(() => '?').join(',');
const rows = db
.prepare(`SELECT * FROM mcp_server_tools WHERE server_id IN (${placeholders})`)
.all(...serverIds) as Row[];
return rows.map((r) => ({
serverId: r.server_id,
toolName: r.tool_name,
description: r.description,
inputSchema: r.input_schema,
}));
},
get(serverId: string, toolName: string): { description: string | null; input_schema: string | null } | null {
const row = db
.prepare('SELECT description, input_schema FROM mcp_server_tools WHERE server_id=? AND tool_name=?')
.get(serverId, toolName) as { description: string | null; input_schema: string | null } | undefined;
return row ?? null;
},
isFreshForServer(serverId: string): boolean {
const row = db
.prepare('SELECT MIN(refreshed_at) AS oldest FROM mcp_server_tools WHERE server_id = ?')
.get(serverId) as { oldest: string | null };
if (!row.oldest) return false;
const ageMs = Date.now() - new Date(row.oldest).getTime();
return ageMs < ttlSeconds * 1000;
},
replaceForServer(
serverId: string,
tools: Array<{ name: string; description?: string; inputSchema?: unknown }>,
): void {
const tx = db.transaction(() => {
db.prepare('DELETE FROM mcp_server_tools WHERE server_id = ?').run(serverId);
const insert = db.prepare(
`INSERT INTO mcp_server_tools (server_id, tool_name, description, input_schema, refreshed_at)
VALUES (?, ?, ?, ?, datetime('now'))`,
);
for (const t of tools) {
if (!SLUG.test(t.name)) {
logger.warn(`[mcp:cache] skip tool with invalid slug: ${t.name}`);
continue;
}
insert.run(
serverId,
t.name,
t.description ?? null,
t.inputSchema ? JSON.stringify(t.inputSchema) : null,
);
}
});
tx();
logger.info(`[mcp:cache] refreshed server=${serverId} count=${tools.length}`);
},
invalidateServer(serverId: string): void {
db.prepare('DELETE FROM mcp_server_tools WHERE server_id = ?').run(serverId);
},
};
}
export type McpToolCache = ReturnType<typeof createToolCache>;
+127
View File
@@ -0,0 +1,127 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { executeMcpCall } from './tool-executor.js';
describe('executeMcpCall', () => {
let workspace: string;
beforeEach(async () => {
workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-exec-'));
});
afterEach(async () => {
await fs.rm(workspace, { recursive: true, force: true });
});
function baseCtx() {
return {
workspacePath: workspace,
ownerId: 'u1',
jobId: 'j1',
config: {
maxBinarySizeMb: 1,
maxOutputFilesPerJob: 5,
maxOutputSizeMbPerJob: 10,
callTimeoutSeconds: 30,
},
quotaState: { files: 0, bytes: 0 },
};
}
it('concatenates text content into output', async () => {
const fakeClient = {
callTool: vi.fn().mockResolvedValue({
content: [
{ type: 'text', text: 'hello ' },
{ type: 'text', text: 'world' },
],
}),
};
const res = await executeMcpCall({
client: fakeClient as never,
serverId: 'canva',
toolName: 'ping',
input: {},
ctx: baseCtx(),
});
expect(res.isError).toBeFalsy();
expect(res.output).toContain('hello world');
});
it('saves image content to output/mcp', async () => {
const PNG = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(4, 0)]);
const fakeClient = {
callTool: vi.fn().mockResolvedValue({
content: [{ type: 'image', data: PNG.toString('base64'), mimeType: 'image/png' }],
}),
};
const res = await executeMcpCall({
client: fakeClient as never,
serverId: 'canva',
toolName: 'render',
input: {},
ctx: baseCtx(),
});
expect(res.isError).toBeFalsy();
expect(res.output).toMatch(/Saved: output\/mcp\/canva\/render-/);
// Image must NOT be pushed to images[] (context-bloat guard)
expect((res as { images?: unknown }).images).toBeUndefined();
});
it('returns isError when callTool throws', async () => {
const fakeClient = {
callTool: vi.fn().mockRejectedValue(new Error('boom')),
};
const res = await executeMcpCall({
client: fakeClient as never,
serverId: 'canva',
toolName: 'x',
input: {},
ctx: baseCtx(),
});
expect(res.isError).toBe(true);
expect(res.output).toMatch(/boom/);
});
it('writes a raw JSON log under logs/mcp/{serverId}/ on success', async () => {
const fakeClient = {
callTool: vi.fn().mockResolvedValue({
content: [{ type: 'text', text: 'pong' }],
}),
};
await executeMcpCall({
client: fakeClient as never,
serverId: 'canva',
toolName: 'ping',
input: { q: 'hi' },
ctx: baseCtx(),
});
const files = await fs.readdir(path.join(workspace, 'logs', 'mcp', 'canva'));
expect(files.some((f) => f.startsWith('ping-') && f.endsWith('.json'))).toBe(true);
const history = await fs.readFile(path.join(workspace, 'logs', 'mcp-history.jsonl'), 'utf-8');
expect(history).toMatch(/"toolName":"ping"/);
});
it('writes a raw JSON log under logs/mcp/{serverId}/ on failure', async () => {
const fakeClient = {
callTool: vi.fn().mockRejectedValue(new Error('boom')),
};
await executeMcpCall({
client: fakeClient as never,
serverId: 'canva',
toolName: 'fail',
input: {},
ctx: baseCtx(),
});
const files = await fs.readdir(path.join(workspace, 'logs', 'mcp', 'canva'));
expect(files.some((f) => f.startsWith('fail-') && f.endsWith('.json'))).toBe(true);
const body = JSON.parse(
await fs.readFile(
path.join(workspace, 'logs', 'mcp', 'canva', files.find((f) => f.startsWith('fail-'))!),
'utf-8',
),
);
expect(body.isError).toBe(true);
expect(body.output).toMatch(/boom/);
});
});
+136
View File
@@ -0,0 +1,136 @@
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { saveBinary, type JobQuotaState } from './binary-saver.js';
import { saveMcpRaw } from './raw-logger.js';
import { logger } from '../logger.js';
export interface ExecuteCtx {
workspacePath: string;
ownerId: string;
jobId: string;
config: {
maxBinarySizeMb: number;
maxOutputFilesPerJob: number;
maxOutputSizeMbPerJob: number;
callTimeoutSeconds: number;
};
quotaState: JobQuotaState;
}
export interface ExecuteInput {
client: Client;
serverId: string;
toolName: string;
input: Record<string, unknown>;
ctx: ExecuteCtx;
}
export interface ExecuteResult {
output: string;
isError: boolean;
}
interface McpContentBlock {
type: string;
text?: string;
data?: string;
mimeType?: string;
resource?: { uri?: string; blob?: string; mimeType?: string };
}
export async function executeMcpCall(input: ExecuteInput): Promise<ExecuteResult> {
const { client, serverId, toolName, ctx } = input;
const timeoutMs = ctx.config.callTimeoutSeconds * 1000;
const abortController = new AbortController();
const timer = setTimeout(() => abortController.abort(), timeoutMs);
try {
const resp = (await client.callTool({ name: toolName, arguments: input.input })) as {
content?: McpContentBlock[];
isError?: boolean;
};
const content = resp.content ?? [];
const texts: string[] = [];
const savedPaths: string[] = [];
for (const block of content) {
if (block.type === 'text' && typeof block.text === 'string') {
texts.push(block.text);
} else if (block.type === 'image' && typeof block.data === 'string') {
const bytes = Buffer.from(block.data, 'base64');
const mimeType = block.mimeType ?? 'application/octet-stream';
const saved = await saveBinary({
workspacePath: ctx.workspacePath,
serverId,
toolName,
bytes,
mimeType,
maxBytes: ctx.config.maxBinarySizeMb * 1024 * 1024,
jobQuota: {
maxFiles: ctx.config.maxOutputFilesPerJob,
maxBytes: ctx.config.maxOutputSizeMbPerJob * 1024 * 1024,
state: ctx.quotaState,
},
});
if (saved.ok) savedPaths.push(saved.relPath);
else texts.push(`(failed to save image: ${saved.reason})`);
} else if (block.type === 'resource' && block.resource) {
if (block.resource.blob) {
const bytes = Buffer.from(block.resource.blob, 'base64');
const mimeType = block.resource.mimeType ?? 'application/octet-stream';
const saved = await saveBinary({
workspacePath: ctx.workspacePath,
serverId,
toolName,
bytes,
mimeType,
maxBytes: ctx.config.maxBinarySizeMb * 1024 * 1024,
jobQuota: {
maxFiles: ctx.config.maxOutputFilesPerJob,
maxBytes: ctx.config.maxOutputSizeMbPerJob * 1024 * 1024,
state: ctx.quotaState,
},
});
if (saved.ok) savedPaths.push(saved.relPath);
else texts.push(`(failed to save resource: ${saved.reason})`);
} else if (block.resource.uri) {
texts.push(`(resource uri reference: ${block.resource.uri} — not downloaded)`);
}
}
}
// NOTE: text blocks are concatenated with empty separator (per test contract);
// adjacent blocks are pieces of one response and should join naturally.
const output = [texts.join(''), ...savedPaths.map((p) => `Saved: ${p}`)]
.filter((s) => s.length > 0)
.join('\n');
const finalOutput = output || '(empty)';
const isError = resp.isError === true;
saveMcpRaw({
workspacePath: ctx.workspacePath,
serverId,
toolName,
args: input.input,
content,
isError,
output: finalOutput,
savedPaths,
});
return { output: finalOutput, isError };
} catch (err) {
const msg = (err as Error).message;
logger.warn(`[mcp:executor] callTool failed server=${serverId} tool=${toolName}: ${msg}`);
const failureOutput = `MCP call failed: ${msg}`;
saveMcpRaw({
workspacePath: ctx.workspacePath,
serverId,
toolName,
args: input.input,
content: [],
isError: true,
output: failureOutput,
savedPaths: [],
});
return { output: failureOutput, isError: true };
} finally {
clearTimeout(timer);
}
}
+90
View File
@@ -0,0 +1,90 @@
export type AuthKind = 'oauth' | 'api_key';
export interface McpServerRecord {
id: string;
name: string;
url: string;
authKind: AuthKind;
ownerId: string | null; // null = global/admin-managed
oauthClientId: string;
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)
issuer: string | null;
authorizationEndpoint: string | null;
tokenEndpoint: string | null;
discoveryFingerprint: string | null;
enabled: boolean;
createdBy: string | null;
createdAt: string;
updatedAt: string;
}
// Server record WITHOUT secrets for API responses
export type McpServerPublic = Omit<McpServerRecord, 'oauthClientSecret' | 'staticToken'>;
export interface UserMcpTokenRecord {
userId: string;
serverId: string;
accessToken: string; // decrypted
refreshToken: string | null;
expiresAt: string | null;
scope: string | null;
scopeType: 'user' | 'org';
scopeId: string | null;
connectedAt: string;
updatedAt: string;
}
export interface McpConnectionPublic {
serverId: string;
serverName: string;
connected: boolean;
connectedAt: string | null;
expiresAt: string | null;
}
export interface McpCachedTool {
serverId: string;
toolName: string;
description: string | null;
inputSchema: string | null; // JSON string
refreshedAt: string;
}
export interface McpDiscoveryMetadata {
issuer: string;
authorizationEndpoint: string;
tokenEndpoint: string;
fingerprint: string;
}
export interface TokenEndpointResponse {
access_token: string;
token_type?: string;
expires_in?: number;
refresh_token?: string;
scope?: string;
iss?: string;
}
export class McpNotConnectedError extends Error {
constructor(public readonly serverId: string) {
super(`User is not connected to MCP server '${serverId}'`);
this.name = 'McpNotConnectedError';
}
}
export class McpTokenExpiredError extends Error {
constructor(public readonly serverId: string) {
super(`Access token expired and no refresh token available for '${serverId}'`);
this.name = 'McpTokenExpiredError';
}
}
export class McpKeyNotConfiguredError extends Error {
constructor() {
super('MCP_ENCRYPTION_KEY is not configured; MCP features are disabled');
this.name = 'McpKeyNotConfiguredError';
}
}