tinywebui 0.1.1 → 0.1.2
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.md +50 -519
- package/bin/tinywebui.js +16 -7
- package/package.json +3 -3
- package/public/chat/attachments.js +5 -1
- package/public/chat/composer.js +16 -6
- package/public/core/access.js +5 -0
- package/public/index.html +53 -0
- package/public/panels/admin.js +42 -0
- package/public/panels/connectors.js +184 -0
- package/public/panels/settings.js +68 -4
- package/public/styles.css +16 -0
- package/src/access/policy.js +23 -3
- package/src/chat/runs.js +7 -2
- package/src/cli.js +14 -3
- package/src/config/approval.js +9 -2
- package/src/config/config.js +55 -12
- package/src/config/models.js +43 -2
- package/src/mcp.js +34 -6
- package/src/routes/admin.js +38 -1
- package/src/routes/auth.js +15 -3
- package/src/routes/chats.js +3 -3
- package/src/routes/settings.js +34 -3
- package/src/server.js +70 -9
- package/src/set-password.js +18 -7
- package/src/store/schema.js +1 -1
- package/src/tools/mcp_tool.js +311 -0
package/src/routes/settings.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { json, readJson } from '../http.js';
|
|
2
|
-
import { isClosed, enabledEntries, publicEntry } from '../config/models.js';
|
|
3
|
-
import { modelsFor } from '../access/policy.js';
|
|
2
|
+
import { isClosed, enabledEntries, publicEntry, findConnector } from '../config/models.js';
|
|
3
|
+
import { modelsFor, SECRET_PATCH_KEYS } from '../access/policy.js';
|
|
4
4
|
import { audit } from '../audit.js';
|
|
5
5
|
import { setOverride } from '../config/approval.js';
|
|
6
6
|
import { actor } from '../access/auth_gate.js';
|
|
@@ -50,7 +50,12 @@ export function settingsRoutes({ config, configFor, hub, saveConfig, toolsView }
|
|
|
50
50
|
}) },
|
|
51
51
|
|
|
52
52
|
{ method: 'POST', path: /^\/api\/config$/, feature: FEATURE.SETTINGS, handle: async ({ req, res, auth }) => {
|
|
53
|
-
|
|
53
|
+
const patch = await readJson(req);
|
|
54
|
+
// Credentials and routing: the sole user or an admin, not any role that
|
|
55
|
+
// was merely granted the settings feature.
|
|
56
|
+
const secret = Object.keys(patch).filter((k) => SECRET_PATCH_KEYS.has(k));
|
|
57
|
+
if (secret.length && !auth.isAdmin) return json(res, 403, { error: `only an admin can change: ${secret.join(', ')}` });
|
|
58
|
+
saveConfig(patch, actor(auth));
|
|
54
59
|
audit('admin.config_changed', { by: auth.userId ?? null });
|
|
55
60
|
return json(res, 200, configFor(auth));
|
|
56
61
|
} },
|
|
@@ -74,6 +79,32 @@ export function settingsRoutes({ config, configFor, hub, saveConfig, toolsView }
|
|
|
74
79
|
return json(res, 200, { supported: true, restricted: true, models: allowed.map((id) => byId.get(id) || { id, name: null }) });
|
|
75
80
|
} },
|
|
76
81
|
|
|
82
|
+
// "Test connection" for the connector editor. A key typed into the form is
|
|
83
|
+
// used as is; a blank one means the stored key of that connector ('default'
|
|
84
|
+
// is the top-level baseUrl/apiKey), since the page never holds keys.
|
|
85
|
+
{ method: 'POST', path: /^\/api\/connectors\/test$/, feature: FEATURE.SETTINGS, handle: async ({ req, res, auth }) => {
|
|
86
|
+
if (!auth.isAdmin) return json(res, 403, { error: 'only an admin can test connectors' });
|
|
87
|
+
const body = await readJson(req);
|
|
88
|
+
const cfg = config();
|
|
89
|
+
const stored = body.id && body.id !== 'default' ? findConnector(cfg, body.id) : { baseUrl: cfg.baseUrl, apiKey: cfg.apiKey };
|
|
90
|
+
const baseUrl = String(body.baseUrl || stored?.baseUrl || '').replace(/\/+$/, '');
|
|
91
|
+
const apiKey = body.apiKey || stored?.apiKey || '';
|
|
92
|
+
if (!/^https?:\/\//.test(baseUrl)) return json(res, 400, { error: 'baseUrl must be an http(s) URL' });
|
|
93
|
+
try {
|
|
94
|
+
const r = await fetch(`${baseUrl}/models`, {
|
|
95
|
+
headers: apiKey ? { authorization: `Bearer ${apiKey}` } : {},
|
|
96
|
+
signal: AbortSignal.timeout(8000)
|
|
97
|
+
});
|
|
98
|
+
if (r.status === 401 || r.status === 403) return json(res, 200, { ok: false, error: `${r.status}: the endpoint rejected the key` });
|
|
99
|
+
// Not every OpenAI-compatible server lists models; reachable is the point.
|
|
100
|
+
const list = r.ok ? await r.json().catch(() => null) : null;
|
|
101
|
+
const rows = Array.isArray(list?.data) ? list.data : Array.isArray(list?.models) ? list.models : null;
|
|
102
|
+
return json(res, 200, { ok: r.status < 500, status: r.status, models: rows ? rows.length : null });
|
|
103
|
+
} catch (err) {
|
|
104
|
+
return json(res, 200, { ok: false, error: err.message });
|
|
105
|
+
}
|
|
106
|
+
} },
|
|
107
|
+
|
|
77
108
|
// The grouped view behind the tools panel: built-ins, and every MCP
|
|
78
109
|
// server's tools under it with that server's connection health.
|
|
79
110
|
{ method: 'GET', path: /^\/api\/tools$/, feature: FEATURE.TOOLS, handle: ({ res }) => json(res, 200, toolsView()) },
|
package/src/server.js
CHANGED
|
@@ -3,11 +3,11 @@ import { readFile } from 'node:fs/promises';
|
|
|
3
3
|
import { watch, existsSync } from 'node:fs';
|
|
4
4
|
import { dirname } from 'node:path';
|
|
5
5
|
|
|
6
|
-
import { createConfigSource, LockedError, configProblems } from './config/config.js';
|
|
7
|
-
import { McpHub } from './mcp.js';
|
|
6
|
+
import { createConfigSource, LockedError, configProblems, redactSecrets } from './config/config.js';
|
|
7
|
+
import { McpHub, missingCommand } from './mcp.js';
|
|
8
8
|
import { isClosed, findEntry } from './config/models.js';
|
|
9
9
|
import { Store, SCHEMA_VERSION } from './store/index.js';
|
|
10
|
-
import { fingerprint, featuresFor, modelsFor } from './access/policy.js';
|
|
10
|
+
import { fingerprint, featuresFor, modelsFor, resolveAccess } from './access/policy.js';
|
|
11
11
|
import { hashPassword, applyAccessPolicy } from './access/auth.js';
|
|
12
12
|
import { audit } from './audit.js';
|
|
13
13
|
import { logger } from './log.js';
|
|
@@ -18,6 +18,7 @@ import { documentToolDef, callReadDocument } from './tools/document_tool.js';
|
|
|
18
18
|
import { askToolDef, callAskUser } from './tools/ask_tool.js';
|
|
19
19
|
import { chatSearchToolDef, callSearchChats } from './tools/chat_search_tool.js';
|
|
20
20
|
import { taskToolDef, callManageTasks } from './tools/task_tool.js';
|
|
21
|
+
import { mcpToolDef, callManageMcp, MANAGE_MCP } from './tools/mcp_tool.js';
|
|
21
22
|
import { automationToolDef, manageAutomation } from './automations/automation.js';
|
|
22
23
|
import { overrideFor } from './config/approval.js';
|
|
23
24
|
import { Retrieval } from './retrieval/retrieval.js';
|
|
@@ -56,6 +57,7 @@ function routeTable(app) {
|
|
|
56
57
|
saveConfig: (patch, by) => app.saveConfig(patch, by), refreshConfig: () => app.refreshConfig(),
|
|
57
58
|
auditMcp: (by, before) => app.auditMcp(by, before), swapHub: (servers) => app.swapHub(servers),
|
|
58
59
|
multiUser: () => app.multiUser(), modelFor: (userId, role) => app.modelFor(userId, role),
|
|
60
|
+
instructionsFor: (userId) => app.instructionsFor(userId),
|
|
59
61
|
toolsFor: (features) => app.toolsFor(features), toolsView: () => app.toolsView(), configFor: (auth) => app.configFor(auth)
|
|
60
62
|
};
|
|
61
63
|
return [
|
|
@@ -69,7 +71,7 @@ export const API_ROUTES = routeTable({}).map((r) => [r.method, r.path, r.feature
|
|
|
69
71
|
|
|
70
72
|
// Fields a non-admin never sees: credentials, and where the deployment's
|
|
71
73
|
// trust boundary sits.
|
|
72
|
-
const ADMIN_ONLY_FIELDS = /^(apiKey|authPassword|sessionSecret|google|baseUrl|trusted|adminEmails|models$)/;
|
|
74
|
+
const ADMIN_ONLY_FIELDS = /^(apiKey|keyHint|connectors|authPassword|sessionSecret|google|baseUrl|trusted|adminEmails|models$)/;
|
|
73
75
|
|
|
74
76
|
/**
|
|
75
77
|
* Starts an instance. Everything but port/host is optional and passed to
|
|
@@ -81,6 +83,9 @@ export async function start({ port = 7777, host = '127.0.0.1', ...sourceOpts } =
|
|
|
81
83
|
const initial = source.load();
|
|
82
84
|
const problems = configProblems(initial);
|
|
83
85
|
if (problems.length) throw new Error(problems.join('\n'));
|
|
86
|
+
if (initial.authMode === 'none' && !['127.0.0.1', '::1', 'localhost'].includes(host)) {
|
|
87
|
+
throw new Error("authMode 'none' is limited to loopback; use password login or a trusted-header gateway for network access");
|
|
88
|
+
}
|
|
84
89
|
|
|
85
90
|
const store = new Store(source.dbPath(initial), { migrate: initial.autoMigrate !== false });
|
|
86
91
|
// Runs live in this process, so nothing from before it can still be waiting.
|
|
@@ -126,14 +131,39 @@ export async function start({ port = 7777, host = '127.0.0.1', ...sourceOpts } =
|
|
|
126
131
|
saveConfig(patch, by) {
|
|
127
132
|
const before = fingerprint(app.cfg, source.loadMcpServers());
|
|
128
133
|
app.cfg = source.save(patch);
|
|
129
|
-
audit('config.changed', { by: by ?? 'local', keys: Object.keys(patch), values: patch, before, after: fingerprint(app.cfg, source.loadMcpServers()) });
|
|
134
|
+
audit('config.changed', { by: by ?? 'local', keys: Object.keys(patch), values: redactSecrets(patch), before, after: fingerprint(app.cfg, source.loadMcpServers()) });
|
|
130
135
|
return app.cfg;
|
|
131
136
|
},
|
|
132
137
|
/** Re-reads the config after something other than saveConfig wrote the file. */
|
|
133
138
|
refreshConfig() {
|
|
134
139
|
app.cfg = source.load();
|
|
135
140
|
},
|
|
136
|
-
|
|
141
|
+
// `change` (action and server name, never a value) says what the assistant did.
|
|
142
|
+
auditMcp: (by, before, change) => audit('mcp.changed', { by: by ?? 'local', ...(change ? { change } : {}), before, after: fingerprint(app.cfg, source.loadMcpServers()) }),
|
|
143
|
+
|
|
144
|
+
/** What manage_mcp may do. It works on the file, not the live hub, so it sees what the next connect will. */
|
|
145
|
+
mcpEdit: {
|
|
146
|
+
locked: () => source.mcpLocked,
|
|
147
|
+
servers() {
|
|
148
|
+
let parsed;
|
|
149
|
+
try { parsed = JSON.parse(source.readMcpFile()); }
|
|
150
|
+
catch (err) { throw new Error(`mcp.json is not valid JSON (${err.message}); ask the user to fix it before anything is changed`); }
|
|
151
|
+
return structuredClone(parsed.mcpServers || parsed);
|
|
152
|
+
},
|
|
153
|
+
save(servers, { by, change }) {
|
|
154
|
+
const before = fingerprint(app.cfg, source.loadMcpServers());
|
|
155
|
+
source.saveMcpFile(JSON.stringify({ mcpServers: servers }, null, 2));
|
|
156
|
+
app.auditMcp(by, before, change);
|
|
157
|
+
// Noted now so the file watcher takes this write for our own. The
|
|
158
|
+
// reconnect waits for the reply to finish (onRunIdle): swapping now
|
|
159
|
+
// would close the servers the running turn is still calling.
|
|
160
|
+
mcpText = source.readMcpFile();
|
|
161
|
+
mcpDirty = true;
|
|
162
|
+
},
|
|
163
|
+
status: () => Object.fromEntries(app.hub.inventory(app.cfg.disabledTools).servers
|
|
164
|
+
.map((s) => [s.name, { status: s.status, error: s.error, tools: s.tools.length }])),
|
|
165
|
+
check: (spec) => missingCommand(spec)?.message ?? null
|
|
166
|
+
},
|
|
137
167
|
|
|
138
168
|
multiUser: () => app.cfg.authMode === 'trusted-header',
|
|
139
169
|
|
|
@@ -158,10 +188,18 @@ export async function start({ port = 7777, host = '127.0.0.1', ...sourceOpts } =
|
|
|
158
188
|
return allowed.map(known).find(Boolean) || fallback;
|
|
159
189
|
},
|
|
160
190
|
|
|
191
|
+
/** A person's own instructions, added after the system prompt, when an admin lets users set them. */
|
|
192
|
+
instructionsFor(userId) {
|
|
193
|
+
if (!userId || app.cfg.authMode !== 'trusted-header') return null;
|
|
194
|
+
if (!resolveAccess(app.cfg).customize.includes('instructions')) return null;
|
|
195
|
+
return store.users.getPrefs(userId).instructions || null;
|
|
196
|
+
},
|
|
197
|
+
|
|
161
198
|
/** The tools a turn gets: what is switched on, minus manage_automation for
|
|
162
199
|
* anyone whose role has no automations. */
|
|
163
200
|
toolsFor: (features) => app.hub.activeTools(app.cfg.disabledTools)
|
|
164
|
-
.filter((t) => features.has(FEATURE.AUTOMATIONS) || t.function.name !== 'manage_automation')
|
|
201
|
+
.filter((t) => features.has(FEATURE.AUTOMATIONS) || t.function.name !== 'manage_automation')
|
|
202
|
+
.filter((t) => features.has(FEATURE.MCP) || t.function.name !== MANAGE_MCP),
|
|
165
203
|
|
|
166
204
|
/** A stored owner's features: null (tiers 1-2) is the one person. */
|
|
167
205
|
ownerFeatures: (userId) => featuresFor(app.cfg, userId ? store.users.get(userId)?.role : null),
|
|
@@ -182,7 +220,13 @@ export async function start({ port = 7777, host = '127.0.0.1', ...sourceOpts } =
|
|
|
182
220
|
|
|
183
221
|
configFor(auth) {
|
|
184
222
|
const pub = source.public(app.cfg);
|
|
185
|
-
if (auth.features.has(FEATURE.SETTINGS)) return pub;
|
|
223
|
+
if (auth.features.has(FEATURE.SETTINGS) && auth.isAdmin) return pub;
|
|
224
|
+
if (auth.features.has(FEATURE.SETTINGS)) {
|
|
225
|
+
// Allowed to edit settings, but not to see where the deployment's keys point.
|
|
226
|
+
const out = {};
|
|
227
|
+
for (const [k, v] of Object.entries(pub)) if (!ADMIN_ONLY_FIELDS.test(k)) out[k] = v;
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
186
230
|
const out = {};
|
|
187
231
|
for (const [k, v] of Object.entries(pub)) if (!ADMIN_ONLY_FIELDS.test(k)) out[k] = v;
|
|
188
232
|
return { ...out, readOnly: true };
|
|
@@ -207,7 +251,15 @@ export async function start({ port = 7777, host = '127.0.0.1', ...sourceOpts } =
|
|
|
207
251
|
return run;
|
|
208
252
|
},
|
|
209
253
|
|
|
210
|
-
onRunIdle: (chatId) =>
|
|
254
|
+
onRunIdle: (chatId) => {
|
|
255
|
+
app.scheduler.drain(chatId);
|
|
256
|
+
// manage_mcp saved mcp.json during that run. Re-read the file rather than
|
|
257
|
+
// reuse what it saved: someone may have edited it since.
|
|
258
|
+
if (mcpDirty) {
|
|
259
|
+
mcpDirty = false;
|
|
260
|
+
app.swapHub(source.loadMcpServers()).catch((err) => log.error(`mcp reconnect failed: ${err.message}`));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
211
263
|
};
|
|
212
264
|
app.runs = createRuns(app);
|
|
213
265
|
app.scheduler = createScheduler(app);
|
|
@@ -230,12 +282,21 @@ export async function start({ port = 7777, host = '127.0.0.1', ...sourceOpts } =
|
|
|
230
282
|
const out = manageAutomation(args, { ...ctx, store, triggerAutomation: scheduler.trigger });
|
|
231
283
|
scheduler.arm();
|
|
232
284
|
return out;
|
|
285
|
+
})
|
|
286
|
+
// Unlike the tools above it asks before anything but "list": a local
|
|
287
|
+
// server is a command run on this machine, and mcp.json holds credentials.
|
|
288
|
+
.registerLocal(mcpToolDef(), (args, ctx) => callManageMcp(args, { ...ctx, mcp: app.mcpEdit }), {
|
|
289
|
+
readOnly: (args) => args?.action === 'list',
|
|
290
|
+
gated: (args) => args?.action !== 'list',
|
|
291
|
+
executionMode: 'sequential'
|
|
233
292
|
});
|
|
234
293
|
|
|
235
294
|
app.hub = await connectHub(source.loadMcpServers());
|
|
236
295
|
// What mcp.json said when the hub was last (re)built, so a reload can tell
|
|
237
296
|
// a real edit from the echo of the UI's own save.
|
|
238
297
|
let mcpText = source.readMcpFile();
|
|
298
|
+
// Set when manage_mcp has saved mcp.json and the hub still has to follow.
|
|
299
|
+
let mcpDirty = false;
|
|
239
300
|
// The hub swap in progress, if any, and whether shutdown has begun.
|
|
240
301
|
let swapping = Promise.resolve();
|
|
241
302
|
let stopping = false;
|
package/src/set-password.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, chmodSync } from 'node:fs';
|
|
2
2
|
import { createInterface } from 'node:readline';
|
|
3
3
|
import { createConfigSource } from './config/config.js';
|
|
4
4
|
import { hashPassword } from './access/auth.js';
|
|
5
|
+
import { MIN_PASSWORD_LENGTH } from './access/policy.js';
|
|
5
6
|
|
|
6
7
|
/** Reads one line without echoing it when attached to a terminal. */
|
|
7
8
|
function ask(prompt) {
|
|
@@ -17,16 +18,26 @@ function ask(prompt) {
|
|
|
17
18
|
* config file and switches authMode to 'single'. Existing sessions are
|
|
18
19
|
* signed with sessionSecret, which is rotated so they all end here too.
|
|
19
20
|
*/
|
|
20
|
-
export async function setPassword() {
|
|
21
|
-
|
|
21
|
+
export async function setPassword(opts = {}) {
|
|
22
|
+
if (opts.config && Object.hasOwn(opts.config, 'authMode') && opts.config.authMode !== 'single') {
|
|
23
|
+
throw new Error('authMode is set in code; change the JS config to use password login');
|
|
24
|
+
}
|
|
25
|
+
if (opts.config && Object.hasOwn(opts.config, 'authPassword')) {
|
|
26
|
+
throw new Error('authPassword is set in code; change the JS config to update the password');
|
|
27
|
+
}
|
|
28
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
29
|
+
throw new Error('set-password needs an interactive terminal; for headless startup set TINYWEBUI_PASSWORD');
|
|
30
|
+
}
|
|
31
|
+
const file = createConfigSource(opts).path();
|
|
22
32
|
if (!file) throw new Error('no config file to write to');
|
|
23
33
|
const pw = await ask('New password: ');
|
|
24
|
-
if (pw.length <
|
|
25
|
-
if (
|
|
34
|
+
if (pw.length < MIN_PASSWORD_LENGTH) throw new Error(`use at least ${MIN_PASSWORD_LENGTH} characters`);
|
|
35
|
+
if ((await ask('Repeat password: ')) !== pw) throw new Error('passwords do not match');
|
|
26
36
|
const current = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {};
|
|
27
37
|
current.authMode = 'single';
|
|
28
38
|
current.authPassword = hashPassword(pw);
|
|
29
39
|
delete current.sessionSecret; // regenerated on next start: every old session ends
|
|
30
|
-
|
|
31
|
-
|
|
40
|
+
if (existsSync(file) && process.platform !== 'win32') chmodSync(file, 0o600);
|
|
41
|
+
writeFileSync(file, JSON.stringify(current, null, 2) + '\n', { mode: 0o600 });
|
|
42
|
+
console.log(`[tinywebui] password set in ${file}`);
|
|
32
43
|
}
|
package/src/store/schema.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
export const SCHEMA = `
|
|
4
4
|
-- Optional auth/RBAC (config authMode 'single'/'multiuser'). Unused and
|
|
5
|
-
-- empty when authMode is 'none'
|
|
5
|
+
-- empty when authMode is explicitly 'none' -- these tables cost nothing
|
|
6
6
|
-- to have around.
|
|
7
7
|
CREATE TABLE IF NOT EXISTS users (
|
|
8
8
|
id TEXT PRIMARY KEY,
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* manage_mcp -- lets the model add, change, remove and switch MCP servers in
|
|
3
|
+
* mcp.json on the user's behalf.
|
|
4
|
+
*
|
|
5
|
+
* Two things make this tool different from the other built-ins:
|
|
6
|
+
*
|
|
7
|
+
* 1. A stdio server is a command run on this machine, so every call that
|
|
8
|
+
* changes something asks first (see `gated` in registerLocal and
|
|
9
|
+
* approvalFor), and an unattended run cannot use it at all.
|
|
10
|
+
* 2. mcp.json holds credentials (headers, env, tokens in URLs). The model gets
|
|
11
|
+
* to write them but never to read them back: results carry the *names* of
|
|
12
|
+
* headers and env variables, never their values, and an update to either is
|
|
13
|
+
* a patch, so a key is never echoed back to change something next to it. A
|
|
14
|
+
* value written as `${NAME}` is filled in from TinyWebUI's environment at
|
|
15
|
+
* connect time (see expandRefs in mcp.js), which keeps the key out of the
|
|
16
|
+
* file and out of the chat altogether.
|
|
17
|
+
*
|
|
18
|
+
* The file is saved at once; the servers reconnect when the reply is finished
|
|
19
|
+
* (server.js), so a turn never loses the tools it is in the middle of using.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const MANAGE_MCP = 'manage_mcp';
|
|
23
|
+
|
|
24
|
+
const NAME = /^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$/;
|
|
25
|
+
const HEADER_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
26
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
27
|
+
// A value that is only a reference to the environment holds no secret, so it
|
|
28
|
+
// may be shown. Anything else about a header or env value is hidden.
|
|
29
|
+
const REF_ONLY = /^(?:(?:Bearer|Basic|Token)\s+)?\$\{[A-Za-z_][A-Za-z0-9_]*\}$/;
|
|
30
|
+
const SECRETISH = /key|token|secret|pass|auth|bearer|credential/i;
|
|
31
|
+
const HIDDEN = '<hidden>';
|
|
32
|
+
|
|
33
|
+
const MAX_SERVERS = 30;
|
|
34
|
+
const MAX_ENTRIES = 20;
|
|
35
|
+
const MAX_ARGS = 50;
|
|
36
|
+
const MAX_VALUE = 4000;
|
|
37
|
+
|
|
38
|
+
export function mcpToolDef() {
|
|
39
|
+
return {
|
|
40
|
+
type: 'function',
|
|
41
|
+
function: {
|
|
42
|
+
name: MANAGE_MCP,
|
|
43
|
+
description: [
|
|
44
|
+
'Add, change, remove, enable or disable the MCP tool servers in mcp.json. The user is',
|
|
45
|
+
'asked to approve every change, so say what you are about to do and why in your reply.',
|
|
46
|
+
'Use "list" first to see what is configured and whether each server is connected.',
|
|
47
|
+
'',
|
|
48
|
+
'A server is either local ("command", optional "args", "env", "cwd": a program this',
|
|
49
|
+
'machine runs) or remote ("url", optional "headers", "transport": "sse" for SSE servers,',
|
|
50
|
+
'default Streamable HTTP). Never give both.',
|
|
51
|
+
'',
|
|
52
|
+
'Secrets. You can write API keys but never read them back: results show header and env',
|
|
53
|
+
'NAMES only. To change one header, pass just that header; the others are kept. Pass null',
|
|
54
|
+
'to remove one. Prefer "${NAME}" over a literal key, e.g. {"Authorization": "Bearer',
|
|
55
|
+
'${GITHUB_TOKEN}"}: TinyWebUI fills it in from its own environment, so the key is never',
|
|
56
|
+
'stored in mcp.json or in this chat. Use a literal value only if the user gave you the key',
|
|
57
|
+
'in this conversation, and never repeat a key in your replies.',
|
|
58
|
+
'',
|
|
59
|
+
'Changes are saved immediately but take effect when your reply is finished: a new',
|
|
60
|
+
"server's tools are available from the user's next message, so tell them that rather",
|
|
61
|
+
'than trying to call them now. Only add servers the user asked for, and only commands',
|
|
62
|
+
'they named or clearly approved: a local server runs as a program on their machine.',
|
|
63
|
+
'',
|
|
64
|
+
'Actions and what each needs:',
|
|
65
|
+
'- list: nothing.',
|
|
66
|
+
'- add: name, plus command or url (and optionally args, env, cwd, headers, transport).',
|
|
67
|
+
'- update: name, plus only the fields to change. args replaces the whole list. env and',
|
|
68
|
+
' headers merge by name. You cannot switch a server between local and remote; remove and add.',
|
|
69
|
+
'- remove: name. Permanent.',
|
|
70
|
+
'- enable / disable: name. Disabling keeps the entry and drops the connection.'
|
|
71
|
+
].join('\n'),
|
|
72
|
+
parameters: {
|
|
73
|
+
type: 'object',
|
|
74
|
+
properties: {
|
|
75
|
+
action: { type: 'string', enum: ['list', 'add', 'update', 'remove', 'enable', 'disable'] },
|
|
76
|
+
name: { type: 'string', description: 'Server name: letters, digits, "-" and "_", up to 32. It prefixes the names of its tools.' },
|
|
77
|
+
command: { type: 'string', description: 'Local server: the program to run, e.g. "npx" or "uvx".' },
|
|
78
|
+
args: { type: 'array', items: { type: 'string' }, description: 'Local server: its arguments.' },
|
|
79
|
+
cwd: { type: 'string', description: 'Local server: working directory.' },
|
|
80
|
+
env: { type: 'object', additionalProperties: { type: ['string', 'null'] }, description: 'Local server: environment variables to set. null removes one.' },
|
|
81
|
+
url: { type: 'string', description: 'Remote server: its http(s) URL.' },
|
|
82
|
+
transport: { type: 'string', enum: ['http', 'sse'], description: 'Remote server: "sse" only for SSE servers; the default is Streamable HTTP.' },
|
|
83
|
+
headers: { type: 'object', additionalProperties: { type: ['string', 'null'] }, description: 'Remote server: request headers, e.g. an Authorization header. null removes one.' }
|
|
84
|
+
},
|
|
85
|
+
required: ['action']
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* ---------- what the model is allowed to see ---------- */
|
|
92
|
+
|
|
93
|
+
const shown = (v) => (REF_ONLY.test(String(v)) ? String(v) : HIDDEN);
|
|
94
|
+
const maskMap = (obj) => obj && Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, shown(v)]));
|
|
95
|
+
|
|
96
|
+
/** Arguments, with the value of anything that looks like a credential flag hidden. */
|
|
97
|
+
function maskArgs(args = []) {
|
|
98
|
+
let afterSecretFlag = false;
|
|
99
|
+
return args.map((a) => {
|
|
100
|
+
const s = String(a);
|
|
101
|
+
const inline = /^(--?[\w-]+)=([\s\S]*)$/.exec(s);
|
|
102
|
+
if (inline) return SECRETISH.test(inline[1]) && !REF_ONLY.test(inline[2]) ? `${inline[1]}=${HIDDEN}` : s;
|
|
103
|
+
const isFlag = /^--?[\w-]+$/.test(s);
|
|
104
|
+
const hide = afterSecretFlag && !isFlag && !REF_ONLY.test(s);
|
|
105
|
+
afterSecretFlag = isFlag && SECRETISH.test(s);
|
|
106
|
+
return hide ? HIDDEN : s;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A URL without credentials: userinfo, query values and token-length path segments are hidden. */
|
|
111
|
+
function maskUrl(raw) {
|
|
112
|
+
let u;
|
|
113
|
+
try { u = new URL(raw); } catch { return HIDDEN; }
|
|
114
|
+
const path = u.pathname.split('/').map((seg) => (seg.length >= 20 && !REF_ONLY.test(decodeURIComponent(seg)) ? HIDDEN : seg)).join('/');
|
|
115
|
+
const query = [...u.searchParams].map(([k, v]) => `${k}=${REF_ONLY.test(v) ? v : HIDDEN}`).join('&');
|
|
116
|
+
return `${u.protocol}//${u.host}${path}${query ? `?${query}` : ''}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Every literal secret value in a spec, so an error message that quotes one can be cleaned. */
|
|
120
|
+
function literalSecrets(spec) {
|
|
121
|
+
const values = [...Object.values(spec.headers || {}), ...Object.values(spec.env || {})].map(String);
|
|
122
|
+
try {
|
|
123
|
+
const u = new URL(spec.url);
|
|
124
|
+
if (u.password) values.push(u.password);
|
|
125
|
+
values.push(...[...u.searchParams.values()], ...u.pathname.split('/').filter((s) => s.length >= 20));
|
|
126
|
+
} catch { /* a local server, or a URL that does not parse */ }
|
|
127
|
+
return values.filter((v) => v.length >= 6 && !REF_ONLY.test(v));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const scrub = (text, spec) => literalSecrets(spec).reduce((t, secret) => t.split(secret).join(HIDDEN), String(text));
|
|
131
|
+
|
|
132
|
+
function describe(name, spec, live) {
|
|
133
|
+
const out = { name, type: spec.command ? 'local' : 'remote', enabled: !spec.disabled };
|
|
134
|
+
if (spec.command) {
|
|
135
|
+
out.command = spec.command;
|
|
136
|
+
if (spec.args?.length) out.args = maskArgs(spec.args);
|
|
137
|
+
if (spec.cwd) out.cwd = spec.cwd;
|
|
138
|
+
if (spec.env && Object.keys(spec.env).length) out.env = maskMap(spec.env);
|
|
139
|
+
} else {
|
|
140
|
+
out.url = maskUrl(spec.url);
|
|
141
|
+
out.transport = spec.transport === 'sse' ? 'sse' : 'http';
|
|
142
|
+
if (spec.headers && Object.keys(spec.headers).length) out.headers = maskMap(spec.headers);
|
|
143
|
+
}
|
|
144
|
+
if (live) {
|
|
145
|
+
out.status = live.status;
|
|
146
|
+
if (live.error) out.error = scrub(live.error, spec);
|
|
147
|
+
out.tools = live.tools;
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/* ---------- checking what the model wrote ---------- */
|
|
153
|
+
|
|
154
|
+
function plainMap(value, what, nameRule) {
|
|
155
|
+
if (value === undefined) return undefined;
|
|
156
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`"${what}" must be an object of name: value`);
|
|
157
|
+
const entries = Object.entries(value);
|
|
158
|
+
if (entries.length > MAX_ENTRIES) throw new Error(`"${what}" can hold at most ${MAX_ENTRIES} entries`);
|
|
159
|
+
for (const [k, v] of entries) {
|
|
160
|
+
if (!nameRule.test(k)) throw new Error(`"${what}" has an invalid name`);
|
|
161
|
+
if (v === null) continue;
|
|
162
|
+
if (typeof v !== 'string' || v.length > MAX_VALUE || /[\r\n]/.test(v)) throw new Error(`"${what}.${k}" must be a single-line string, or null to remove it`);
|
|
163
|
+
}
|
|
164
|
+
return value;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Applies a name -> value | null patch to the current map; an empty result is dropped. */
|
|
168
|
+
function patched(current, patch) {
|
|
169
|
+
if (patch === undefined) return current;
|
|
170
|
+
const out = { ...(current || {}) };
|
|
171
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
172
|
+
if (v === null) delete out[k]; else out[k] = v;
|
|
173
|
+
}
|
|
174
|
+
return Object.keys(out).length ? out : undefined;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function checkUrl(raw) {
|
|
178
|
+
if (typeof raw !== 'string' || !raw) throw new Error('"url" must be a string');
|
|
179
|
+
let u;
|
|
180
|
+
try { u = new URL(raw); } catch { throw new Error('"url" is not a valid URL'); }
|
|
181
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('"url" must start with http:// or https://');
|
|
182
|
+
return u;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const LOCAL_ONLY = ['command', 'args', 'cwd', 'env'];
|
|
186
|
+
const REMOTE_ONLY = ['url', 'transport', 'headers'];
|
|
187
|
+
|
|
188
|
+
function rejectOther(args, fields, kind) {
|
|
189
|
+
const given = fields.filter((f) => args[f] !== undefined);
|
|
190
|
+
if (given.length) throw new Error(`${given.map((f) => `"${f}"`).join(', ')} cannot be used with a ${kind} server`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Folds the call's fields into a server entry, validating as it goes. `base` is the current entry for an update. */
|
|
194
|
+
function build(args, base) {
|
|
195
|
+
const local = base ? Boolean(base.command) : Boolean(args.command);
|
|
196
|
+
if (!base && Boolean(args.command) === Boolean(args.url)) {
|
|
197
|
+
throw new Error('give either "command" (a local program) or "url" (a remote server), not both and not neither');
|
|
198
|
+
}
|
|
199
|
+
const spec = { ...(base || {}) };
|
|
200
|
+
if (local) {
|
|
201
|
+
rejectOther(args, REMOTE_ONLY, 'local');
|
|
202
|
+
if (args.command !== undefined) {
|
|
203
|
+
if (typeof args.command !== 'string' || !args.command.trim()) throw new Error('"command" must be a non-empty string');
|
|
204
|
+
spec.command = args.command.trim();
|
|
205
|
+
}
|
|
206
|
+
if (args.args !== undefined) {
|
|
207
|
+
if (!Array.isArray(args.args) || args.args.length > MAX_ARGS || args.args.some((a) => typeof a !== 'string' || a.length > MAX_VALUE)) {
|
|
208
|
+
throw new Error(`"args" must be a list of at most ${MAX_ARGS} strings`);
|
|
209
|
+
}
|
|
210
|
+
spec.args = args.args;
|
|
211
|
+
}
|
|
212
|
+
if (args.cwd !== undefined) {
|
|
213
|
+
if (typeof args.cwd !== 'string') throw new Error('"cwd" must be a string');
|
|
214
|
+
spec.cwd = args.cwd;
|
|
215
|
+
}
|
|
216
|
+
const env = patched(spec.env, plainMap(args.env, 'env', ENV_NAME));
|
|
217
|
+
if (env) spec.env = env; else delete spec.env;
|
|
218
|
+
} else {
|
|
219
|
+
rejectOther(args, LOCAL_ONLY, 'remote');
|
|
220
|
+
if (args.url !== undefined) { checkUrl(args.url); spec.url = args.url; }
|
|
221
|
+
if (args.transport !== undefined) {
|
|
222
|
+
if (!['http', 'sse'].includes(args.transport)) throw new Error('"transport" must be "http" or "sse"');
|
|
223
|
+
if (args.transport === 'sse') spec.transport = 'sse'; else delete spec.transport;
|
|
224
|
+
}
|
|
225
|
+
const headers = patched(spec.headers, plainMap(args.headers, 'headers', HEADER_NAME));
|
|
226
|
+
if (headers) spec.headers = headers; else delete spec.headers;
|
|
227
|
+
}
|
|
228
|
+
return spec;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Notes worth telling the model about a spec it just wrote. */
|
|
232
|
+
function notes(spec, wrote) {
|
|
233
|
+
const out = {};
|
|
234
|
+
const plain = Object.entries({ ...(spec.headers || {}), ...(spec.env || {}) })
|
|
235
|
+
.filter(([k, v]) => wrote.includes(k) && SECRETISH.test(k) && !REF_ONLY.test(String(v)) && !String(v).includes('${'))
|
|
236
|
+
.map(([k]) => k);
|
|
237
|
+
if (plain.length) {
|
|
238
|
+
out.hint = `${plain.join(', ')} is stored as plain text in mcp.json. To keep a key out of the file, set an environment`
|
|
239
|
+
+ ' variable for TinyWebUI and use "${NAME}" as the value instead.';
|
|
240
|
+
}
|
|
241
|
+
if (spec.url && spec.headers && new URL(spec.url).protocol === 'http:' && !/^(localhost|127\.|\[::1\])/.test(new URL(spec.url).hostname)) {
|
|
242
|
+
out.warning = 'This URL is plain http to another machine, so its headers travel unencrypted. Prefer https.';
|
|
243
|
+
}
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* `mcp`: { servers(), save(servers, change), locked(), status(), check(spec) }.
|
|
249
|
+
* `servers()` throws when mcp.json is not valid JSON, so a broken file is
|
|
250
|
+
* never overwritten with one new entry.
|
|
251
|
+
*/
|
|
252
|
+
export function callManageMcp(args, { mcp, chatId } = {}) {
|
|
253
|
+
if (!mcp) return 'Error: this tool is not available here.';
|
|
254
|
+
const { action } = args || {};
|
|
255
|
+
const known = ['list', 'add', 'update', 'remove', 'enable', 'disable'];
|
|
256
|
+
if (!known.includes(action)) return `Error: action must be one of ${known.join(', ')}.`;
|
|
257
|
+
|
|
258
|
+
let servers;
|
|
259
|
+
try { servers = mcp.servers(); } catch (err) { return `Error: ${err.message}`; }
|
|
260
|
+
const live = mcp.status();
|
|
261
|
+
|
|
262
|
+
if (action === 'list') {
|
|
263
|
+
return JSON.stringify({
|
|
264
|
+
editable: !mcp.locked(),
|
|
265
|
+
servers: Object.keys(servers).sort().map((n) => describe(n, servers[n], live[n]))
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
if (mcp.locked()) {
|
|
269
|
+
return 'Error: the MCP servers are locked in this deployment (set in code, or frozen), so they cannot be changed from here. Tell the user to edit mcp.json themselves.';
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const name = args.name;
|
|
273
|
+
if (typeof name !== 'string' || !NAME.test(name)) return 'Error: "name" must be 1-32 letters, digits, "-" or "_".';
|
|
274
|
+
const exists = Object.hasOwn(servers, name);
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
let wrote = [];
|
|
278
|
+
if (action === 'add') {
|
|
279
|
+
if (exists) return `Error: a server named "${name}" already exists. Use update to change it.`;
|
|
280
|
+
if (Object.keys(servers).length >= MAX_SERVERS) return `Error: at most ${MAX_SERVERS} servers can be configured.`;
|
|
281
|
+
servers[name] = build(args, null);
|
|
282
|
+
wrote = [...Object.keys(args.headers || {}), ...Object.keys(args.env || {})];
|
|
283
|
+
} else if (!exists) {
|
|
284
|
+
return `Error: no server named "${name}". Use list to see what is configured.`;
|
|
285
|
+
} else if (action === 'update') {
|
|
286
|
+
const fields = [...LOCAL_ONLY, ...REMOTE_ONLY, 'args'].filter((f) => args[f] !== undefined);
|
|
287
|
+
if (!fields.length) return 'Error: update needs at least one field to change.';
|
|
288
|
+
servers[name] = build(args, servers[name]);
|
|
289
|
+
wrote = [...Object.keys(args.headers || {}), ...Object.keys(args.env || {})];
|
|
290
|
+
} else if (action === 'remove') {
|
|
291
|
+
delete servers[name];
|
|
292
|
+
} else {
|
|
293
|
+
const on = action === 'enable';
|
|
294
|
+
if (on) delete servers[name].disabled; else servers[name].disabled = true;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
mcp.save(servers, { by: `assistant (chat ${chatId ?? '?'})`, change: { action, name } });
|
|
298
|
+
|
|
299
|
+
const result = { saved: true, action, name, takes_effect: 'when this reply is finished; new tools are available from the next message' };
|
|
300
|
+
if (action !== 'remove') {
|
|
301
|
+
result.server = describe(name, servers[name]);
|
|
302
|
+
Object.assign(result, notes(servers[name], wrote));
|
|
303
|
+
const missing = servers[name].command && !servers[name].disabled ? mcp.check?.(servers[name]) : null;
|
|
304
|
+
if (missing) result.warning = missing;
|
|
305
|
+
}
|
|
306
|
+
return JSON.stringify(result);
|
|
307
|
+
} catch (err) {
|
|
308
|
+
// Validation messages name fields, never values; save errors name the file.
|
|
309
|
+
return `Error: ${err.message}`;
|
|
310
|
+
}
|
|
311
|
+
}
|