n2-qln 3.4.2 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +459 -470
- package/README.md +459 -490
- package/dist/index.d.ts +3 -0
- package/dist/index.js +87 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/config.d.ts +9 -0
- package/{lib → dist/lib}/config.js +23 -27
- package/dist/lib/config.js.map +1 -0
- package/dist/lib/embedding.d.ts +27 -0
- package/{lib → dist/lib}/embedding.js +39 -47
- package/dist/lib/embedding.js.map +1 -0
- package/dist/lib/executor.d.ts +57 -0
- package/dist/lib/executor.js +175 -0
- package/dist/lib/executor.js.map +1 -0
- package/dist/lib/mcp-discovery.d.ts +83 -0
- package/dist/lib/mcp-discovery.js +203 -0
- package/dist/lib/mcp-discovery.js.map +1 -0
- package/dist/lib/provider-loader.d.ts +13 -0
- package/dist/lib/provider-loader.js +146 -0
- package/dist/lib/provider-loader.js.map +1 -0
- package/dist/lib/registry.d.ts +38 -0
- package/{lib → dist/lib}/registry.js +82 -92
- package/dist/lib/registry.js.map +1 -0
- package/dist/lib/router.d.ts +63 -0
- package/{lib → dist/lib}/router.js +75 -117
- package/dist/lib/router.js.map +1 -0
- package/dist/lib/schema.d.ts +20 -0
- package/{lib → dist/lib}/schema.js +38 -30
- package/dist/lib/schema.js.map +1 -0
- package/dist/lib/store.d.ts +37 -0
- package/dist/lib/store.js +207 -0
- package/dist/lib/store.js.map +1 -0
- package/dist/lib/validator.d.ts +37 -0
- package/dist/lib/validator.js +114 -0
- package/dist/lib/validator.js.map +1 -0
- package/dist/lib/vector-index.d.ts +37 -0
- package/{lib → dist/lib}/vector-index.js +19 -36
- package/dist/lib/vector-index.js.map +1 -0
- package/dist/tools/qln-call.d.ts +41 -0
- package/dist/tools/qln-call.js +353 -0
- package/dist/tools/qln-call.js.map +1 -0
- package/dist/tools/qln-helpers.d.ts +55 -0
- package/dist/tools/qln-helpers.js +88 -0
- package/dist/tools/qln-helpers.js.map +1 -0
- package/dist/types.d.ts +243 -0
- package/dist/types.js +4 -0
- package/dist/types.js.map +1 -0
- package/index.js +3 -79
- package/package.json +11 -4
- package/.github/FUNDING.yml +0 -3
- package/docs/README.md +0 -2
- package/docs/architecture.png +0 -0
- package/lib/executor.js +0 -104
- package/lib/provider-loader.js +0 -126
- package/lib/store.js +0 -217
- package/lib/validator.js +0 -171
- package/tools/qln-call.js +0 -257
package/lib/store.js
DELETED
|
@@ -1,217 +0,0 @@
|
|
|
1
|
-
// QLN — SQLite storage engine (pure JS via sql.js WASM). No native dependencies.
|
|
2
|
-
const fs = require('fs');
|
|
3
|
-
const path = require('path');
|
|
4
|
-
|
|
5
|
-
/** @type {object|null} sql.js module singleton */
|
|
6
|
-
let _SQL = null;
|
|
7
|
-
/** @type {Promise<object>|null} sql.js init promise */
|
|
8
|
-
let _initPromise = null;
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Initialize sql.js WASM module (once per process).
|
|
12
|
-
* @returns {Promise<object>} sql.js module with Database constructor
|
|
13
|
-
*/
|
|
14
|
-
async function initSqlJs() {
|
|
15
|
-
if (_SQL) return _SQL;
|
|
16
|
-
if (_initPromise) return _initPromise;
|
|
17
|
-
_initPromise = (async () => {
|
|
18
|
-
const initFn = require('sql.js');
|
|
19
|
-
_SQL = await initFn();
|
|
20
|
-
return _SQL;
|
|
21
|
-
})();
|
|
22
|
-
return _initPromise;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* QLN SQLite store.
|
|
27
|
-
* Dedicated tool index DB — separated from Soul KV-Cache.
|
|
28
|
-
*
|
|
29
|
-
* File: {dataDir}/qln-tools.sqlite
|
|
30
|
-
*/
|
|
31
|
-
class Store {
|
|
32
|
-
/**
|
|
33
|
-
* @param {string} dataDir - Data directory path
|
|
34
|
-
*/
|
|
35
|
-
constructor(dataDir) {
|
|
36
|
-
this._dataDir = dataDir;
|
|
37
|
-
this._db = null;
|
|
38
|
-
this._dbPath = path.join(dataDir, 'qln-tools.sqlite');
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** Async init — load sql.js + open DB + create schema */
|
|
42
|
-
async init() {
|
|
43
|
-
await initSqlJs();
|
|
44
|
-
if (!fs.existsSync(this._dataDir)) {
|
|
45
|
-
fs.mkdirSync(this._dataDir, { recursive: true });
|
|
46
|
-
}
|
|
47
|
-
if (fs.existsSync(this._dbPath)) {
|
|
48
|
-
const buffer = fs.readFileSync(this._dbPath);
|
|
49
|
-
this._db = new _SQL.Database(buffer);
|
|
50
|
-
} else {
|
|
51
|
-
this._db = new _SQL.Database();
|
|
52
|
-
}
|
|
53
|
-
this._createSchema();
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** Create tools + providers table schema */
|
|
57
|
-
_createSchema() {
|
|
58
|
-
this._db.run(`
|
|
59
|
-
CREATE TABLE IF NOT EXISTS tools (
|
|
60
|
-
name TEXT PRIMARY KEY,
|
|
61
|
-
description TEXT DEFAULT '',
|
|
62
|
-
source TEXT DEFAULT 'unknown',
|
|
63
|
-
category TEXT DEFAULT 'misc',
|
|
64
|
-
provider TEXT DEFAULT '',
|
|
65
|
-
input_schema TEXT DEFAULT '{}',
|
|
66
|
-
triggers TEXT DEFAULT '[]',
|
|
67
|
-
tags TEXT DEFAULT '[]',
|
|
68
|
-
examples TEXT DEFAULT '[]',
|
|
69
|
-
endpoint TEXT DEFAULT '',
|
|
70
|
-
search_text TEXT DEFAULT '',
|
|
71
|
-
embedding TEXT DEFAULT '',
|
|
72
|
-
usage_count INTEGER DEFAULT 0,
|
|
73
|
-
success_rate REAL DEFAULT 1.0,
|
|
74
|
-
registered_at TEXT DEFAULT (datetime('now')),
|
|
75
|
-
updated_at TEXT DEFAULT (datetime('now'))
|
|
76
|
-
)
|
|
77
|
-
`);
|
|
78
|
-
this._db.run(`CREATE INDEX IF NOT EXISTS idx_tools_source ON tools(source)`);
|
|
79
|
-
this._db.run(`CREATE INDEX IF NOT EXISTS idx_tools_category ON tools(category)`);
|
|
80
|
-
|
|
81
|
-
this._db.run(`
|
|
82
|
-
CREATE TABLE IF NOT EXISTS providers (
|
|
83
|
-
name TEXT PRIMARY KEY,
|
|
84
|
-
version TEXT DEFAULT '1.0.0',
|
|
85
|
-
description TEXT DEFAULT '',
|
|
86
|
-
endpoint TEXT DEFAULT '',
|
|
87
|
-
tool_count INTEGER DEFAULT 0,
|
|
88
|
-
registered_at TEXT DEFAULT (datetime('now')),
|
|
89
|
-
updated_at TEXT DEFAULT (datetime('now'))
|
|
90
|
-
)
|
|
91
|
-
`);
|
|
92
|
-
|
|
93
|
-
// Safe migration: add new columns if missing (for existing DBs)
|
|
94
|
-
this._migrateSchema();
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** Schema migration — safe ADD COLUMN (ignores if already exists) */
|
|
98
|
-
_migrateSchema() {
|
|
99
|
-
const addCol = (table, col, type, dflt) => {
|
|
100
|
-
try {
|
|
101
|
-
this._db.run(`ALTER TABLE ${table} ADD COLUMN ${col} ${type} DEFAULT ${dflt}`);
|
|
102
|
-
} catch { /* column already exists */ }
|
|
103
|
-
};
|
|
104
|
-
addCol('tools', 'provider', 'TEXT', "''");
|
|
105
|
-
addCol('tools', 'examples', 'TEXT', "'[]'");
|
|
106
|
-
addCol('tools', 'endpoint', 'TEXT', "''");
|
|
107
|
-
addCol('tools', 'last_used_at', 'TEXT', 'NULL');
|
|
108
|
-
|
|
109
|
-
// Copy plugin_name → provider (if old schema has plugin_name)
|
|
110
|
-
try {
|
|
111
|
-
const cols = this._db.exec("PRAGMA table_info(tools)");
|
|
112
|
-
if (cols.length > 0) {
|
|
113
|
-
const colNames = cols[0].values.map(r => r[1]);
|
|
114
|
-
if (colNames.includes('plugin_name') && colNames.includes('provider')) {
|
|
115
|
-
this._db.run("UPDATE tools SET provider = plugin_name WHERE provider = '' AND plugin_name != ''");
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
} catch { /* table doesn't exist yet */ }
|
|
119
|
-
|
|
120
|
-
// Create provider index (safe)
|
|
121
|
-
try {
|
|
122
|
-
this._db.run(`CREATE INDEX IF NOT EXISTS idx_tools_provider ON tools(provider)`);
|
|
123
|
-
} catch { /* already exists */ }
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Upsert a tool entry.
|
|
128
|
-
* @param {object} entry - Normalized tool entry
|
|
129
|
-
*/
|
|
130
|
-
upsert(entry) {
|
|
131
|
-
this._db.run(`
|
|
132
|
-
INSERT INTO tools (name, description, source, category, provider,
|
|
133
|
-
input_schema, triggers, tags, examples, endpoint, search_text, embedding,
|
|
134
|
-
usage_count, success_rate, last_used_at, updated_at)
|
|
135
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
136
|
-
?, COALESCE(?, (SELECT embedding FROM tools WHERE name = ?)),
|
|
137
|
-
?, ?, ?, datetime('now'))
|
|
138
|
-
ON CONFLICT(name) DO UPDATE SET
|
|
139
|
-
description = excluded.description,
|
|
140
|
-
source = excluded.source,
|
|
141
|
-
category = excluded.category,
|
|
142
|
-
provider = excluded.provider,
|
|
143
|
-
input_schema = excluded.input_schema,
|
|
144
|
-
triggers = excluded.triggers,
|
|
145
|
-
tags = excluded.tags,
|
|
146
|
-
examples = excluded.examples,
|
|
147
|
-
endpoint = excluded.endpoint,
|
|
148
|
-
search_text = excluded.search_text,
|
|
149
|
-
embedding = COALESCE(excluded.embedding, tools.embedding),
|
|
150
|
-
usage_count = excluded.usage_count,
|
|
151
|
-
success_rate = excluded.success_rate,
|
|
152
|
-
last_used_at = excluded.last_used_at,
|
|
153
|
-
updated_at = excluded.updated_at
|
|
154
|
-
`, [
|
|
155
|
-
entry.name, entry.description, entry.source, entry.category, entry.provider,
|
|
156
|
-
JSON.stringify(entry.inputSchema), JSON.stringify(entry.triggers),
|
|
157
|
-
JSON.stringify(entry.tags), JSON.stringify(entry.examples), entry.endpoint || '',
|
|
158
|
-
entry.searchText,
|
|
159
|
-
entry.embedding ? JSON.stringify(entry.embedding) : null, entry.name,
|
|
160
|
-
entry.usageCount, entry.successRate, entry.lastUsedAt || null,
|
|
161
|
-
]);
|
|
162
|
-
this._persist();
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* Remove a tool by name.
|
|
167
|
-
* @param {string} name
|
|
168
|
-
* @returns {boolean}
|
|
169
|
-
*/
|
|
170
|
-
remove(name) {
|
|
171
|
-
this._db.run('DELETE FROM tools WHERE name = ?', [name]);
|
|
172
|
-
this._persist();
|
|
173
|
-
return true;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* Purge all tools by source (for re-sync).
|
|
178
|
-
* @param {string} source
|
|
179
|
-
*/
|
|
180
|
-
purgeBySource(source) {
|
|
181
|
-
this._db.run('DELETE FROM tools WHERE source = ?', [source]);
|
|
182
|
-
this._persist();
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Load all tools from DB.
|
|
187
|
-
* @returns {object[]} Array of SQLite rows
|
|
188
|
-
*/
|
|
189
|
-
loadAll() {
|
|
190
|
-
const results = this._db.exec('SELECT * FROM tools ORDER BY name');
|
|
191
|
-
if (results.length === 0) return [];
|
|
192
|
-
const cols = results[0].columns;
|
|
193
|
-
return results[0].values.map(row => {
|
|
194
|
-
const obj = {};
|
|
195
|
-
cols.forEach((c, i) => { obj[c] = row[i]; });
|
|
196
|
-
return obj;
|
|
197
|
-
});
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/** Persist DB to disk */
|
|
201
|
-
_persist() {
|
|
202
|
-
if (!this._db) return;
|
|
203
|
-
const data = this._db.export();
|
|
204
|
-
fs.writeFileSync(this._dbPath, Buffer.from(data));
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/** Close connection */
|
|
208
|
-
dispose() {
|
|
209
|
-
if (this._db) {
|
|
210
|
-
this._persist();
|
|
211
|
-
this._db.close();
|
|
212
|
-
this._db = null;
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
module.exports = { Store, initSqlJs };
|
package/lib/validator.js
DELETED
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
// QLN — Tool registration validator (validator.rs pattern)
|
|
2
|
-
// Enforced validation: errors reject registration, warnings pass with message.
|
|
3
|
-
|
|
4
|
-
/** Valid tool categories */
|
|
5
|
-
const VALID_CATEGORIES = ['web', 'data', 'file', 'dev', 'ai', 'capture', 'misc'];
|
|
6
|
-
|
|
7
|
-
/** Tool name pattern: verb_target (e.g. read_pdf, take_screenshot) */
|
|
8
|
-
const NAME_PATTERN = /^[a-z]+_[a-z][a-z0-9_]*$/;
|
|
9
|
-
|
|
10
|
-
/** Minimum description length */
|
|
11
|
-
const MIN_DESCRIPTION_LENGTH = 10;
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Validate tool entry for registration.
|
|
15
|
-
* Pattern: accumulate errors → reject if any error exists.
|
|
16
|
-
*
|
|
17
|
-
* @param {object} params - Tool creation params
|
|
18
|
-
* @param {import('./registry').Registry} registry - For duplicate check
|
|
19
|
-
* @returns {{ valid: boolean, errors: ValidationError[] }}
|
|
20
|
-
*/
|
|
21
|
-
function validateToolEntry(params, registry) {
|
|
22
|
-
/** @type {ValidationError[]} */
|
|
23
|
-
const errors = [];
|
|
24
|
-
|
|
25
|
-
// Rule 1: name required + verb_target format enforced
|
|
26
|
-
if (!params.name || params.name.trim() === '') {
|
|
27
|
-
errors.push({
|
|
28
|
-
field: 'name',
|
|
29
|
-
message: 'name은 필수입니다',
|
|
30
|
-
severity: 'error',
|
|
31
|
-
});
|
|
32
|
-
} else if (!NAME_PATTERN.test(params.name)) {
|
|
33
|
-
errors.push({
|
|
34
|
-
field: 'name',
|
|
35
|
-
message: `'${params.name}' → 동사_대상 형식 필수 (예: read_pdf, take_screenshot)`,
|
|
36
|
-
severity: 'error',
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// Rule 2: description required (= first-line comment) + min 10 chars
|
|
41
|
-
if (!params.description || params.description.trim() === '') {
|
|
42
|
-
errors.push({
|
|
43
|
-
field: 'description',
|
|
44
|
-
message: 'description은 필수입니다 (도구 용도 설명)',
|
|
45
|
-
severity: 'error',
|
|
46
|
-
});
|
|
47
|
-
} else if (params.description.trim().length < MIN_DESCRIPTION_LENGTH) {
|
|
48
|
-
errors.push({
|
|
49
|
-
field: 'description',
|
|
50
|
-
message: `최소 ${MIN_DESCRIPTION_LENGTH}자 이상 설명 필요 (현재: ${params.description.trim().length}자)`,
|
|
51
|
-
severity: 'error',
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// Rule 3: category enum enforced
|
|
56
|
-
if (params.category && !VALID_CATEGORIES.includes(params.category)) {
|
|
57
|
-
errors.push({
|
|
58
|
-
field: 'category',
|
|
59
|
-
message: `'${params.category}' → ${VALID_CATEGORIES.join('|')} 중 하나`,
|
|
60
|
-
severity: 'error',
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// Rule 4: duplicate name check
|
|
65
|
-
if (params.name && registry && registry.get(params.name)) {
|
|
66
|
-
errors.push({
|
|
67
|
-
field: 'name',
|
|
68
|
-
message: `'${params.name}' 이미 존재합니다. action: "update" 를 사용하세요`,
|
|
69
|
-
severity: 'error',
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// Warning: no tags (search accuracy may be lower)
|
|
74
|
-
if (!params.tags || params.tags.length === 0) {
|
|
75
|
-
errors.push({
|
|
76
|
-
field: 'tags',
|
|
77
|
-
message: 'tags 미지정 — 검색 정확도가 낮아질 수 있습니다',
|
|
78
|
-
severity: 'warning',
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const errorCount = errors.filter(e => e.severity === 'error').length;
|
|
83
|
-
return { valid: errorCount === 0, errors };
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Validate tool update params.
|
|
88
|
-
* Same rules as create, but no duplicate check (updating existing tool).
|
|
89
|
-
*
|
|
90
|
-
* @param {object} params - Update params (only changed fields)
|
|
91
|
-
* @param {object} existing - Current tool entry from registry
|
|
92
|
-
* @returns {{ valid: boolean, errors: ValidationError[] }}
|
|
93
|
-
*/
|
|
94
|
-
function validateUpdateEntry(params, existing) {
|
|
95
|
-
/** @type {ValidationError[]} */
|
|
96
|
-
const errors = [];
|
|
97
|
-
|
|
98
|
-
// Merge: use existing values as fallback
|
|
99
|
-
const merged = {
|
|
100
|
-
name: params.name || existing.name,
|
|
101
|
-
description: params.description || existing.description,
|
|
102
|
-
category: params.category || existing.category,
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
// Rule 1: name format (if changed)
|
|
106
|
-
if (params.name && !NAME_PATTERN.test(params.name)) {
|
|
107
|
-
errors.push({
|
|
108
|
-
field: 'name',
|
|
109
|
-
message: `'${params.name}' → 동사_대상 형식 필수 (예: read_pdf, take_screenshot)`,
|
|
110
|
-
severity: 'error',
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// Rule 2: description min length (if changed)
|
|
115
|
-
if (params.description && params.description.trim().length < MIN_DESCRIPTION_LENGTH) {
|
|
116
|
-
errors.push({
|
|
117
|
-
field: 'description',
|
|
118
|
-
message: `최소 ${MIN_DESCRIPTION_LENGTH}자 이상 설명 필요 (현재: ${params.description.trim().length}자)`,
|
|
119
|
-
severity: 'error',
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Rule 3: category enum (if changed)
|
|
124
|
-
if (params.category && !VALID_CATEGORIES.includes(params.category)) {
|
|
125
|
-
errors.push({
|
|
126
|
-
field: 'category',
|
|
127
|
-
message: `'${params.category}' → ${VALID_CATEGORIES.join('|')} 중 하나`,
|
|
128
|
-
severity: 'error',
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const errorCount = errors.filter(e => e.severity === 'error').length;
|
|
133
|
-
return { valid: errorCount === 0, errors };
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Format validation errors as user-readable string.
|
|
138
|
-
* @param {ValidationError[]} errors
|
|
139
|
-
* @returns {string}
|
|
140
|
-
*/
|
|
141
|
-
function formatValidationErrors(errors) {
|
|
142
|
-
const errorCount = errors.filter(e => e.severity === 'error').length;
|
|
143
|
-
const warnCount = errors.filter(e => e.severity === 'warning').length;
|
|
144
|
-
|
|
145
|
-
const lines = errors.map(e => {
|
|
146
|
-
const icon = e.severity === 'error' ? '❌' : '⚠️';
|
|
147
|
-
return ` ${icon} [${e.field}] ${e.message}`;
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
const header = errorCount > 0
|
|
151
|
-
? `등록 거부 (${errorCount} error${errorCount > 1 ? 's' : ''}${warnCount > 0 ? `, ${warnCount} warning` : ''}):`
|
|
152
|
-
: `등록 완료 (${warnCount} warning${warnCount > 1 ? 's' : ''}):`;
|
|
153
|
-
|
|
154
|
-
return `${header}\n${lines.join('\n')}`;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* @typedef {object} ValidationError
|
|
159
|
-
* @property {string} field - Field name
|
|
160
|
-
* @property {string} message - Error message
|
|
161
|
-
* @property {'error'|'warning'} severity - Severity level
|
|
162
|
-
*/
|
|
163
|
-
|
|
164
|
-
module.exports = {
|
|
165
|
-
validateToolEntry,
|
|
166
|
-
validateUpdateEntry,
|
|
167
|
-
formatValidationErrors,
|
|
168
|
-
VALID_CATEGORIES,
|
|
169
|
-
NAME_PATTERN,
|
|
170
|
-
MIN_DESCRIPTION_LENGTH,
|
|
171
|
-
};
|
package/tools/qln-call.js
DELETED
|
@@ -1,257 +0,0 @@
|
|
|
1
|
-
// QLN MCP tool — n2_qln_call (unified tool: search/exec/create/update/delete)
|
|
2
|
-
// Single entry point for all QLN operations. ~200 tokens in AI context.
|
|
3
|
-
const { validateToolEntry, validateUpdateEntry, formatValidationErrors } = require('../lib/validator');
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Register the unified n2_qln_call MCP tool.
|
|
7
|
-
* @param {object} server - MCP server
|
|
8
|
-
* @param {object} z - Zod validator
|
|
9
|
-
* @param {import('../lib/router').Router} router - L1 search engine
|
|
10
|
-
* @param {import('../lib/executor').Executor} executor - L3 tool executor
|
|
11
|
-
* @param {import('../lib/registry').Registry} registry - L2 tool index
|
|
12
|
-
*/
|
|
13
|
-
function registerQlnCall(server, z, router, executor, registry) {
|
|
14
|
-
server.tool(
|
|
15
|
-
'n2_qln_call',
|
|
16
|
-
{
|
|
17
|
-
title: 'QLN Call',
|
|
18
|
-
description:
|
|
19
|
-
'Query Layer Network — unified tool dispatcher. ' +
|
|
20
|
-
'Search 1000+ tools, execute them, or manage the index. ' +
|
|
21
|
-
'Actions: search, exec, create, update, delete.',
|
|
22
|
-
inputSchema: {
|
|
23
|
-
action: z.enum(['search', 'exec', 'create', 'update', 'delete'])
|
|
24
|
-
.describe('Action: search | exec | create | update | delete'),
|
|
25
|
-
// search
|
|
26
|
-
query: z.string().optional()
|
|
27
|
-
.describe('[search] Natural language query (e.g. "take a screenshot")'),
|
|
28
|
-
topK: z.number().optional()
|
|
29
|
-
.describe('[search] Number of results (default: 5, max: 20)'),
|
|
30
|
-
// exec
|
|
31
|
-
tool: z.string().optional()
|
|
32
|
-
.describe('[exec/update/delete] Tool name'),
|
|
33
|
-
args: z.record(z.unknown()).optional()
|
|
34
|
-
.describe('[exec] Tool arguments (JSON object)'),
|
|
35
|
-
// create / update
|
|
36
|
-
name: z.string().optional()
|
|
37
|
-
.describe('[create/update] Tool name (unique identifier)'),
|
|
38
|
-
description: z.string().optional()
|
|
39
|
-
.describe('[create/update] Tool description (used for search matching)'),
|
|
40
|
-
source: z.string().optional()
|
|
41
|
-
.describe('[create/update] Source: mcp, plugin, local (default: local)'),
|
|
42
|
-
category: z.string().optional()
|
|
43
|
-
.describe('[create/update] Category: web, data, file, dev, ai, capture, misc'),
|
|
44
|
-
toolSchema: z.record(z.unknown()).optional()
|
|
45
|
-
.describe('[create/update] JSON Schema for tool input'),
|
|
46
|
-
tags: z.array(z.string()).optional()
|
|
47
|
-
.describe('[create/update] Additional search tags'),
|
|
48
|
-
provider: z.string().optional()
|
|
49
|
-
.describe('[create/update] Provider name (e.g. "n2-browser", "pdf-tools")'),
|
|
50
|
-
examples: z.array(z.string()).optional()
|
|
51
|
-
.describe('[create/update] Usage examples (e.g. ["read this PDF file", "extract text from PDF"])'),
|
|
52
|
-
endpoint: z.string().optional()
|
|
53
|
-
.describe('[create/update] Execution HTTP endpoint'),
|
|
54
|
-
},
|
|
55
|
-
},
|
|
56
|
-
async (params) => {
|
|
57
|
-
try {
|
|
58
|
-
switch (params.action) {
|
|
59
|
-
case 'search': return await _handleSearch(router, registry, params);
|
|
60
|
-
case 'exec': return await _handleExec(executor, registry, params);
|
|
61
|
-
case 'create': return await _handleCreate(registry, router, params);
|
|
62
|
-
case 'update': return await _handleUpdate(registry, router, params);
|
|
63
|
-
case 'delete': return await _handleDelete(registry, router, params);
|
|
64
|
-
default:
|
|
65
|
-
return _error(`Unknown action: ${params.action}`);
|
|
66
|
-
}
|
|
67
|
-
} catch (err) {
|
|
68
|
-
return _error(`QLN internal error: ${err.message}. Try the action again or use raw tool calls as fallback.`);
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// ── Action Handlers ──
|
|
75
|
-
|
|
76
|
-
/** Search tools by natural language query */
|
|
77
|
-
async function _handleSearch(router, registry, { query, topK }) {
|
|
78
|
-
if (!query) return _error('Missing required param: query');
|
|
79
|
-
try {
|
|
80
|
-
const k = Math.min(topK || 5, 20);
|
|
81
|
-
const { results, timing } = await router.route(query, { topK: k });
|
|
82
|
-
|
|
83
|
-
if (results.length === 0) {
|
|
84
|
-
// Blind Spot prevention: show available categories so AI doesn't hallucinate
|
|
85
|
-
const stats = registry.stats();
|
|
86
|
-
const categories = Object.entries(stats.byCategory)
|
|
87
|
-
.map(([cat, count]) => `${cat}(${count})`)
|
|
88
|
-
.join(', ');
|
|
89
|
-
return _text(
|
|
90
|
-
`No tools found for: "${query}" (${timing.total}ms)\n\n` +
|
|
91
|
-
`Available categories [${stats.total} tools]: ${categories || 'none'}\n` +
|
|
92
|
-
`→ Try a different keyword, or check available categories above.`
|
|
93
|
-
);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const lines = results.map((r, i) => {
|
|
97
|
-
const schemaHint = r.inputSchema
|
|
98
|
-
? ` | args: ${JSON.stringify(Object.keys(r.inputSchema.properties || r.inputSchema || {}))}`
|
|
99
|
-
: '';
|
|
100
|
-
return `${i + 1}. **${r.name}** (${r.score}) [${r.source}/${r.category}]${schemaHint}\n ${r.description || '(no description)'}`;
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
const top = results[0];
|
|
104
|
-
const hint = `\n→ Execute: n2_qln_call(action: "exec", tool: "${top.name}", args: {})`;
|
|
105
|
-
|
|
106
|
-
return _text(
|
|
107
|
-
`Route "${query}" (${timing.total}ms, stages: T${timing.stage1}+K${timing.stage2}+S${timing.stage3}ms):\n\n` +
|
|
108
|
-
`${lines.join('\n\n')}${hint}`
|
|
109
|
-
);
|
|
110
|
-
} catch (err) {
|
|
111
|
-
return _error(`Search failed: ${err.message}`);
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/** Execute a tool by name */
|
|
116
|
-
async function _handleExec(executor, registry, { tool: toolName, args }) {
|
|
117
|
-
if (!toolName) return _error('Missing required param: tool');
|
|
118
|
-
try {
|
|
119
|
-
const { result, source, elapsed } = await executor.exec(toolName, args || {});
|
|
120
|
-
registry.recordUsage(toolName, true);
|
|
121
|
-
|
|
122
|
-
const resultStr = typeof result === 'string'
|
|
123
|
-
? result
|
|
124
|
-
: JSON.stringify(result, null, 2);
|
|
125
|
-
|
|
126
|
-
const truncated = resultStr.length > 4000
|
|
127
|
-
? resultStr.substring(0, 4000) + '\n... (truncated)'
|
|
128
|
-
: resultStr;
|
|
129
|
-
|
|
130
|
-
return _text(`✅ [${toolName}] (${source}, ${elapsed}ms):\n${truncated}`);
|
|
131
|
-
} catch (err) {
|
|
132
|
-
registry.recordUsage(toolName, false);
|
|
133
|
-
return { content: [{ type: 'text', text: `❌ [${toolName}] failed: ${err.message}` }], isError: true };
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/** Create (register) a new tool — with enforced validation */
|
|
138
|
-
async function _handleCreate(registry, router, params) {
|
|
139
|
-
// Step 1: Forced validation (validator.rs pattern)
|
|
140
|
-
const validation = validateToolEntry(params, registry);
|
|
141
|
-
if (!validation.valid) {
|
|
142
|
-
return { content: [{ type: 'text', text: formatValidationErrors(validation.errors) }], isError: true };
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
try {
|
|
146
|
-
// Step 2: Register with auto-enrichment
|
|
147
|
-
const entry = registry.register({
|
|
148
|
-
name: params.name,
|
|
149
|
-
description: params.description,
|
|
150
|
-
source: params.source || 'local',
|
|
151
|
-
category: params.category || undefined,
|
|
152
|
-
provider: params.provider || '',
|
|
153
|
-
inputSchema: params.toolSchema,
|
|
154
|
-
tags: params.tags,
|
|
155
|
-
examples: params.examples || [],
|
|
156
|
-
endpoint: params.endpoint || '',
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
// Step 3: Rebuild vector index
|
|
160
|
-
const indexResult = router.buildIndex();
|
|
161
|
-
|
|
162
|
-
// Step 4: Return with warnings if any
|
|
163
|
-
const warnings = validation.errors.filter(e => e.severity === 'warning');
|
|
164
|
-
const warnMsg = warnings.length > 0
|
|
165
|
-
? `\n${formatValidationErrors(warnings)}`
|
|
166
|
-
: '';
|
|
167
|
-
|
|
168
|
-
return _text(
|
|
169
|
-
`✅ Created: ${entry.name} [${entry.source}/${entry.category}]\n` +
|
|
170
|
-
`Provider: ${entry.provider || '(none)'}\n` +
|
|
171
|
-
`Triggers: ${entry.triggers.join(', ')}\n` +
|
|
172
|
-
`Examples: ${entry.examples.length}\n` +
|
|
173
|
-
`Index: ${indexResult.indexed} tools, ${indexResult.categories} categories${warnMsg}`
|
|
174
|
-
);
|
|
175
|
-
} catch (err) {
|
|
176
|
-
return _error(`Create failed: ${err.message}`);
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/** Update an existing tool — with validation */
|
|
181
|
-
async function _handleUpdate(registry, router, params) {
|
|
182
|
-
const toolName = params.tool || params.name;
|
|
183
|
-
if (!toolName) return _error('Missing required param: tool (or name)');
|
|
184
|
-
|
|
185
|
-
const existing = registry.get(toolName);
|
|
186
|
-
if (!existing) return _error(`Tool not found: ${toolName}`);
|
|
187
|
-
|
|
188
|
-
// Validate changed fields
|
|
189
|
-
const validation = validateUpdateEntry(params, existing);
|
|
190
|
-
if (!validation.valid) {
|
|
191
|
-
return { content: [{ type: 'text', text: formatValidationErrors(validation.errors) }], isError: true };
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
try {
|
|
195
|
-
const entry = registry.register({
|
|
196
|
-
name: toolName,
|
|
197
|
-
description: params.description || existing.description,
|
|
198
|
-
source: params.source || existing.source,
|
|
199
|
-
category: params.category || existing.category,
|
|
200
|
-
provider: params.provider || existing.provider,
|
|
201
|
-
inputSchema: params.toolSchema || existing.inputSchema,
|
|
202
|
-
tags: params.tags || existing.tags,
|
|
203
|
-
examples: params.examples || existing.examples,
|
|
204
|
-
endpoint: params.endpoint || existing.endpoint,
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
const indexResult = router.buildIndex();
|
|
208
|
-
return _text(
|
|
209
|
-
`✅ Updated: ${entry.name} [${entry.source}/${entry.category}]\n` +
|
|
210
|
-
`Provider: ${entry.provider || '(none)'}\n` +
|
|
211
|
-
`Triggers: ${entry.triggers.join(', ')}\n` +
|
|
212
|
-
`Index: ${indexResult.indexed} tools, ${indexResult.categories} categories`
|
|
213
|
-
);
|
|
214
|
-
} catch (err) {
|
|
215
|
-
return _error(`Update failed: ${err.message}`);
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/** Delete tool(s) — by name or by provider */
|
|
220
|
-
async function _handleDelete(registry, router, params) {
|
|
221
|
-
const toolName = params.tool || params.name;
|
|
222
|
-
|
|
223
|
-
// Provider-level delete: remove all tools of a provider
|
|
224
|
-
if (params.provider && !toolName) {
|
|
225
|
-
const count = registry.removeByProvider(params.provider);
|
|
226
|
-
if (count === 0) return _error(`No tools found for provider: ${params.provider}`);
|
|
227
|
-
const indexResult = router.buildIndex();
|
|
228
|
-
return _text(
|
|
229
|
-
`✅ Deleted ${count} tools from provider: ${params.provider}\n` +
|
|
230
|
-
`Index: ${indexResult.indexed} tools, ${indexResult.categories} categories`
|
|
231
|
-
);
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// Single tool delete
|
|
235
|
-
if (!toolName) return _error('Missing required param: tool (or name). For bulk delete, use provider param.');
|
|
236
|
-
|
|
237
|
-
const removed = registry.remove(toolName);
|
|
238
|
-
if (!removed) return _error(`Tool not found: ${toolName}`);
|
|
239
|
-
|
|
240
|
-
const indexResult = router.buildIndex();
|
|
241
|
-
return _text(
|
|
242
|
-
`✅ Deleted: ${toolName}\n` +
|
|
243
|
-
`Index: ${indexResult.indexed} tools, ${indexResult.categories} categories`
|
|
244
|
-
);
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
// ── Response Helpers ──
|
|
248
|
-
|
|
249
|
-
function _text(text) {
|
|
250
|
-
return { content: [{ type: 'text', text }] };
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
function _error(message) {
|
|
254
|
-
return { content: [{ type: 'text', text: `⚠️ ${message}` }], isError: true };
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
module.exports = { registerQlnCall };
|