feat: initial public release (MAESTRO)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 ?? {}) };
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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: '***',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -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()));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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())),
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -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: {} });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user