tinywebui 0.1.4 → 0.1.5
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 +20 -13
- package/bin/tinywebui.js +69 -53
- package/package.json +25 -8
- package/public/app.js +40 -34
- package/public/chat/composer.js +1 -1
- package/public/chat/sidebar.js +8 -5
- package/public/index.html +88 -53
- package/public/panels/admin.js +40 -1
- package/public/panels/model-editor.js +336 -0
- package/public/panels/panels.js +37 -12
- package/public/panels/settings-home.js +125 -0
- package/public/panels/settings.js +124 -36
- package/public/shared/attachment_note.js +7 -1
- package/public/shared/events.js +1 -0
- package/public/shared/features.js +1 -0
- package/public/shared/origins.js +1 -0
- package/public/shared/usage.js +16 -1
- package/public/styles.css +84 -21
- package/src/access/auth.js +155 -155
- package/src/access/auth_gate.js +39 -38
- package/src/access/email.js +17 -0
- package/src/access/policy.js +123 -136
- package/src/audit.js +1 -1
- package/src/automations/automation.js +135 -123
- package/src/automations/scheduler.js +138 -127
- package/src/chat/compact.js +149 -156
- package/src/chat/llm.js +380 -382
- package/src/chat/runs.js +162 -151
- package/src/chat/tool_executor.js +229 -218
- package/src/cli.js +535 -433
- package/src/config/approval.js +35 -29
- package/src/config/config.js +546 -454
- package/src/config/data_dir.js +24 -0
- package/src/config/models.js +191 -115
- package/src/config/refs.js +32 -0
- package/src/config/secrets.js +153 -0
- package/src/files/documents.js +57 -68
- package/src/files/images.js +30 -31
- package/src/harness/agent.js +60 -60
- package/src/harness/attribution.js +66 -58
- package/src/harness/checkpoint.js +67 -62
- package/src/harness/history.js +28 -23
- package/src/harness/prompt.js +40 -57
- package/src/harness/provider.js +284 -283
- package/src/harness/types.js +1 -0
- package/src/http.js +90 -72
- package/src/log.js +11 -12
- package/src/mcp.js +398 -374
- package/src/retrieval/embedding.js +228 -240
- package/src/retrieval/retrieval.js +182 -173
- package/src/routes/admin.js +178 -138
- package/src/routes/auth.js +184 -111
- package/src/routes/automations.js +99 -82
- package/src/routes/chats.js +240 -218
- package/src/routes/documents.js +130 -120
- package/src/routes/library.js +43 -46
- package/src/routes/mcp.js +49 -49
- package/src/routes/settings.js +127 -122
- package/src/routes/types.js +1 -0
- package/src/server.js +426 -415
- package/src/store/automations.js +106 -106
- package/src/store/chats.js +217 -242
- package/src/store/documents.js +181 -185
- package/src/store/embeddings.js +52 -54
- package/src/store/index.js +95 -49
- package/src/store/messages.js +55 -80
- package/src/store/schema.js +128 -107
- package/src/store/scope.js +18 -17
- package/src/store/search.js +25 -27
- package/src/store/turns.js +53 -53
- package/src/store/usage.js +343 -269
- package/src/store/users.js +162 -87
- package/src/store/wire.js +50 -41
- package/src/tools/ask_tool.js +68 -68
- package/src/tools/chat_search_tool.js +38 -44
- package/src/tools/context_tool.js +131 -138
- package/src/tools/document_tool.js +73 -78
- package/src/tools/mcp_tool.js +274 -232
- package/src/tools/task_tool.js +53 -50
- package/src/tools/types.js +1 -0
- package/public/panels/connectors.js +0 -184
- package/src/set-password.js +0 -43
package/src/tools/mcp_tool.js
CHANGED
|
@@ -12,15 +12,13 @@
|
|
|
12
12
|
* headers and env variables, never their values, and an update to either is
|
|
13
13
|
* a patch, so a key is never echoed back to change something next to it. A
|
|
14
14
|
* value written as `${NAME}` is filled in from TinyWebUI's environment at
|
|
15
|
-
* connect time (see expandRefs in
|
|
15
|
+
* connect time (see expandRefs in config/refs.ts), which keeps the key out of the
|
|
16
16
|
* file and out of the chat altogether.
|
|
17
17
|
*
|
|
18
18
|
* The file is saved at once; the servers reconnect when the reply is finished
|
|
19
19
|
* (server.js), so a turn never loses the tools it is in the middle of using.
|
|
20
20
|
*/
|
|
21
|
-
|
|
22
21
|
export const MANAGE_MCP = 'manage_mcp';
|
|
23
|
-
|
|
24
22
|
const NAME = /^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$/;
|
|
25
23
|
const HEADER_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
26
24
|
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
@@ -29,283 +27,327 @@ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
|
29
27
|
const REF_ONLY = /^(?:(?:Bearer|Basic|Token)\s+)?\$\{[A-Za-z_][A-Za-z0-9_]*\}$/;
|
|
30
28
|
const SECRETISH = /key|token|secret|pass|auth|bearer|credential/i;
|
|
31
29
|
const HIDDEN = '<hidden>';
|
|
32
|
-
|
|
33
30
|
const MAX_SERVERS = 30;
|
|
34
31
|
const MAX_ENTRIES = 20;
|
|
35
32
|
const MAX_ARGS = 50;
|
|
36
33
|
const MAX_VALUE = 4000;
|
|
37
|
-
|
|
38
34
|
export function mcpToolDef() {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
35
|
+
return {
|
|
36
|
+
type: 'function',
|
|
37
|
+
function: {
|
|
38
|
+
name: MANAGE_MCP,
|
|
39
|
+
description: [
|
|
40
|
+
'Add, change, remove, enable or disable the MCP tool servers in mcp.json. The user is',
|
|
41
|
+
'asked to approve every change, so say what you are about to do and why in your reply.',
|
|
42
|
+
'Use "list" first to see what is configured and whether each server is connected.',
|
|
43
|
+
'',
|
|
44
|
+
'A server is either local ("command", optional "args", "env", "cwd": a program this',
|
|
45
|
+
'machine runs) or remote ("url", optional "headers", "transport": "sse" for SSE servers,',
|
|
46
|
+
'default Streamable HTTP). Never give both.',
|
|
47
|
+
'',
|
|
48
|
+
'Secrets. You can write API keys but never read them back: results show header and env',
|
|
49
|
+
'NAMES only. To change one header, pass just that header; the others are kept. Pass null',
|
|
50
|
+
'to remove one. Prefer "${NAME}" over a literal key, e.g. {"Authorization": "Bearer',
|
|
51
|
+
'${GITHUB_TOKEN}"}: TinyWebUI fills it in from its own environment, so the key is never',
|
|
52
|
+
'stored in mcp.json or in this chat. Use a literal value only if the user gave you the key',
|
|
53
|
+
'in this conversation, and never repeat a key in your replies.',
|
|
54
|
+
'',
|
|
55
|
+
'Changes are saved immediately but take effect when your reply is finished: a new',
|
|
56
|
+
"server's tools are available from the user's next message, so tell them that rather",
|
|
57
|
+
'than trying to call them now. Only add servers the user asked for, and only commands',
|
|
58
|
+
'they named or clearly approved: a local server runs as a program on their machine.',
|
|
59
|
+
'',
|
|
60
|
+
'Actions and what each needs:',
|
|
61
|
+
'- list: nothing.',
|
|
62
|
+
'- add: name, plus command or url (and optionally args, env, cwd, headers, transport).',
|
|
63
|
+
'- update: name, plus only the fields to change. args replaces the whole list. env and',
|
|
64
|
+
' headers merge by name. You cannot switch a server between local and remote; remove and add.',
|
|
65
|
+
'- remove: name. Permanent.',
|
|
66
|
+
'- enable / disable: name. Disabling keeps the entry and drops the connection.'
|
|
67
|
+
].join('\n'),
|
|
68
|
+
parameters: {
|
|
69
|
+
type: 'object',
|
|
70
|
+
properties: {
|
|
71
|
+
action: { type: 'string', enum: ['list', 'add', 'update', 'remove', 'enable', 'disable'] },
|
|
72
|
+
name: { type: 'string', description: 'Server name: letters, digits, "-" and "_", up to 32. It prefixes the names of its tools.' },
|
|
73
|
+
command: { type: 'string', description: 'Local server: the program to run, e.g. "npx" or "uvx".' },
|
|
74
|
+
args: { type: 'array', items: { type: 'string' }, description: 'Local server: its arguments.' },
|
|
75
|
+
cwd: { type: 'string', description: 'Local server: working directory.' },
|
|
76
|
+
env: { type: 'object', additionalProperties: { type: ['string', 'null'] }, description: 'Local server: environment variables to set. null removes one.' },
|
|
77
|
+
url: { type: 'string', description: 'Remote server: its http(s) URL.' },
|
|
78
|
+
transport: { type: 'string', enum: ['http', 'sse'], description: 'Remote server: "sse" only for SSE servers; the default is Streamable HTTP.' },
|
|
79
|
+
headers: { type: 'object', additionalProperties: { type: ['string', 'null'] }, description: 'Remote server: request headers, e.g. an Authorization header. null removes one.' }
|
|
80
|
+
},
|
|
81
|
+
required: ['action']
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
};
|
|
89
85
|
}
|
|
90
|
-
|
|
91
86
|
/* ---------- what the model is allowed to see ---------- */
|
|
92
|
-
|
|
93
87
|
const shown = (v) => (REF_ONLY.test(String(v)) ? String(v) : HIDDEN);
|
|
94
88
|
const maskMap = (obj) => obj && Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, shown(v)]));
|
|
95
|
-
|
|
96
89
|
/** Arguments, with the value of anything that looks like a credential flag hidden. */
|
|
97
90
|
function maskArgs(args = []) {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
91
|
+
let afterSecretFlag = false;
|
|
92
|
+
return args.map((a) => {
|
|
93
|
+
const s = String(a);
|
|
94
|
+
const inline = /^(--?[\w-]+)=([\s\S]*)$/.exec(s);
|
|
95
|
+
if (inline)
|
|
96
|
+
return SECRETISH.test(inline[1]) && !REF_ONLY.test(inline[2]) ? `${inline[1]}=${HIDDEN}` : s;
|
|
97
|
+
const isFlag = /^--?[\w-]+$/.test(s);
|
|
98
|
+
const hide = afterSecretFlag && !isFlag && !REF_ONLY.test(s);
|
|
99
|
+
afterSecretFlag = isFlag && SECRETISH.test(s);
|
|
100
|
+
return hide ? HIDDEN : s;
|
|
101
|
+
});
|
|
108
102
|
}
|
|
109
|
-
|
|
110
103
|
/** A URL without credentials: userinfo, query values and token-length path segments are hidden. */
|
|
111
104
|
function maskUrl(raw) {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
105
|
+
let u;
|
|
106
|
+
try {
|
|
107
|
+
u = new URL(raw);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return HIDDEN;
|
|
111
|
+
}
|
|
112
|
+
const path = u.pathname.split('/').map((seg) => (seg.length >= 20 && !REF_ONLY.test(decodeURIComponent(seg)) ? HIDDEN : seg)).join('/');
|
|
113
|
+
const query = [...u.searchParams].map(([k, v]) => `${k}=${REF_ONLY.test(v) ? v : HIDDEN}`).join('&');
|
|
114
|
+
return `${u.protocol}//${u.host}${path}${query ? `?${query}` : ''}`;
|
|
117
115
|
}
|
|
118
|
-
|
|
119
116
|
/** Every literal secret value in a spec, so an error message that quotes one can be cleaned. */
|
|
120
117
|
function literalSecrets(spec) {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
118
|
+
const values = [...Object.values(spec.headers || {}), ...Object.values(spec.env || {})].map(String);
|
|
119
|
+
try {
|
|
120
|
+
const u = new URL(spec.url);
|
|
121
|
+
if (u.password)
|
|
122
|
+
values.push(u.password);
|
|
123
|
+
values.push(...[...u.searchParams.values()], ...u.pathname.split('/').filter((s) => s.length >= 20));
|
|
124
|
+
}
|
|
125
|
+
catch { /* a local server, or a URL that does not parse */ }
|
|
126
|
+
return values.filter((v) => v.length >= 6 && !REF_ONLY.test(v));
|
|
128
127
|
}
|
|
129
|
-
|
|
130
128
|
const scrub = (text, spec) => literalSecrets(spec).reduce((t, secret) => t.split(secret).join(HIDDEN), String(text));
|
|
131
|
-
|
|
132
129
|
function describe(name, spec, live) {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
130
|
+
const out = { name, type: spec.command ? 'local' : 'remote', enabled: !spec.disabled };
|
|
131
|
+
if (spec.command) {
|
|
132
|
+
out.command = spec.command;
|
|
133
|
+
if (spec.args?.length)
|
|
134
|
+
out.args = maskArgs(spec.args);
|
|
135
|
+
if (spec.cwd)
|
|
136
|
+
out.cwd = spec.cwd;
|
|
137
|
+
if (spec.env && Object.keys(spec.env).length)
|
|
138
|
+
out.env = maskMap(spec.env);
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
out.url = maskUrl(spec.url);
|
|
142
|
+
out.transport = spec.transport === 'sse' ? 'sse' : 'http';
|
|
143
|
+
if (spec.headers && Object.keys(spec.headers).length)
|
|
144
|
+
out.headers = maskMap(spec.headers);
|
|
145
|
+
}
|
|
146
|
+
if (live) {
|
|
147
|
+
out.status = live.status;
|
|
148
|
+
if (live.error)
|
|
149
|
+
out.error = scrub(live.error, spec);
|
|
150
|
+
out.tools = live.tools;
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
150
153
|
}
|
|
151
|
-
|
|
152
154
|
/* ---------- checking what the model wrote ---------- */
|
|
153
|
-
|
|
154
155
|
function plainMap(value, what, nameRule) {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
if (
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
156
|
+
if (value === undefined)
|
|
157
|
+
return undefined;
|
|
158
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
159
|
+
throw new Error(`"${what}" must be an object of name: value`);
|
|
160
|
+
const entries = Object.entries(value);
|
|
161
|
+
if (entries.length > MAX_ENTRIES)
|
|
162
|
+
throw new Error(`"${what}" can hold at most ${MAX_ENTRIES} entries`);
|
|
163
|
+
for (const [k, v] of entries) {
|
|
164
|
+
if (!nameRule.test(k))
|
|
165
|
+
throw new Error(`"${what}" has an invalid name`);
|
|
166
|
+
if (v === null)
|
|
167
|
+
continue;
|
|
168
|
+
if (typeof v !== 'string' || v.length > MAX_VALUE || /[\r\n]/.test(v))
|
|
169
|
+
throw new Error(`"${what}.${k}" must be a single-line string, or null to remove it`);
|
|
170
|
+
}
|
|
171
|
+
return value;
|
|
165
172
|
}
|
|
166
|
-
|
|
167
173
|
/** Applies a name -> value | null patch to the current map; an empty result is dropped. */
|
|
168
174
|
function patched(current, patch) {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
+
if (patch === undefined)
|
|
176
|
+
return current;
|
|
177
|
+
const out = { ...(current || {}) };
|
|
178
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
179
|
+
if (v === null)
|
|
180
|
+
delete out[k];
|
|
181
|
+
else
|
|
182
|
+
out[k] = v;
|
|
183
|
+
}
|
|
184
|
+
return Object.keys(out).length ? out : undefined;
|
|
175
185
|
}
|
|
176
|
-
|
|
177
186
|
function checkUrl(raw) {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
187
|
+
if (typeof raw !== 'string' || !raw)
|
|
188
|
+
throw new Error('"url" must be a string');
|
|
189
|
+
let u;
|
|
190
|
+
try {
|
|
191
|
+
u = new URL(raw);
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
throw new Error('"url" is not a valid URL');
|
|
195
|
+
}
|
|
196
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:')
|
|
197
|
+
throw new Error('"url" must start with http:// or https://');
|
|
198
|
+
return u;
|
|
183
199
|
}
|
|
184
|
-
|
|
185
200
|
const LOCAL_ONLY = ['command', 'args', 'cwd', 'env'];
|
|
186
201
|
const REMOTE_ONLY = ['url', 'transport', 'headers'];
|
|
187
|
-
|
|
188
202
|
function rejectOther(args, fields, kind) {
|
|
189
|
-
|
|
190
|
-
|
|
203
|
+
const given = fields.filter((f) => args[f] !== undefined);
|
|
204
|
+
if (given.length)
|
|
205
|
+
throw new Error(`${given.map((f) => `"${f}"`).join(', ')} cannot be used with a ${kind} server`);
|
|
191
206
|
}
|
|
192
|
-
|
|
193
207
|
/** Folds the call's fields into a server entry, validating as it goes. `base` is the current entry for an update. */
|
|
194
208
|
function build(args, base) {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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;
|
|
209
|
+
const local = base ? Boolean(base.command) : Boolean(args.command);
|
|
210
|
+
if (!base && Boolean(args.command) === Boolean(args.url)) {
|
|
211
|
+
throw new Error('give either "command" (a local program) or "url" (a remote server), not both and not neither');
|
|
211
212
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
213
|
+
const spec = { ...(base || {}) };
|
|
214
|
+
if (local) {
|
|
215
|
+
rejectOther(args, REMOTE_ONLY, 'local');
|
|
216
|
+
if (args.command !== undefined) {
|
|
217
|
+
if (typeof args.command !== 'string' || !args.command.trim())
|
|
218
|
+
throw new Error('"command" must be a non-empty string');
|
|
219
|
+
spec.command = args.command.trim();
|
|
220
|
+
}
|
|
221
|
+
if (args.args !== undefined) {
|
|
222
|
+
if (!Array.isArray(args.args) || args.args.length > MAX_ARGS || args.args.some((a) => typeof a !== 'string' || a.length > MAX_VALUE)) {
|
|
223
|
+
throw new Error(`"args" must be a list of at most ${MAX_ARGS} strings`);
|
|
224
|
+
}
|
|
225
|
+
spec.args = args.args;
|
|
226
|
+
}
|
|
227
|
+
if (args.cwd !== undefined) {
|
|
228
|
+
if (typeof args.cwd !== 'string')
|
|
229
|
+
throw new Error('"cwd" must be a string');
|
|
230
|
+
spec.cwd = args.cwd;
|
|
231
|
+
}
|
|
232
|
+
const env = patched(spec.env, plainMap(args.env, 'env', ENV_NAME));
|
|
233
|
+
if (env)
|
|
234
|
+
spec.env = env;
|
|
235
|
+
else
|
|
236
|
+
delete spec.env;
|
|
215
237
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
238
|
+
else {
|
|
239
|
+
rejectOther(args, LOCAL_ONLY, 'remote');
|
|
240
|
+
if (args.url !== undefined) {
|
|
241
|
+
checkUrl(args.url);
|
|
242
|
+
spec.url = args.url;
|
|
243
|
+
}
|
|
244
|
+
if (args.transport !== undefined) {
|
|
245
|
+
if (!['http', 'sse'].includes(args.transport))
|
|
246
|
+
throw new Error('"transport" must be "http" or "sse"');
|
|
247
|
+
if (args.transport === 'sse')
|
|
248
|
+
spec.transport = 'sse';
|
|
249
|
+
else
|
|
250
|
+
delete spec.transport;
|
|
251
|
+
}
|
|
252
|
+
const headers = patched(spec.headers, plainMap(args.headers, 'headers', HEADER_NAME));
|
|
253
|
+
if (headers)
|
|
254
|
+
spec.headers = headers;
|
|
255
|
+
else
|
|
256
|
+
delete spec.headers;
|
|
224
257
|
}
|
|
225
|
-
|
|
226
|
-
if (headers) spec.headers = headers; else delete spec.headers;
|
|
227
|
-
}
|
|
228
|
-
return spec;
|
|
258
|
+
return spec;
|
|
229
259
|
}
|
|
230
|
-
|
|
231
260
|
/** Notes worth telling the model about a spec it just wrote. */
|
|
232
261
|
function notes(spec, wrote) {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
262
|
+
const out = {};
|
|
263
|
+
const plain = Object.entries({ ...(spec.headers || {}), ...(spec.env || {}) })
|
|
264
|
+
.filter(([k, v]) => wrote.includes(k) && SECRETISH.test(k) && !REF_ONLY.test(String(v)) && !String(v).includes('${'))
|
|
265
|
+
.map(([k]) => k);
|
|
266
|
+
if (plain.length) {
|
|
267
|
+
out.hint = `${plain.join(', ')} is stored as plain text in mcp.json. To keep a key out of the file, set an environment`
|
|
268
|
+
+ ' variable for TinyWebUI and use "${NAME}" as the value instead.';
|
|
269
|
+
}
|
|
270
|
+
if (spec.url && spec.headers && new URL(spec.url).protocol === 'http:' && !/^(localhost|127\.|\[::1\])/.test(new URL(spec.url).hostname)) {
|
|
271
|
+
out.warning = 'This URL is plain http to another machine, so its headers travel unencrypted. Prefer https.';
|
|
272
|
+
}
|
|
273
|
+
return out;
|
|
245
274
|
}
|
|
246
|
-
|
|
247
275
|
/**
|
|
248
276
|
* `mcp`: { servers(), save(servers, change), locked(), status(), check(spec) }.
|
|
249
277
|
* `servers()` throws when mcp.json is not valid JSON, so a broken file is
|
|
250
278
|
* never overwritten with one new entry.
|
|
251
279
|
*/
|
|
252
280
|
export function callManageMcp(args, { mcp, chatId } = {}) {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
281
|
+
if (!mcp)
|
|
282
|
+
return 'Error: this tool is not available here.';
|
|
283
|
+
const { action } = args || {};
|
|
284
|
+
const known = ['list', 'add', 'update', 'remove', 'enable', 'disable'];
|
|
285
|
+
if (!known.includes(action))
|
|
286
|
+
return `Error: action must be one of ${known.join(', ')}.`;
|
|
287
|
+
let servers;
|
|
288
|
+
try {
|
|
289
|
+
servers = mcp.servers();
|
|
290
|
+
}
|
|
291
|
+
catch (err) {
|
|
292
|
+
return `Error: ${err.message}`;
|
|
293
|
+
}
|
|
294
|
+
const live = mcp.status();
|
|
295
|
+
if (action === 'list') {
|
|
296
|
+
return JSON.stringify({
|
|
297
|
+
editable: !mcp.locked(),
|
|
298
|
+
servers: Object.keys(servers).sort().map((n) => describe(n, servers[n], live[n]))
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
if (mcp.locked()) {
|
|
302
|
+
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.';
|
|
303
|
+
}
|
|
304
|
+
const name = args.name;
|
|
305
|
+
if (typeof name !== 'string' || !NAME.test(name))
|
|
306
|
+
return 'Error: "name" must be 1-32 letters, digits, "-" or "_".';
|
|
307
|
+
const exists = Object.hasOwn(servers, name);
|
|
308
|
+
try {
|
|
309
|
+
let wrote = [];
|
|
310
|
+
if (action === 'add') {
|
|
311
|
+
if (exists)
|
|
312
|
+
return `Error: a server named "${name}" already exists. Use update to change it.`;
|
|
313
|
+
if (Object.keys(servers).length >= MAX_SERVERS)
|
|
314
|
+
return `Error: at most ${MAX_SERVERS} servers can be configured.`;
|
|
315
|
+
servers[name] = build(args, null);
|
|
316
|
+
wrote = [...Object.keys(args.headers || {}), ...Object.keys(args.env || {})];
|
|
317
|
+
}
|
|
318
|
+
else if (!exists) {
|
|
319
|
+
return `Error: no server named "${name}". Use list to see what is configured.`;
|
|
320
|
+
}
|
|
321
|
+
else if (action === 'update') {
|
|
322
|
+
const fields = [...LOCAL_ONLY, ...REMOTE_ONLY, 'args'].filter((f) => args[f] !== undefined);
|
|
323
|
+
if (!fields.length)
|
|
324
|
+
return 'Error: update needs at least one field to change.';
|
|
325
|
+
servers[name] = build(args, servers[name]);
|
|
326
|
+
wrote = [...Object.keys(args.headers || {}), ...Object.keys(args.env || {})];
|
|
327
|
+
}
|
|
328
|
+
else if (action === 'remove') {
|
|
329
|
+
delete servers[name];
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
const on = action === 'enable';
|
|
333
|
+
if (on)
|
|
334
|
+
delete servers[name].disabled;
|
|
335
|
+
else
|
|
336
|
+
servers[name].disabled = true;
|
|
337
|
+
}
|
|
338
|
+
mcp.save(servers, { by: `assistant (chat ${chatId ?? '?'})`, change: { action, name } });
|
|
339
|
+
const result = { saved: true, action, name, takes_effect: 'when this reply is finished; new tools are available from the next message' };
|
|
340
|
+
if (action !== 'remove') {
|
|
341
|
+
result.server = describe(name, servers[name]);
|
|
342
|
+
Object.assign(result, notes(servers[name], wrote));
|
|
343
|
+
const missing = servers[name].command && !servers[name].disabled ? mcp.check?.(servers[name]) : null;
|
|
344
|
+
if (missing)
|
|
345
|
+
result.warning = missing;
|
|
346
|
+
}
|
|
347
|
+
return JSON.stringify(result);
|
|
295
348
|
}
|
|
296
|
-
|
|
297
|
-
|
|
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;
|
|
349
|
+
catch (err) {
|
|
350
|
+
// Validation messages name fields, never values; save errors name the file.
|
|
351
|
+
return `Error: ${err.message}`;
|
|
305
352
|
}
|
|
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
353
|
}
|