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/routes/documents.js
CHANGED
|
@@ -4,130 +4,140 @@ import { normalizeImage } from '../files/images.js';
|
|
|
4
4
|
import { logger } from '../log.js';
|
|
5
5
|
import { FEATURE } from '../../public/shared/features.js';
|
|
6
6
|
import { CORPUS } from '../store/index.js';
|
|
7
|
-
|
|
8
7
|
const log = logger('retrieval');
|
|
9
|
-
|
|
10
8
|
const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
|
11
|
-
|
|
12
9
|
/** Decodes and normalizes one uploaded image; null on anything unusable. */
|
|
13
10
|
export async function normalizeUpload(mimeRaw, dataBase64) {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
11
|
+
const mime = String(mimeRaw || '');
|
|
12
|
+
if (!mime.startsWith('image/') && mime !== '')
|
|
13
|
+
return null;
|
|
14
|
+
let buf;
|
|
15
|
+
try {
|
|
16
|
+
buf = Buffer.from(String(dataBase64 || ''), 'base64');
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
if (!buf.length || buf.length > MAX_FILE_BYTES)
|
|
22
|
+
return null;
|
|
23
|
+
try {
|
|
24
|
+
const normalized = await normalizeImage(buf, mime);
|
|
25
|
+
return normalized && { mime: normalized.mime, data: normalized.data.toString('base64') };
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
25
30
|
}
|
|
26
|
-
|
|
27
31
|
export function documentRoutes({ retrieval, store }) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
32
|
+
// Uploads and attaches land before the first message exists, so the chat is
|
|
33
|
+
// created lazily, the same way /api/chat does for a brand-new conversation.
|
|
34
|
+
// Null when the id is someone else's: "no such chat", never a key clash.
|
|
35
|
+
const chatFor = (chatId, auth, title) => {
|
|
36
|
+
const own = store.chats.get(chatId, auth.userId);
|
|
37
|
+
if (own)
|
|
38
|
+
return own;
|
|
39
|
+
if (store.chats.byId(chatId))
|
|
40
|
+
return null;
|
|
41
|
+
return store.chats.create({ id: chatId, title: String(title).slice(0, 60) }, auth.userId);
|
|
42
|
+
};
|
|
43
|
+
// Embedded once, in the background; a question that arrives first waits for it.
|
|
44
|
+
const ingest = (doc) => retrieval.ingest(CORPUS.DOCUMENTS, doc.id)
|
|
45
|
+
.catch((err) => log.error(`embedding ${doc.id} failed: ${err.message}`));
|
|
46
|
+
return [
|
|
47
|
+
// Uploads, including the paste-as-file path.
|
|
48
|
+
{ method: 'POST', path: /^\/api\/chats\/([\w.-]+)\/documents$/, feature: FEATURE.ATTACHMENTS, handle: async ({ req, res, auth, params: [chatId] }) => {
|
|
49
|
+
const { filename, mime, dataBase64 } = await readJson(req, UPLOAD_LIMIT);
|
|
50
|
+
if (!filename || typeof dataBase64 !== 'string') {
|
|
51
|
+
return json(res, 400, { error: 'filename and dataBase64 are required' });
|
|
52
|
+
}
|
|
53
|
+
let buf;
|
|
54
|
+
try {
|
|
55
|
+
buf = Buffer.from(dataBase64, 'base64');
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return json(res, 400, { error: 'dataBase64 is not valid base64' });
|
|
59
|
+
}
|
|
60
|
+
if (buf.length > MAX_FILE_BYTES) {
|
|
61
|
+
return json(res, 400, { error: `file exceeds ${MAX_FILE_BYTES.toLocaleString('en-US')} byte limit` });
|
|
62
|
+
}
|
|
63
|
+
let text;
|
|
64
|
+
try {
|
|
65
|
+
text = await extractText(buf, String(filename));
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
return json(res, 400, { error: err.message });
|
|
69
|
+
}
|
|
70
|
+
if (!text.trim())
|
|
71
|
+
return json(res, 400, { error: 'no extractable text in that file' });
|
|
72
|
+
const chat = chatFor(chatId, auth, filename);
|
|
73
|
+
if (!chat)
|
|
74
|
+
return json(res, 404, { error: 'no such chat' });
|
|
75
|
+
// The same file dropped in twice is one document in the chat.
|
|
76
|
+
const already = store.documents.findInChat(chat.id, buf);
|
|
77
|
+
if (already)
|
|
78
|
+
return json(res, 200, { chatId: chat.id, document: already });
|
|
79
|
+
const doc = store.documents.add(chat.id, { filename: String(filename), mime: typeof mime === 'string' && mime ? mime : null, content: text, original: buf }, retrieval.passageSettings());
|
|
80
|
+
ingest(doc);
|
|
81
|
+
return json(res, 200, { chatId: chat.id, document: doc });
|
|
82
|
+
} },
|
|
83
|
+
// What the user has attached in earlier chats, one line per distinct file,
|
|
84
|
+
// for the "attach from earlier chats" picker.
|
|
85
|
+
{ method: 'GET', path: /^\/api\/documents$/, feature: FEATURE.ATTACHMENTS, handle: ({ res, auth }) => {
|
|
86
|
+
return json(res, 200, { documents: store.documents.library(auth.userId) });
|
|
87
|
+
} },
|
|
88
|
+
// Attaches a file the user already uploaded elsewhere, without uploading
|
|
89
|
+
// it again: the chat gets its own copy of the text (same stored file).
|
|
90
|
+
{ method: 'POST', path: /^\/api\/chats\/([\w.-]+)\/documents\/attach$/, feature: FEATURE.ATTACHMENTS, handle: async ({ req, res, auth, params: [chatId] }) => {
|
|
91
|
+
const { documentId } = await readJson(req);
|
|
92
|
+
if (typeof documentId !== 'string' || !documentId)
|
|
93
|
+
return json(res, 400, { error: 'documentId is required' });
|
|
94
|
+
// Checked before the chat is created, so a bad id leaves no empty chat behind.
|
|
95
|
+
const source = store.documents.get(documentId, auth.userId);
|
|
96
|
+
if (!source)
|
|
97
|
+
return json(res, 404, { error: 'no such document' });
|
|
98
|
+
const chat = chatFor(chatId, auth, source.filename);
|
|
99
|
+
if (!chat)
|
|
100
|
+
return json(res, 404, { error: 'no such chat' });
|
|
101
|
+
const doc = store.documents.copyToChat(documentId, chat.id, auth.userId, retrieval.passageSettings());
|
|
102
|
+
if (!doc.existing)
|
|
103
|
+
ingest(doc);
|
|
104
|
+
return json(res, 200, { chatId: chat.id, document: doc });
|
|
105
|
+
} },
|
|
106
|
+
// Lets the "files" rail open a document's full extracted text -- the
|
|
107
|
+
// same content the model reads via read_document, in a plain new tab.
|
|
108
|
+
// Documents have no delete route of their own: the only way one goes
|
|
109
|
+
// away is editing the message that attached it (see /edit in chats.js).
|
|
110
|
+
{ method: 'GET', path: /^\/api\/documents\/([\w.-]+)$/, feature: FEATURE.ATTACHMENTS, handle: ({ res, auth, params: [id] }) => {
|
|
111
|
+
const doc = store.documents.get(id, auth.userId);
|
|
112
|
+
if (!doc)
|
|
113
|
+
return json(res, 404, { error: 'no such document' });
|
|
114
|
+
return json(res, 200, { filename: doc.filename, mime: doc.mime, content: doc.content });
|
|
115
|
+
} },
|
|
116
|
+
// What the "artifacts" rail opens: the file as uploaded. Documents stored
|
|
117
|
+
// before originals were kept fall back to their extracted text.
|
|
118
|
+
{ method: 'GET', path: /^\/api\/documents\/([\w.-]+)\/original$/, feature: FEATURE.ATTACHMENTS, handle: ({ res, auth, params: [id] }) => {
|
|
119
|
+
const doc = store.documents.get(id, auth.userId);
|
|
120
|
+
if (!doc)
|
|
121
|
+
return json(res, 404, { error: 'no such document' });
|
|
122
|
+
const original = store.documents.original(id, auth.userId);
|
|
123
|
+
const { type, inline } = original ? servingFor(doc.filename) : servingFor('.txt');
|
|
124
|
+
const body = original || Buffer.from(doc.content, 'utf8');
|
|
125
|
+
// The name is percent-encoded (RFC 5987) so it cannot break out of the header.
|
|
126
|
+
const disposition = `${inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(doc.filename)}`;
|
|
127
|
+
res.writeHead(200, { 'content-type': type, 'content-length': body.length, 'content-disposition': disposition });
|
|
128
|
+
res.end(body);
|
|
129
|
+
} },
|
|
130
|
+
// Converts one staged image to a wire-safe format before it's ever sent,
|
|
131
|
+
// so the composer's own preview and the optimistic thumbnail in the
|
|
132
|
+
// transcript show the same bytes the model (and the store) end up with,
|
|
133
|
+
// instead of a HEIC/AVIF the browser can't decode until the turn ends
|
|
134
|
+
// and the chat reloads with what the server stored.
|
|
135
|
+
{ method: 'POST', path: /^\/api\/images\/normalize$/, feature: FEATURE.IMAGES, handle: async ({ req, res }) => {
|
|
136
|
+
const { mime, dataBase64 } = await readJson(req, UPLOAD_LIMIT);
|
|
137
|
+
const normalized = await normalizeUpload(mime, dataBase64);
|
|
138
|
+
if (!normalized)
|
|
139
|
+
return json(res, 400, { error: 'unrecognized or oversized image' });
|
|
140
|
+
return json(res, 200, normalized);
|
|
141
|
+
} }
|
|
142
|
+
];
|
|
133
143
|
}
|
package/src/routes/library.js
CHANGED
|
@@ -1,51 +1,48 @@
|
|
|
1
1
|
import { json, readJson } from '../http.js';
|
|
2
2
|
import { FEATURE } from '../../public/shared/features.js';
|
|
3
|
-
|
|
4
3
|
/** The sidebar's search, usage statistics, and folders. */
|
|
5
4
|
export function libraryRoutes({ runs, store }) {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
} }
|
|
50
|
-
];
|
|
5
|
+
return [
|
|
6
|
+
// Full-text search across every stored message, for the sidebar's search
|
|
7
|
+
// box. GET with a query string, so it is bookmarkable and cacheable like
|
|
8
|
+
// any other read.
|
|
9
|
+
{ method: 'GET', path: /^\/api\/search$/, feature: FEATURE.SEARCH, handle: ({ res, auth, url }) => {
|
|
10
|
+
const q = url.searchParams.get('q') || '';
|
|
11
|
+
const limit = 30;
|
|
12
|
+
// A chat mid-turn is still being written -- store.search would be
|
|
13
|
+
// matching against text that has not settled, and openChat/rejoin is
|
|
14
|
+
// not built to land a click in the middle of a live stream. Overfetch
|
|
15
|
+
// and filter rather than ask the store to know about runs, which is a
|
|
16
|
+
// server-only concept it has no business importing.
|
|
17
|
+
const results = store.search.chats(q, limit * 2, auth.userId)
|
|
18
|
+
.filter((r) => !runs.isRunning(r.chatId))
|
|
19
|
+
.slice(0, limit);
|
|
20
|
+
return json(res, 200, { results });
|
|
21
|
+
} },
|
|
22
|
+
// ?model= narrows every figure to one model; adding &provider= to one
|
|
23
|
+
// upstream serving it.
|
|
24
|
+
{ method: 'GET', path: /^\/api\/usage$/, feature: FEATURE.STATISTICS, handle: ({ res, auth, url }) => {
|
|
25
|
+
const q = url.searchParams;
|
|
26
|
+
const filter = { model: q.get('model') || undefined, provider: q.has('provider') ? q.get('provider') : undefined };
|
|
27
|
+
return json(res, 200, { days: store.usage.rollup(auth.userId, filter), statistics: store.usage.statistics(auth.userId, filter) });
|
|
28
|
+
} },
|
|
29
|
+
{ method: 'GET', path: /^\/api\/folders$/, feature: FEATURE.FOLDERS, handle: ({ res, auth }) => json(res, 200, { folders: store.chats.listFolders(auth.userId) }) },
|
|
30
|
+
{ method: 'POST', path: /^\/api\/folders$/, feature: FEATURE.FOLDERS, handle: async ({ req, res, auth }) => {
|
|
31
|
+
const { name } = await readJson(req);
|
|
32
|
+
const created = store.chats.createFolder(name, auth.userId);
|
|
33
|
+
if (!created)
|
|
34
|
+
return json(res, 400, { error: 'folder name required' });
|
|
35
|
+
return json(res, 200, { folder: created });
|
|
36
|
+
} },
|
|
37
|
+
{ method: 'POST', path: /^\/api\/chats\/([\w.-]+)\/organize$/, feature: FEATURE.FOLDERS, handle: async ({ req, res, auth, params: [chatId] }) => {
|
|
38
|
+
const { folder, tags } = await readJson(req);
|
|
39
|
+
const found = store.chats.get(chatId, auth.userId);
|
|
40
|
+
if (!found)
|
|
41
|
+
return json(res, 404, { error: 'no such chat' });
|
|
42
|
+
if (folder)
|
|
43
|
+
store.chats.createFolder(folder, auth.userId);
|
|
44
|
+
const updated = store.chats.organize(chatId, { folder, tags }, auth.userId);
|
|
45
|
+
return json(res, 200, { folder: updated.folder });
|
|
46
|
+
} }
|
|
47
|
+
];
|
|
51
48
|
}
|
package/src/routes/mcp.js
CHANGED
|
@@ -5,55 +5,55 @@ import { actor } from '../access/auth_gate.js';
|
|
|
5
5
|
import { toolList } from './settings.js';
|
|
6
6
|
import { logger } from '../log.js';
|
|
7
7
|
import { FEATURE } from '../../public/shared/features.js';
|
|
8
|
-
|
|
9
8
|
const log = logger('mcp');
|
|
10
|
-
|
|
11
9
|
export function mcpRoutes({ auditMcp, config, hub, source, swapHub }) {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
10
|
+
/** Writes mcp.json (audited), then reconnects. A locked or invalid file is the caller's error. */
|
|
11
|
+
const saveAndReconnect = async (res, text, auth) => {
|
|
12
|
+
let servers;
|
|
13
|
+
try {
|
|
14
|
+
const before = fingerprint(config(), source.loadMcpServers());
|
|
15
|
+
servers = source.saveMcpFile(text);
|
|
16
|
+
auditMcp(actor(auth), before);
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
json(res, err instanceof LockedError ? 409 : 400, { error: err.message });
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
await swapHub(servers);
|
|
23
|
+
log.info(`reloaded: ${hub().tools.length} tool(s)`);
|
|
24
|
+
return true;
|
|
25
|
+
};
|
|
26
|
+
return [
|
|
27
|
+
// mcp.json can hold server credentials (env, headers): admins only.
|
|
28
|
+
{ method: 'GET', path: /^\/api\/mcp$/, feature: FEATURE.MCP, handle: ({ res }) => json(res, 200, { path: source.mcpPath(), text: source.readMcpFile(), locked: source.mcpLocked }) },
|
|
29
|
+
{ method: 'POST', path: /^\/api\/mcp$/, feature: FEATURE.MCP, handle: async ({ req, res, auth }) => {
|
|
30
|
+
const { text } = await readJson(req, UPLOAD_LIMIT);
|
|
31
|
+
if (typeof text !== 'string')
|
|
32
|
+
return json(res, 400, { error: 'text must be the contents of mcp.json' });
|
|
33
|
+
if (!await saveAndReconnect(res, text, auth))
|
|
34
|
+
return undefined;
|
|
35
|
+
return json(res, 200, { tools: toolList(hub()), mcpErrors: hub().errors });
|
|
36
|
+
} },
|
|
37
|
+
// Disabling a server tears its connection down rather than filtering its
|
|
38
|
+
// tools out client-side: an unwanted server is one fewer child process or
|
|
39
|
+
// open connection, not just one the model happens not to be offered.
|
|
40
|
+
{ method: 'POST', path: /^\/api\/mcp\/servers\/([^/]+)\/toggle$/, feature: FEATURE.MCP, handle: async ({ req, res, auth, params: [rawName] }) => {
|
|
41
|
+
const name = decodeURIComponent(rawName);
|
|
42
|
+
const { disabled } = await readJson(req);
|
|
43
|
+
let servers;
|
|
44
|
+
try {
|
|
45
|
+
const parsed = JSON.parse(source.readMcpFile());
|
|
46
|
+
servers = parsed.mcpServers || parsed;
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
return json(res, 500, { error: `mcp.json is not valid JSON: ${err.message}` });
|
|
50
|
+
}
|
|
51
|
+
if (!servers[name])
|
|
52
|
+
return json(res, 404, { error: `no such server "${name}"` });
|
|
53
|
+
servers[name] = { ...servers[name], disabled: Boolean(disabled) };
|
|
54
|
+
if (!await saveAndReconnect(res, JSON.stringify({ mcpServers: servers }, null, 2), auth))
|
|
55
|
+
return undefined;
|
|
56
|
+
return json(res, 200, { ...hub().inventory(config().disabledTools), disabledTools: config().disabledTools || [] });
|
|
57
|
+
} }
|
|
58
|
+
];
|
|
59
59
|
}
|