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