sync: update from private repo (8ee7d54)
Some checks failed
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync 2026-06-09 14:38:42 +00:00
parent b3747add6b
commit 5c3d7bb5c4
5 changed files with 76 additions and 6 deletions

View File

@ -4,6 +4,8 @@ import { randomBytes } from 'node:crypto';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
const SLUG = /^[a-z0-9_-]{1,64}$/; const SLUG = /^[a-z0-9_-]{1,64}$/;
// Tool names from remote MCP servers may be camelCase (allow uppercase).
const TOOL_NAME = /^[a-zA-Z0-9_-]{1,64}$/;
const MIME_TO_EXT: Record<string, string> = { const MIME_TO_EXT: Record<string, string> = {
'image/png': 'png', 'image/png': 'png',
@ -62,7 +64,7 @@ export type SaveBinaryResult =
export async function saveBinary(input: SaveBinaryInput): Promise<SaveBinaryResult> { export async function saveBinary(input: SaveBinaryInput): Promise<SaveBinaryResult> {
if (!SLUG.test(input.serverId)) return { ok: false, reason: 'invalid serverId slug' }; 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 (!TOOL_NAME.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 (input.bytes.length > input.maxBytes) return { ok: false, reason: `binary exceeds max ${input.maxBytes}B` };
if (!magicMatches(input.bytes, input.mimeType)) { if (!magicMatches(input.bytes, input.mimeType)) {
return { ok: false, reason: `magic bytes do not match mimeType ${input.mimeType}` }; return { ok: false, reason: `magic bytes do not match mimeType ${input.mimeType}` };

View File

@ -32,6 +32,12 @@ describe('normalizeToolName / parseToolName', () => {
it('rejects slug violations', () => { it('rejects slug violations', () => {
expect(parseToolName('mcp__BAD__ok')).toBeNull(); expect(parseToolName('mcp__BAD__ok')).toBeNull();
}); });
it('accepts camelCase tool names (remote MCP servers, e.g. NocoDB)', () => {
expect(parseToolName('mcp__nocodb__createRecords')).toEqual({
serverId: 'nocodb',
toolName: 'createRecords',
});
});
}); });
describe('buildToolDefsFromCache', () => { describe('buildToolDefsFromCache', () => {
@ -57,4 +63,17 @@ describe('buildToolDefsFromCache', () => {
const defs = buildToolDefsFromCache(cache, ['mcp__canva__export_design'], serverNames); const defs = buildToolDefsFromCache(cache, ['mcp__canva__export_design'], serverNames);
expect(defs[0].function.parameters).toEqual({ type: 'object', properties: {} }); expect(defs[0].function.parameters).toEqual({ type: 'object', properties: {} });
}); });
it('includes camelCase tool names (does not drop NocoDB-style tools)', () => {
const nocodb = [
{ serverId: 'nocodb', toolName: 'createRecords', description: 'c', inputSchema: null },
{ serverId: 'nocodb', toolName: 'updateRecords', description: 'u', inputSchema: null },
{ serverId: 'nocodb', toolName: 'aggregate', description: 'a', inputSchema: null },
];
const defs = buildToolDefsFromCache(nocodb, ['mcp__nocodb__*'], new Map([['nocodb', 'NocoDB']]));
expect(defs.map((d) => d.function.name)).toEqual([
'mcp__nocodb__createRecords',
'mcp__nocodb__updateRecords',
'mcp__nocodb__aggregate',
]);
});
}); });

View File

@ -1,6 +1,11 @@
import type { ToolDef } from '../llm/openai-compat.js'; import type { ToolDef } from '../llm/openai-compat.js';
// serverId is our own slug (lowercase). Tool names come from the remote MCP
// server and commonly use camelCase (e.g. NocoDB's `createRecords`), so they
// must allow uppercase — the combined `mcp__id__name` still matches the LLM
// tool-name grammar `[a-zA-Z0-9_-]`.
const SLUG = /^[a-z0-9_-]{1,64}$/; const SLUG = /^[a-z0-9_-]{1,64}$/;
const TOOL_NAME = /^[a-zA-Z0-9_-]{1,64}$/;
export function normalizeToolName(serverId: string, toolName: string): string { export function normalizeToolName(serverId: string, toolName: string): string {
return `mcp__${serverId}__${toolName}`; return `mcp__${serverId}__${toolName}`;
@ -11,7 +16,7 @@ export function parseToolName(name: string): { serverId: string; toolName: strin
const parts = name.split('__'); const parts = name.split('__');
if (parts.length !== 3) return null; if (parts.length !== 3) return null;
const [, serverId, toolName] = parts; const [, serverId, toolName] = parts;
if (!SLUG.test(serverId) || !SLUG.test(toolName)) return null; if (!SLUG.test(serverId) || !TOOL_NAME.test(toolName)) return null;
return { serverId, toolName }; return { serverId, toolName };
} }
@ -45,7 +50,7 @@ export function buildToolDefsFromCache(
const defs: ToolDef[] = []; const defs: ToolDef[] = [];
for (const tool of cache) { for (const tool of cache) {
if (!SLUG.test(tool.serverId) || !SLUG.test(tool.toolName)) continue; if (!SLUG.test(tool.serverId) || !TOOL_NAME.test(tool.toolName)) continue;
const canonical = normalizeToolName(tool.serverId, tool.toolName); const canonical = normalizeToolName(tool.serverId, tool.toolName);
if (!matchesAnyPattern(canonical, mcpPatterns)) continue; if (!matchesAnyPattern(canonical, mcpPatterns)) continue;

View File

@ -0,0 +1,41 @@
import { describe, it, expect, afterEach } from 'vitest';
import Database from 'better-sqlite3';
import { runMigrations } from '../db/migrate.js';
import { createToolCache } from './tool-cache.js';
describe('toolCache.replaceForServer', () => {
let db: Database.Database;
afterEach(() => db?.close());
function makeDb(): Database.Database {
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);
return db;
}
it('keeps camelCase tool names (NocoDB regression: previously only "aggregate" survived)', () => {
const cache = createToolCache(makeDb(), 600);
cache.replaceForServer('nocodb', [
{ name: 'getTablesList' },
{ name: 'queryRecords' },
{ name: 'createRecords' },
{ name: 'updateRecords' },
{ name: 'deleteRecords' },
{ name: 'aggregate' },
]);
const names = cache.getForServer('nocodb').map((t) => t.toolName).sort();
expect(names).toEqual([
'aggregate', 'createRecords', 'deleteRecords', 'getTablesList', 'queryRecords', 'updateRecords',
]);
});
it('still skips names with illegal characters', () => {
const cache = createToolCache(makeDb(), 600);
cache.replaceForServer('s', [{ name: 'ok_Tool-1' }, { name: 'bad name!' }, { name: 'has.dot' }]);
const names = cache.getForServer('s').map((t) => t.toolName);
expect(names).toEqual(['ok_Tool-1']);
});
});

View File

@ -2,7 +2,10 @@ import type Database from 'better-sqlite3';
import type { CachedTool } from './tool-adapter.js'; import type { CachedTool } from './tool-adapter.js';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
const SLUG = /^[a-z0-9_-]{1,64}$/; // Remote MCP tool names commonly use camelCase (e.g. NocoDB's `createRecords`),
// so allow uppercase. Only the combined `mcp__id__name` must satisfy the LLM
// tool-name grammar, which also permits `[a-zA-Z0-9_-]`.
const TOOL_NAME = /^[a-zA-Z0-9_-]{1,64}$/;
interface Row { interface Row {
server_id: string; server_id: string;
@ -63,8 +66,8 @@ export function createToolCache(db: Database.Database, ttlSeconds: number) {
VALUES (?, ?, ?, ?, datetime('now'))`, VALUES (?, ?, ?, ?, datetime('now'))`,
); );
for (const t of tools) { for (const t of tools) {
if (!SLUG.test(t.name)) { if (!TOOL_NAME.test(t.name)) {
logger.warn(`[mcp:cache] skip tool with invalid slug: ${t.name}`); logger.warn(`[mcp:cache] skip tool with invalid name: ${t.name}`);
continue; continue;
} }
insert.run( insert.run(